> Complete content index: https://narsil.sondelali.com/llms.txt

# 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.

URL: https://narsil.sondelali.com/docs/client-sdk
Section: Clients
Version: 0.3 (@delali/narsil@0.3.0, latest)

`@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.

## Connect and search

`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](/docs/http-server) page describes. Behind that server, the engine keeps its work on the main thread, so that the example starts no worker threads.

```ts title="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()
```

```json result open
[
  {
    "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](/docs/errors) page shows. `get` resolves `undefined` for an id that the index lacks.

```ts title="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'] } }) // [!code --]
const created = await client.put('articles', 'password-reset', { // [!code ++:13]
  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)) // [!code --]
console.log(JSON.stringify({ created, replaced, missing: missing ?? null }, null, 2))

await server.shutdown()
```

```json result open
{
  "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.

```ts title="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)) // [!code --]
await client.updatePartitionConfig('articles', { maxPartitions: 2 }) // [!code ++:16]
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()
```

```json result open
{
  "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](/docs/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](/docs/errors) covers the codes that the client throws and the entry points that share them.
- [React hooks](/docs/react-hooks) covers the hooks that wrap this client, with a search box as the example.
