Clients

Client SDK

Reach a Narsil server from Node, a browser, or an edge function with the same calls as the embedded engine, and handle the tasks, deadlines, and failures of a network call.

@delali/narsil/client sends your calls to a Narsil server over HTTP through methods that have the names and take the arguments of the embedded engine's methods, with request settings at the end, so a query written against one works against the other. The client sends each request through fetch and holds no connection, so it works in Node, in a browser, and in an edge function. You build search-client.ts here, in which you search a server, write documents to it, and follow a rebalance that the server carries on after the call returns. Each code block below holds the whole file, with green lines marking what each step adds and struck lines marking what it takes away.

createNarsilClient takes the server's address, along with an apiKey that the client sends as a bearer token on every request. In the first block of the file, an engine and a server start in the same process to stand in for the server that you deploy. The server's onRequest hook lets through only the requests that carry the key, as the HTTP server page describes. Behind that server, the engine keeps its work on the main thread, so that the example starts no worker threads.

search-client.ts
import { createNarsil } from '@delali/narsil'
import { createNarsilClient } from '@delali/narsil/client'
import { createServer } from '@delali/narsil/server'
 
const API_KEY = process.env.NARSIL_API_KEY ?? 'support-desk-key'
 
const engine = await createNarsil({ workers: { enabled: false } })
const server = createServer(engine, {
  host: '127.0.0.1',
  port: 7700,
  onRequest: (request) =>
    request.headers.authorization === `Bearer ${API_KEY}`
      ? undefined
      : { status: 401, code: 'UNAUTHORISED', message: 'Send the API key as a bearer token.' },
})
 
await server.listen()
 
const client = createNarsilClient({ url: 'http://127.0.0.1:7700', apiKey: API_KEY })
 
await client.createIndex('articles', { schema: { title: 'string', body: 'string' } })
await client.insertBatch('articles', [
  { id: 'refund-timing', title: 'How a refund reaches your bank', body: 'A refund returns to the card that paid for the order.' },
  { id: 'parcel-tracking', title: 'Track a parcel after dispatch', body: 'Every dispatched order has a tracking code.' },
  { id: 'refund-currency', title: 'Refunds in another currency', body: 'Your bank converts a refund at the rate on the day it arrives.' },
])
await client.waitForWrites('articles')
 
const found = await client.query('articles', { term: 'refund', document: { include: ['title'] } })
 
console.log(JSON.stringify(found.hits.map((hit) => ({ id: hit.id, title: hit.document?.title })), null, 2))
 
await server.shutdown()
[
  {
    "id": "refund-timing",
    "title": "How a refund reaches your bank"
  },
  {
    "id": "refund-currency",
    "title": "Refunds in another currency"
  }
]

The server answers with the hits that query returns in process. waitForWrites matters more here than it does on an embedded engine, because a server sends every search on a large index to a worker copy, which applies each write after the main copy does.

Write a document under your own id

put writes a document at the id that you give it and returns created, which is true where the call created the document and false where it replaced one. On the embedded engine you would have to combine insert and update, because insert fails on an id that the index holds and update fails on one that it lacks, as the errors page shows. get resolves undefined for an id that the index lacks.

search-client.ts
import { createNarsil } from '@delali/narsil'
import { createNarsilClient } from '@delali/narsil/client'
import { createServer } from '@delali/narsil/server'
 
const API_KEY = process.env.NARSIL_API_KEY ?? 'support-desk-key'
 
const engine = await createNarsil({ workers: { enabled: false } })
const server = createServer(engine, {
  host: '127.0.0.1',
  port: 7700,
  onRequest: (request) =>
    request.headers.authorization === `Bearer ${API_KEY}`
      ? undefined
      : { status: 401, code: 'UNAUTHORISED', message: 'Send the API key as a bearer token.' },
})
 
await server.listen()
 
const client = createNarsilClient({ url: 'http://127.0.0.1:7700', apiKey: API_KEY })
 
await client.createIndex('articles', { schema: { title: 'string', body: 'string' } })
await client.insertBatch('articles', [
  { id: 'refund-timing', title: 'How a refund reaches your bank', body: 'A refund returns to the card that paid for the order.' },
  { id: 'parcel-tracking', title: 'Track a parcel after dispatch', body: 'Every dispatched order has a tracking code.' },
  { id: 'refund-currency', title: 'Refunds in another currency', body: 'Your bank converts a refund at the rate on the day it arrives.' },
])
await client.waitForWrites('articles')
 
const found = await client.query('articles', { term: 'refund', document: { include: ['title'] } }) 
const created = await client.put('articles', 'password-reset', { 
  title: 'Reset your password',
  body: 'Request a reset link from the sign-in page.',
})
const replaced = await client.put('articles', 'password-reset', {
  title: 'Reset your password',
  body: 'Request a reset link from the sign-in page, and it expires after one hour.',
})
const missing = await client.get('articles', 'gift-cards')
 
console.log(JSON.stringify(found.hits.map((hit) => ({ id: hit.id, title: hit.document?.title })), null, 2)) 
console.log(JSON.stringify({ created, replaced, missing: missing ?? null }, null, 2))
 
await server.shutdown()
{
  "created": {
    "id": "password-reset",
    "created": true
  },
  "replaced": {
    "id": "password-reset",
    "created": false
  },
  "missing": null
}

The first put creates the article and the second replaces it, so created holds true and then false.

Follow the work that outlasts a request

A rebalance, a restore, an analysis rebuild, a vector optimisation, and a started import can take longer than a request should wait. For each of them, the server answers at once with a task record and carries on with the work. waitForTask(id) then polls that task every 250 ms until the work finishes.

search-client.ts
import { createNarsil } from '@delali/narsil'
import { createNarsilClient } from '@delali/narsil/client'
import { createServer } from '@delali/narsil/server'
 
const API_KEY = process.env.NARSIL_API_KEY ?? 'support-desk-key'
 
const engine = await createNarsil({ workers: { enabled: false } })
const server = createServer(engine, {
  host: '127.0.0.1',
  port: 7700,
  onRequest: (request) =>
    request.headers.authorization === `Bearer ${API_KEY}`
      ? undefined
      : { status: 401, code: 'UNAUTHORISED', message: 'Send the API key as a bearer token.' },
})
 
await server.listen()
 
const client = createNarsilClient({ url: 'http://127.0.0.1:7700', apiKey: API_KEY })
 
await client.createIndex('articles', { schema: { title: 'string', body: 'string' } })
await client.insertBatch('articles', [
  { id: 'refund-timing', title: 'How a refund reaches your bank', body: 'A refund returns to the card that paid for the order.' },
  { id: 'parcel-tracking', title: 'Track a parcel after dispatch', body: 'Every dispatched order has a tracking code.' },
  { id: 'refund-currency', title: 'Refunds in another currency', body: 'Your bank converts a refund at the rate on the day it arrives.' },
])
await client.waitForWrites('articles')
 
const created = await client.put('articles', 'password-reset', {
  title: 'Reset your password',
  body: 'Request a reset link from the sign-in page.',
})
const replaced = await client.put('articles', 'password-reset', {
  title: 'Reset your password',
  body: 'Request a reset link from the sign-in page, and it expires after one hour.',
})
const missing = await client.get('articles', 'gift-cards')
 
console.log(JSON.stringify({ created, replaced, missing: missing ?? null }, null, 2)) 
await client.updatePartitionConfig('articles', { maxPartitions: 2 }) 
const started = await client.rebalance('articles', 2)
const finished = await client.waitForTask(started.id, { waitTimeoutMs: 60_000 })
const stats = await client.getStats('articles')
 
console.log(
  JSON.stringify(
    {
      started: { type: started.type, status: started.status },
      finished: finished.status,
      partitionCount: stats.partitionCount,
    },
    null,
    2
  )
)
 
await server.shutdown()
{
  "started": {
    "type": "rebalance",
    "status": "running"
  },
  "finished": "succeeded",
  "partitionCount": 2
}

The server answers the rebalance with a record whose status is running. The wait then resolves once the status reaches succeeded, by which point the index holds two partitions. A task can also end as failed, with the error in the record's error field, or as cancelled after cancelTask(id). With waitTimeoutMs, the wait fails with CLIENT_TASK_TIMEOUT once that time passes, while the server carries on with the task.

Set deadlines and handle failures

The client gives every request 30 seconds, after which the request fails with CLIENT_REQUEST_TIMEOUT. Set timeoutMs on the client to change that for every request, or pass { timeoutMs } as the last argument of one call, where a value of 0 means that the client waits for as long as the server takes. importDocuments, snapshot, and restore move a whole corpus or a whole index, so they wait without a deadline unless you set timeoutMs on the client or on the call. Pass { signal } to cancel a call from your side, which then fails with CLIENT_REQUEST_ABORTED.

The client throws each failure as a NarsilError. For a failure that the server reports, the client keeps the server's code and adds the HTTP status to details, while CLIENT_CONNECTION_FAILED means that the client reached no server at the address. createNarsilClient checks the address at once and fails with CONFIG_INVALID for a value that it cannot read as an http or https URL or as a path. Errors covers the codes and why isNarsilError is the test to use.

In a browser, pass a path such as /search-api as the url, which the browser resolves against the page. Have your own server forward the requests under that path to Narsil with the key attached, so that the key stays off the page. Where a page calls the Narsil server from another origin, list that origin in the server's cors option. With maxResponseBytes, the client stops reading an answer past that many bytes and fails with CLIENT_INVALID_RESPONSE, which protects a browser tab from an answer that grows without limit.

Where to go next

  • Errors covers the codes that the client throws and the entry points that share them.
  • React hooks covers the hooks that wrap this client, with a search box as the example.