@nohead/sdk 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,88 @@ Changes to `@nohead/sdk` that you can notice. Versions follow
5
5
  releases; a change that could break your code is a major one. Each release's
6
6
  section is its GitHub release's notes.
7
7
 
8
+ ## 0.9.0
9
+
10
+ - **Breaking:** the client no longer reads `NOHEAD_API_URL`: a stray value
11
+ sent requests, with the API key, to another host. The base URL is
12
+ `baseUrl`, else `https://api.nohead.io`; pass `baseUrl` to use another
13
+ API, as in `new Nohead({ baseUrl: process.env.NOHEAD_API_URL })`.
14
+
15
+ - An `APIError`'s `message` names the status, the error type and the
16
+ request ID along with the API's message, as in the Python and Ruby SDKs:
17
+ `422 validation_error: Title is required (request req_…)`, where it was
18
+ only `Title is required`. The API's own text is `apiMessage`.
19
+
20
+ - `unwrapWebhook` and `webhooks.unwrap` throw a `WebhookVerificationError`
21
+ for an empty secret (`""` or `"whsec_"`, as from an unset environment
22
+ variable), not the platform's `DOMException`.
23
+
24
+ - `DEFAULT_BASE_URL` is exported: the API's address (`https://api.nohead.io`)
25
+ that a client uses when `baseUrl` isn't set.
26
+
27
+ - `timeout` is now the longest an attempt may go without progress: to get
28
+ the response, then between chunks of its body. A large response that keeps
29
+ arriving no longer times out; one that stalls still does. An upload's bytes
30
+ get as long as sending them at 64 KiB/s takes, if that is longer.
31
+
32
+ - `withOptions({ timeout, maxRetries, headers, onRetry })` copies a client
33
+ with other settings; the copy shares the key's project lookup. `headers`
34
+ are added to the client's.
35
+
36
+ - Redirects are no longer followed, which could send the API key to another
37
+ host or a write as a GET: a `3xx` throws an `APIError` with `type`
38
+ `redirect` and the message
39
+ `301 redirect: The API answered with a redirect to <Location>; check the base URL`.
40
+ A redirect from storage fails an upload with `UploadError`.
41
+
42
+ - Headers are matched by name in any case, and each is sent once. A call's
43
+ `headers` replace the client's, which replace the SDK's own; the headers
44
+ the request options set (`Idempotency-Key`, `If-Match`,
45
+ `Nohead-Change-Note`, `Content-Type`) always win. A client header
46
+ `authorization` was sent along with the SDK's `Authorization`.
47
+
48
+ - Empty options count as unset: `apiKey`, `baseUrl` and `projectId` of `""`
49
+ fall back to `NOHEAD_API_KEY` and the defaults, an `idempotencyKey` of
50
+ `""` gets a generated key, an upload's `filename` and `content_type` of
51
+ `""` their defaults, and `imageUrl`'s empty options are left out.
52
+
53
+ - A `baseUrl` that isn't an `http` or `https` URL throws a `NoheadError`
54
+ when the client is made, not on its first request.
55
+
56
+ - A path value of `.` or `..` (`records.get("..")`) throws a `NoheadError`,
57
+ `Invalid record_id: ".."`, before anything is sent: URLs resolve them.
58
+
59
+ - `ifMatch` that isn't a revision number, or an object with one, throws a
60
+ `TypeError` before anything is sent, where an object without one sent
61
+ `If-Match: "undefined"`.
62
+
63
+ - `NaN` and `Infinity` in a body throw a `TypeError`, where they were sent as
64
+ `null`.
65
+
66
+ - A list whose response isn't the API's list (a proxy's page, say) throws a
67
+ `NoheadError`, `The API's response could not be read (HTTP 200)`, not a
68
+ `TypeError` or a page without items.
69
+
70
+ - `Retry-After` is read as a number of seconds (`120`, `1.5`) or an HTTP
71
+ date, in all three of its forms, in GMT. Anything else (`1e3`, `0x10`,
72
+ `-1`, an ISO 8601 time) is ignored and the usual backoff applies.
73
+
74
+ - An error's details that aren't objects are kept, as
75
+ `{ code: "unknown", message: <the value as text> }`, rather than dropped.
76
+
77
+ - `assets.upload` sends a stream with `Content-Length: byte_size`, which the
78
+ upload URL is signed for; storage could refuse it without one. Given a string,
79
+ it throws a `TypeError`: pass `await fs.openAsBlob(path)` for a file. Its
80
+ `UploadError` messages name the filename you passed (or `upload`).
81
+
82
+ - `unwrapWebhook` and `webhooks.unwrap` take an unset `secret` (`undefined`,
83
+ as from a missing environment variable) and throw `WebhookVerificationError`
84
+ `The webhook secret is empty`, not a `TypeError`.
85
+
86
+ - `imageUrl` throws a `TypeError` for an unknown `fit`
87
+ (`fit must be one of contain, cover, fill`), where it wrote `rs:undefined`
88
+ into the URL.
89
+
8
90
  ## 0.8.0
9
91
 
10
92
  - `Asset` has `file_url`, the file as uploaded, which downloads under its
package/README.md CHANGED
@@ -40,28 +40,39 @@ for await (const post of nohead.records.list("posts", {
40
40
  npm install @nohead/sdk
41
41
  ```
42
42
 
43
- The package is ESM; Node 22+ can also `require()` it. It runs on Node 22, 24 and 26, Bun, Deno, Cloudflare Workers and other runtimes with `fetch` and Web Crypto. Use it on servers: API keys are secrets.
43
+ The package is ESM; Node 22+ can also `require()` it. It runs on Node 22, 24 and 26, Bun, Deno, Cloudflare Workers and other runtimes with `fetch`, Web Crypto and ES2024. Use it on servers: API keys are secrets.
44
+
45
+ Until 1.0, any release may break your code: pin a version, and read the changelog's **Breaking** entries before upgrading.
44
46
 
45
47
  ## Configuration
46
48
 
47
49
  ```ts
48
50
  const nohead = new Nohead({
49
- apiKey: process.env.NOHEAD_API_KEY, // default: NOHEAD_API_KEY
50
- baseUrl: "https://api.nohead.io", // default: NOHEAD_API_URL, else production
51
+ apiKey: process.env.NOHEAD_API_KEY, // the default
52
+ timeout: 10_000,
53
+ maxRetries: 3,
51
54
  })
52
55
  ```
53
56
 
54
- | Option | Default | |
55
- | ------------ | ---------------------------------------------- | ---------------------------------------------------------------------------------- |
56
- | `apiKey` | `NOHEAD_API_KEY` | A project API key (`nohead_sk_…`, or `nohead_pk_…` when published-only). Required. |
57
- | `baseUrl` | `NOHEAD_API_URL`, else `https://api.nohead.io` | |
58
- | `projectId` | the key's project | Looked up once with `GET /v1/me` when omitted. |
59
- | `maxRetries` | `2` | See [retries](#retries-and-idempotency). |
60
- | `timeout` | `60000` | Milliseconds per attempt. |
61
- | `fetch` | `globalThis.fetch` | For proxies, instrumentation and tests. |
62
- | `headers` | `{}` | Added to every request. |
63
- | `warnings` | `true` | Logs deprecation and plan usage warnings, once each. |
64
- | `onRetry` | | Called before each retry. See [retries](#retries-and-idempotency). |
57
+ | Option | Default | |
58
+ | ------------ | ----------------------- | ----------------------------------------------------------------------------------------- |
59
+ | `apiKey` | `NOHEAD_API_KEY` | A project API key (`nohead_sk_…`, or `nohead_pk_…` when published-only). Required. |
60
+ | `baseUrl` | `https://api.nohead.io` | For a proxy, or a mock server in tests. |
61
+ | `projectId` | the key's project | Looked up once with `GET /v1/me` when omitted. |
62
+ | `maxRetries` | `2` | See [retries](#retries-and-idempotency). |
63
+ | `timeout` | `60000` | Milliseconds an attempt may go without progress. See [retries](#retries-and-idempotency). |
64
+ | `fetch` | `globalThis.fetch` | For proxies, instrumentation and tests. |
65
+ | `headers` | `{}` | Added to every request. |
66
+ | `warnings` | `true` | Logs deprecation and plan usage warnings, once each. |
67
+ | `onRetry` | | Called before each retry. See [retries](#retries-and-idempotency). |
68
+
69
+ An empty option counts as unset, as an empty environment variable does: `apiKey: ""` falls back to `NOHEAD_API_KEY`, and `baseUrl: ""` to `https://api.nohead.io`. The base URL comes only from `baseUrl`, never from the environment. A `baseUrl` that isn't an `http` or `https` URL throws.
70
+
71
+ `withOptions` copies a client with another `timeout`, `maxRetries` or `onRetry`, or more `headers` (added to the client's). The copy shares the key's project, so it doesn't look it up again:
72
+
73
+ ```ts
74
+ const patient = nohead.withOptions({ timeout: 120_000, maxRetries: 5 })
75
+ ```
65
76
 
66
77
  API keys belong to a project, so methods like `collections.list()` need no project ID. Collections can be named by ID or slug everywhere.
67
78
 
@@ -190,6 +201,10 @@ Every error is a `NoheadError`. API errors are `APIError`s with `status`, `type`
190
201
  | `InternalServerError` | 500 and other 5xx |
191
202
  | `ServiceUnavailableError` | 503 |
192
203
 
204
+ An API error's `message` names the status, the type and the request ID along with the API's message, so logging it is enough to look the request up: `422 validation_error: Title is required (request req_…)`. The API's own text is `apiMessage`, to show a person.
205
+
206
+ The client follows no redirects: a `3xx` is an `APIError` (`type` `redirect`) that names where it pointed, which usually means the base URL is wrong.
207
+
193
208
  Other errors: `ConnectionError`, `TimeoutError` (a `ConnectionError`), `AbortError` (your `signal` fired), `UploadError` and `WebhookVerificationError`.
194
209
 
195
210
  ```ts
@@ -241,6 +256,10 @@ Every method takes request options as its last argument:
241
256
 
242
257
  Writes that change records or the schema also take `changeNote`, a reason kept with the revision, schema change or migration they make. Record and draft writes take `ifMatch` (see [concurrency](#concurrency)).
243
258
 
259
+ Headers are matched by name in any case, and each is sent once: a call's `headers` replace the client's, which replace the SDK's own (`Authorization`, `User-Agent`…). The headers the request options set (`Idempotency-Key`, `If-Match`, `Nohead-Change-Note`, and `Content-Type` for a body) always win.
260
+
261
+ **Timeouts:** `timeout` is the longest an attempt may go without progress: to connect and get the response, then between chunks of its body. A response that keeps arriving never times out; one that stalls for `timeout` throws `TimeoutError`, and is retried. fetch shows no progress while it sends an upload's bytes, so storage gets as long as sending them at 64 KiB/s takes, when that is longer than `timeout`.
262
+
244
263
  ## Concurrency
245
264
 
246
265
  Pass the revision you read to make sure nobody changed the record since:
@@ -302,11 +321,11 @@ Thumbnails: an image's `preview_url` fits in 640×640, as WebP.
302
321
  2. Sends the bytes straight to storage.
303
322
  3. Completes the upload, which checks the file, and returns the `ready` asset.
304
323
 
305
- **Errors:** `UploadError` if storage refuses the bytes; `ValidationError` if the file fails the checks.
324
+ A string is neither a path nor the content: it throws `TypeError` (use `await openAsBlob(path)` from `node:fs`). A stream is sent with `Content-Length: byte_size`, which the upload URL is signed for.
306
325
 
307
- **Resuming:** pass an `idempotencyKey` (`upload(file, {}, { idempotencyKey })`) and running the same upload again resumes it: an asset that is already `ready` comes back without sending the bytes again. Once the upload's URL has expired (its `upload.expires_at`), it throws `UploadError`: start over with a new key.
326
+ **Errors:** `UploadError` whenever the bytes don't reach storage: `Storage refused the upload of cover.jpg (403)` when it refuses them, with its `status`, or `Could not upload cover.jpg: …` when it can't be reached or times out, with that error as the `cause`. `ValidationError` if the file fails the checks.
308
327
 
309
- **Uploading from a browser:** create the upload on your server with `createUpload`, `PUT` the file from the browser, then `complete` it.
328
+ **Resuming:** pass an `idempotencyKey` (`upload(file, {}, { idempotencyKey })`) and running the same upload again resumes it: an asset that is already `ready` comes back without sending the bytes again. Once the upload's URL has expired (its `upload.expires_at`), it throws `UploadError`: start over with a new key.
310
329
 
311
330
  **Renaming and alt text:** `update` changes an asset's `filename` and `alt` text, the default wherever the asset is used (`expand` returns it with the asset). The file itself never changes.
312
331
 
@@ -379,7 +398,7 @@ export async function POST(request: Request) {
379
398
  await request.text(),
380
399
  request.headers,
381
400
  {
382
- secret: process.env.NOHEAD_WEBHOOK_SECRET!,
401
+ secret: process.env.NOHEAD_WEBHOOK_SECRET,
383
402
  }
384
403
  )
385
404
  if (event.type === "record.published" && event.data.record) {
@@ -391,7 +410,7 @@ export async function POST(request: Request) {
391
410
 
392
411
  With `"max"`, Next.js serves the old version to the next request while it fetches the new one; pass `{ expire: 0 }` instead to drop it at once.
393
412
 
394
- Event types are those of the activity log (`record.published`, `record.restored`, `field.updated`, `asset.purged`, …), and a webhook subscribes to types or wildcards (`record.*`, `*`). Narrowing by `type` types `event.data`, and `unwrap<Post>` types the records and drafts in it. `unwrap` checks the signature and the timestamp (Standard Webhooks), and throws `WebhookVerificationError` if either is off. After `rotateSecret`, requests carry a signature for the old secret and the new one for 24 hours, so switch the receiver's secret within that day (or end it sooner with `webhooks.secrets.retire`). Pass the raw body: parsing and re-serializing JSON changes the bytes.
413
+ Event types are those of the activity log (`record.published`, `record.restored`, `field.updated`, `asset.purged`, …), and a webhook subscribes to types or wildcards (`record.*`, `*`). Narrowing by `type` types `event.data`, and `unwrap<Post>` types the records and drafts in it. `unwrap` checks the signature and the timestamp (Standard Webhooks), and throws `WebhookVerificationError` if either is off, or if the secret is empty or unset (`The webhook secret is empty`). After `rotateSecret`, requests carry a signature for the old secret and the new one for 24 hours, so switch the receiver's secret within that day (or end it sooner with `webhooks.secrets.retire`). Pass the raw body: parsing and re-serializing JSON changes the bytes.
395
414
 
396
415
  A receiver that never calls the API can use `unwrapWebhook` from `@nohead/sdk/webhooks` without a client. Events can arrive more than once, so deduplicate by the `webhook-id` header.
397
416
 
@@ -434,28 +453,37 @@ The SDK covers every operation an API key can call. Organizations, projects, mem
434
453
  ```bash
435
454
  npm install # also builds dist/
436
455
  npm test # unit and contract tests
437
- npm run lint && npm run typecheck && npm run format:check
456
+ npm run lint && npm run typecheck && npm run typecheck:smoke && npm run format:check
438
457
  npm run generate # after updating openapi.json
439
458
  npm run samples # after changing test/calls.ts (the docs' code samples)
440
459
  NOHEAD_API_URL=http://localhost:3000 NOHEAD_API_KEY=nohead_sk_… npm run smoke
441
460
  ```
442
461
 
462
+ Before pushing, also check that the generated code and the samples are current, as CI does:
463
+
464
+ ```bash
465
+ npm run generate && npm run samples && git diff --exit-code src/generated samples.json
466
+ ```
467
+
468
+ The unit tests' mock client (`mockClient` in `test/helpers.ts`) checks every request to the API, and every reply to one, against `openapi.json`, and fails the test on a mismatch; build replies from the contract's examples (`exampleOf`), and mark one that breaks the contract on purpose with `raw()`.
469
+
443
470
  **How the code is organized:**
444
471
 
445
- - `openapi.json` is the API's published contract. `npm run generate` derives two things from it:
472
+ - `openapi.json` is the API's published contract. `npm run generate` derives three things from it:
446
473
  - the types, in `src/generated/types.gen.ts` (hey-api)
447
474
  - the operation table, in `src/generated/operations.gen.ts`
475
+ - the schema types the SDK exports, in `src/generated/schemas.gen.ts`
448
476
  - The methods in `src/resources` are written by hand.
449
477
  - `test/contract.test.ts` calls every public method. It fails when an API-key operation in the contract has no method, when a query parameter is never sent (no method takes it, or no call passes it), or when a request doesn't match its operation (method, path, query parameters and JSON body, against their schemas). Each call gets an example of its operation's response, built from the contract's schema, and must return it.
450
478
 
451
- The smoke test (`smoke/smoke.ts`) runs the core flow against a real API, using the built package. Nohead's own CI runs it on every API contract change.
479
+ The smoke test (`smoke/smoke.ts`) runs the core flow against a real API, using the built package. The Nohead API repository runs it in its end-to-end tests and before every release.
452
480
 
453
481
  ## Releasing
454
482
 
455
- 1. Bump the version in `package.json` and `src/version.ts`.
483
+ 1. Bump the version in `package.json` and `src/version.ts`, then `npm install --package-lock-only`, so `package-lock.json` records it.
456
484
  2. Add a section for it to `CHANGELOG.md` (`## 1.2.3`), which becomes the release's notes.
457
485
  3. Merge to `main`. Its ruleset requires the **CI passed** check, so the commit goes through a pull request or a branch whose CI passed, and force pushes are refused.
458
- 4. Run the **SDK release** workflow in the Nohead API repository. It runs this commit's smoke test against the API and pushes the tag `v1.2.3`. Nobody else can push `v*` tags: a tag ruleset lets only that workflow's deploy key through.
486
+ 4. Run the **SDK release** workflow in the Nohead API repository. It runs this commit's smoke test against the API and pushes the tag `v1.2.3`. A tag ruleset lets only deploy keys push `v*` tags.
459
487
  5. The tag starts `.github/workflows/release.yml`. A build job checks the tag is on `main` and matches the version and its notes, and packs the package, with no publishing credential. Its publishing job runs in the `release` environment, which only `v*` tags can use, and the registry's trusted publisher accepts only that environment. It stages that package on npm with provenance through trusted publishing (no token), then creates a draft GitHub release.
460
488
  6. Approve the staged version with 2FA, on npmjs.com, or with `npm stage list @nohead/sdk` and `npm stage approve <stage-id>`. Only then is it installable. Publish the draft GitHub release.
461
489
 
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { PagePromise } from "./api-promise.ts";
2
- import { type ClientOptions, type RequestOptions } from "./core.ts";
2
+ import { type ClientOptions, type CopyOptions, type RequestOptions } from "./core.ts";
3
3
  import type * as G from "./generated/types.gen.ts";
4
4
  import { Assets } from "./resources/assets.ts";
5
5
  import { Drafts } from "./resources/drafts.ts";
@@ -31,6 +31,14 @@ export declare class Nohead {
31
31
  readonly me: Me;
32
32
  readonly health: Health;
33
33
  constructor(options?: ClientOptions);
34
+ /**
35
+ * A copy of the client with another `timeout`, `maxRetries` or `onRetry`,
36
+ * or more `headers` (added to this client's). It shares the key's project,
37
+ * so it doesn't look it up again.
38
+ *
39
+ * await nohead.withOptions({ timeout: 5_000 }).records.get(id)
40
+ */
41
+ withOptions(options: CopyOptions): Nohead;
34
42
  /**
35
43
  * Full-text search across the key's project (or some of its
36
44
  * collections), most relevant first, through the first 1,000 hits.
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Core } from "./core.js";
1
+ import { Core, } from "./core.js";
2
2
  import { Assets } from "./resources/assets.js";
3
3
  import { Drafts } from "./resources/drafts.js";
4
4
  import { AuditEvents, FeatureFlags, Health, Me } from "./resources/other.js";
@@ -28,7 +28,7 @@ export class Nohead {
28
28
  health;
29
29
  #core;
30
30
  constructor(options = {}) {
31
- const core = new Core(options);
31
+ const core = options[COPY_OF] ?? new Core(options);
32
32
  this.#core = core;
33
33
  this.records = new Records(core);
34
34
  this.revisions = new RevisionsById(core);
@@ -43,6 +43,16 @@ export class Nohead {
43
43
  this.me = new Me(core);
44
44
  this.health = new Health(core);
45
45
  }
46
+ /**
47
+ * A copy of the client with another `timeout`, `maxRetries` or `onRetry`,
48
+ * or more `headers` (added to this client's). It shares the key's project,
49
+ * so it doesn't look it up again.
50
+ *
51
+ * await nohead.withOptions({ timeout: 5_000 }).records.get(id)
52
+ */
53
+ withOptions(options) {
54
+ return new Nohead({ [COPY_OF]: this.#core.withOptions(options) });
55
+ }
46
56
  /**
47
57
  * Full-text search across the key's project (or some of its
48
58
  * collections), most relevant first, through the first 1,000 hits.
@@ -61,3 +71,5 @@ export class Nohead {
61
71
  }, options);
62
72
  }
63
73
  }
74
+ // The core a copy is made with (`withOptions`), out of ClientOptions' sight.
75
+ const COPY_OF = Symbol("copyOf");
package/dist/core.d.ts CHANGED
@@ -9,13 +9,19 @@ export interface ClientOptions {
9
9
  * Defaults to `NOHEAD_API_KEY`.
10
10
  */
11
11
  apiKey?: string;
12
- /** Defaults to `NOHEAD_API_URL`, else `https://api.nohead.io`. */
12
+ /**
13
+ * The API's address. Defaults to `https://api.nohead.io`; the environment
14
+ * isn't read for it, so the key only goes where the code says.
15
+ */
13
16
  baseUrl?: string;
14
17
  /** The key's project. Looked up once with `GET /v1/me` when omitted. */
15
18
  projectId?: string;
16
19
  /** Retries of failed requests (see README, "Retries"). Default 2. */
17
20
  maxRetries?: number;
18
- /** Milliseconds per attempt. Default 60 000. */
21
+ /**
22
+ * Milliseconds an attempt may go without progress (connecting, waiting for
23
+ * the response, then between chunks of its body). Default 60 000.
24
+ */
19
25
  timeout?: number;
20
26
  /** A `fetch` implementation, for proxies, instrumentation or tests. */
21
27
  fetch?: typeof fetch;
@@ -26,6 +32,8 @@ export interface ClientOptions {
26
32
  /** Called before each retry, for example to report it. */
27
33
  onRetry?: (retry: RetryEvent) => void;
28
34
  }
35
+ /** What `withOptions` changes in a client's copy. */
36
+ export type CopyOptions = Pick<ClientOptions, "timeout" | "maxRetries" | "headers" | "onRetry">;
29
37
  /** A retry about to happen (`onRetry`). */
30
38
  export interface RetryEvent {
31
39
  /** 1 for the first retry of the request, 2 for the second… */
@@ -43,7 +51,7 @@ export interface RequestOptions {
43
51
  /** Sent as `Idempotency-Key` on writes. Generated when omitted. */
44
52
  idempotencyKey?: string;
45
53
  signal?: AbortSignal;
46
- /** Milliseconds per attempt, overriding the client's. */
54
+ /** Milliseconds without progress, overriding the client's `timeout`. */
47
55
  timeout?: number;
48
56
  /** Retries, overriding the client's. */
49
57
  maxRetries?: number;
@@ -92,15 +100,24 @@ export interface RequestInput {
92
100
  }
93
101
  export declare class Core {
94
102
  #private;
95
- constructor(options: ClientOptions);
103
+ constructor(options: ClientOptions, project?: {
104
+ id?: Promise<string>;
105
+ });
106
+ /** A copy with other settings, sharing the project lookup. */
107
+ withOptions(options: CopyOptions): Core;
96
108
  /** The API key's project, from `GET /v1/me` the first time. */
97
109
  projectId(): Promise<string>;
98
110
  request<Op extends OperationId>(operation: Op, input?: RequestInput, options?: OptionsOf<Op>): APIPromise<Operations[Op]["response"]>;
99
111
  /** A list operation as pages of `Item`. */
100
112
  paginate<Item, Meta extends ListMeta = ListMeta>(operation: OperationId, input: RequestInput, options?: RequestOptions): PagePromise<Item, Meta>;
101
- /** `fetch` with this client's timeout and retries, for presigned URLs. */
113
+ /**
114
+ * `fetch` with this client's retries, for an upload's presigned URL. fetch
115
+ * shows no upload progress, so each attempt may take as long as sending
116
+ * `byteSize` at 64 KiB/s, if that is longer than the timeout.
117
+ */
102
118
  fetchWithRetries(url: string, init: RequestInit, options: RequestOptions & {
103
119
  retry: boolean;
120
+ byteSize: number;
104
121
  }): Promise<Response>;
105
122
  }
106
123
  /**
package/dist/core.js CHANGED
@@ -1,16 +1,12 @@
1
1
  import { APIPromise, Page, PagePromise, } from "./api-promise.js";
2
- import { AbortError, ConnectionError, NoheadError, TimeoutError, apiError, retryAfterSeconds, } from "./errors.js";
2
+ import { AbortError, ConnectionError, NoheadError, TimeoutError, apiError, isObject, retryAfterSeconds, } from "./errors.js";
3
3
  import { OPERATIONS, } from "./generated/operations.gen.js";
4
4
  import { VERSION } from "./version.js";
5
5
  export const DEFAULT_BASE_URL = "https://api.nohead.io";
6
- /**
7
- * The string with lone surrogates (half an emoji, from cutting a string)
8
- * replaced by U+FFFD, as `String.prototype.toWellFormed` does, so
9
- * `encodeURIComponent` can encode it.
10
- */
11
- const wellFormed = (text) => text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
12
6
  const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
13
7
  const MAX_RETRY_AFTER_SECONDS = 60;
8
+ // Once per process, not per client: an app that makes a client per request
9
+ // (as on Workers) would otherwise warn on every one.
14
10
  const warnedDeprecations = new Set();
15
11
  let warnedUsage = false;
16
12
  export class Core {
@@ -22,37 +18,51 @@ export class Core {
22
18
  #timeout;
23
19
  #warnings;
24
20
  #onRetry;
25
- #projectId;
26
- constructor(options) {
27
- const env = environment();
28
- // Empty environment variables count as unset.
29
- const apiKey = options.apiKey ?? (env.NOHEAD_API_KEY || undefined);
21
+ // The project lookup, shared with the client's copies (`withOptions`).
22
+ #project;
23
+ constructor(options, project = {}) {
24
+ // Empty options and environment variables count as unset.
25
+ const apiKey = options.apiKey || environment().NOHEAD_API_KEY;
30
26
  if (!apiKey) {
31
27
  throw new NoheadError("Missing API key: pass `apiKey` or set NOHEAD_API_KEY");
32
28
  }
33
29
  this.#apiKey = apiKey;
34
- this.#baseUrl = withoutTrailingSlashes(options.baseUrl ?? (env.NOHEAD_API_URL || DEFAULT_BASE_URL));
30
+ this.#baseUrl = baseUrlOf(options.baseUrl || DEFAULT_BASE_URL);
35
31
  this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
36
32
  this.#headers = options.headers ?? {};
37
33
  this.#maxRetries = options.maxRetries ?? 2;
38
34
  this.#timeout = options.timeout ?? 60_000;
39
35
  this.#warnings = options.warnings ?? true;
40
36
  this.#onRetry = options.onRetry;
37
+ this.#project = project;
41
38
  if (options.projectId)
42
- this.#projectId = Promise.resolve(options.projectId);
39
+ project.id ??= Promise.resolve(options.projectId);
40
+ }
41
+ /** A copy with other settings, sharing the project lookup. */
42
+ withOptions(options) {
43
+ return new Core({
44
+ apiKey: this.#apiKey,
45
+ baseUrl: this.#baseUrl,
46
+ fetch: this.#fetch,
47
+ headers: { ...this.#headers, ...options.headers },
48
+ maxRetries: options.maxRetries ?? this.#maxRetries,
49
+ timeout: options.timeout ?? this.#timeout,
50
+ warnings: this.#warnings,
51
+ onRetry: options.onRetry ?? this.#onRetry,
52
+ }, this.#project);
43
53
  }
44
54
  /** The API key's project, from `GET /v1/me` the first time. */
45
55
  projectId() {
46
- this.#projectId ??= this.request("me_get", {}).then((me) => {
56
+ this.#project.id ??= this.request("me_get", {}).then((me) => {
47
57
  if (!me.api_key) {
48
58
  throw new NoheadError("The credentials are not a project API key");
49
59
  }
50
60
  return me.api_key.project_id;
51
61
  }, (error) => {
52
- this.#projectId = undefined;
62
+ this.#project.id = undefined;
53
63
  throw error;
54
64
  });
55
- return this.#projectId;
65
+ return this.#project.id;
56
66
  }
57
67
  request(operation, input = {}, options = {}) {
58
68
  return new APIPromise(this.#send(operation, input, options));
@@ -63,21 +73,32 @@ export class Core {
63
73
  const query = { ...input.query, ...(cursor ? { cursor } : {}) };
64
74
  const result = await this.#send(operation, { ...input, query }, options);
65
75
  const list = result.data;
76
+ // A proxy's page, say, rather than the API's list.
77
+ if (!isObject(list) ||
78
+ !Array.isArray(list.data) ||
79
+ !isObject(list.meta)) {
80
+ throw new NoheadError(`The API's response could not be read (HTTP ${result.response.status})`);
81
+ }
66
82
  const page = new Page(list.data, list.meta, (next) => fetchPage(next).then((r) => r.data));
67
83
  return { ...result, data: page };
68
84
  };
69
85
  const first = input.query?.cursor;
70
86
  return new PagePromise(fetchPage(first));
71
87
  }
72
- /** `fetch` with this client's timeout and retries, for presigned URLs. */
88
+ /**
89
+ * `fetch` with this client's retries, for an upload's presigned URL. fetch
90
+ * shows no upload progress, so each attempt may take as long as sending
91
+ * `byteSize` at 64 KiB/s, if that is longer than the timeout.
92
+ */
73
93
  async fetchWithRetries(url, init, options) {
74
94
  const maxRetries = options.retry
75
95
  ? (options.maxRetries ?? this.#maxRetries)
76
96
  : 0;
97
+ const timeout = Math.max(options.timeout ?? this.#timeout, (options.byteSize / 65_536) * 1000);
77
98
  for (let attempt = 0;; attempt++) {
78
99
  let failed;
79
100
  try {
80
- const { response } = await this.#attempt(url, init, options, false);
101
+ const { response } = await this.#attempt(url, init, { ...options, timeout }, false);
81
102
  if (!RETRYABLE_STATUSES.has(response.status) || attempt >= maxRetries) {
82
103
  return response;
83
104
  }
@@ -96,33 +117,39 @@ export class Core {
96
117
  const { method, path } = OPERATIONS[operation];
97
118
  const url = new URL(this.#baseUrl + (await this.#path(path, input.path)));
98
119
  appendQuery(url.searchParams, input.query);
99
- const headers = {
120
+ // Each name once, whatever its case: the defaults, then the client's
121
+ // headers, the call's, and those of the request options, which win.
122
+ const headers = {};
123
+ setHeaders(headers, {
100
124
  Accept: "application/json",
101
125
  Authorization: `Bearer ${this.#apiKey}`,
102
126
  "Nohead-Client": `sdk-typescript/${VERSION}`,
103
127
  ...USER_AGENT,
104
- ...this.#headers,
105
- };
128
+ });
129
+ setHeaders(headers, this.#headers);
130
+ setHeaders(headers, options.headers);
131
+ const own = {};
106
132
  if (input.body !== undefined)
107
- headers["Content-Type"] = "application/json";
133
+ own["Content-Type"] = "application/json";
108
134
  if (method !== "GET") {
109
- headers["Idempotency-Key"] = options.idempotencyKey ?? crypto.randomUUID();
135
+ own["Idempotency-Key"] = options.idempotencyKey || crypto.randomUUID();
110
136
  }
111
- if (options.ifMatch !== undefined) {
112
- const revision = typeof options.ifMatch === "object"
113
- ? options.ifMatch.revision
114
- : options.ifMatch;
115
- headers["If-Match"] = `"${revision}"`;
137
+ if (options.ifMatch !== undefined && options.ifMatch !== null) {
138
+ own["If-Match"] = `"${revisionOf(options.ifMatch)}"`;
116
139
  }
117
140
  // Percent-encoded UTF-8: header values are bytes, and fetch refuses
118
- // anything past Latin-1 (the API decodes it).
141
+ // anything past Latin-1 (the API decodes it). Lone surrogates (half an
142
+ // emoji, from cutting a string) become U+FFFD, so encodeURIComponent
143
+ // can encode them.
119
144
  if (options.changeNote)
120
- headers["Nohead-Change-Note"] = encodeURIComponent(wellFormed(options.changeNote));
121
- Object.assign(headers, options.headers);
145
+ own["Nohead-Change-Note"] = encodeURIComponent(options.changeNote.toWellFormed());
146
+ setHeaders(headers, own);
122
147
  const init = {
123
148
  method,
124
149
  headers,
125
- body: input.body === undefined ? undefined : JSON.stringify(input.body),
150
+ body: input.body === undefined
151
+ ? undefined
152
+ : JSON.stringify(input.body, finiteNumbers),
126
153
  };
127
154
  const maxRetries = options.maxRetries ?? this.#maxRetries;
128
155
  for (let attempt = 0;; attempt++) {
@@ -154,14 +181,32 @@ export class Core {
154
181
  return sleep(delay, signal);
155
182
  }
156
183
  async #attempt(url, init, options, readBody) {
184
+ // The timeout is the longest wait for progress, so a response that
185
+ // keeps arriving never times out: the timer starts again when the
186
+ // response comes, and with each chunk of its body.
157
187
  const timeout = options.timeout ?? this.#timeout;
158
- const timer = AbortSignal.timeout(timeout);
188
+ const timer = new AbortController();
189
+ let timeoutId;
190
+ const progress = () => {
191
+ clearTimeout(timeoutId);
192
+ timeoutId = setTimeout(() => timer.abort(), timeout);
193
+ };
159
194
  const signal = options.signal
160
- ? AbortSignal.any([options.signal, timer])
161
- : timer;
195
+ ? AbortSignal.any([options.signal, timer.signal])
196
+ : timer.signal;
162
197
  try {
163
- const response = await this.#fetch(url, { ...init, signal });
164
- const body = readBody ? await parseBody(response) : undefined;
198
+ progress();
199
+ // Redirects aren't followed: one would send the API key to another
200
+ // host, or a write as a GET.
201
+ const response = await this.#fetch(url, {
202
+ ...init,
203
+ redirect: "manual",
204
+ signal,
205
+ });
206
+ progress();
207
+ const body = readBody
208
+ ? await parseBody(response, signal, progress)
209
+ : undefined;
165
210
  return { response, body };
166
211
  }
167
212
  catch (error) {
@@ -170,11 +215,14 @@ export class Core {
170
215
  cause: options.signal.reason,
171
216
  });
172
217
  }
173
- if (timer.aborted) {
218
+ if (timer.signal.aborted) {
174
219
  throw new TimeoutError(`The request timed out after ${timeout} ms`);
175
220
  }
176
221
  throw new ConnectionError(`Could not reach ${new URL(url).host}: ${error.message}`, { cause: error });
177
222
  }
223
+ finally {
224
+ clearTimeout(timeoutId);
225
+ }
178
226
  }
179
227
  async #path(template, values = {}) {
180
228
  let path = template;
@@ -183,7 +231,7 @@ export class Core {
183
231
  for (const name of Object.keys(values))
184
232
  if (!names.includes(name))
185
233
  throw new TypeError(`${name} is not a path parameter of ${template}`);
186
- for (const [, name] of template.matchAll(/\{(\w+)\}/g)) {
234
+ for (const name of names) {
187
235
  let value = values[name];
188
236
  if (value === undefined && name === "project_id") {
189
237
  value = await this.projectId();
@@ -191,6 +239,10 @@ export class Core {
191
239
  if (value === undefined || value === "") {
192
240
  throw new NoheadError(`Missing ${name}`);
193
241
  }
242
+ // Encoding leaves these as they are, and URLs resolve them.
243
+ if (value === "." || value === "..") {
244
+ throw new NoheadError(`Invalid ${name}: "${value}"`);
245
+ }
194
246
  path = path.replace(`{${name}}`, encodeURIComponent(String(value)));
195
247
  }
196
248
  return path;
@@ -249,8 +301,8 @@ function sleep(seconds, signal) {
249
301
  signal?.addEventListener("abort", aborted, { once: true });
250
302
  });
251
303
  }
252
- async function parseBody(response) {
253
- const text = await response.text();
304
+ async function parseBody(response, signal, progress) {
305
+ const text = await readText(response, signal, progress);
254
306
  if (!text)
255
307
  return undefined;
256
308
  if (!response.headers.get("content-type")?.includes("json"))
@@ -262,6 +314,31 @@ async function parseBody(response) {
262
314
  return text;
263
315
  }
264
316
  }
317
+ /** The body's text, calling `progress` for each chunk that arrives. */
318
+ async function readText(response, signal, progress) {
319
+ if (!response.body)
320
+ return "";
321
+ const reader = response.body.getReader();
322
+ // A fetch ends the body when the signal aborts, but not every fetch is
323
+ // the platform's.
324
+ const cancel = () => void reader.cancel().catch(() => undefined);
325
+ signal.addEventListener("abort", cancel, { once: true });
326
+ const decoder = new TextDecoder();
327
+ let text = "";
328
+ try {
329
+ for (;;) {
330
+ const { done, value } = await reader.read();
331
+ signal.throwIfAborted();
332
+ if (done)
333
+ return text + decoder.decode();
334
+ progress();
335
+ text += decoder.decode(value, { stream: true });
336
+ }
337
+ }
338
+ finally {
339
+ signal.removeEventListener("abort", cancel);
340
+ }
341
+ }
265
342
  /**
266
343
  * Query parameters as the API reads them: objects become `key[sub]=…`
267
344
  * (`filter[price][lt]=50`), arrays are comma-separated (`expand=author,tags`)
@@ -308,12 +385,48 @@ function runtimeVersion() {
308
385
  return `node/${g.process.versions.node}`;
309
386
  return undefined;
310
387
  }
311
- /** `url` without trailing slashes; a loop, as a regex backtracks on long runs of them. */
312
- function withoutTrailingSlashes(url) {
388
+ /** `url` without trailing slashes, if it is an http or https URL. */
389
+ function baseUrlOf(url) {
390
+ // A loop, as a regex backtracks on long runs of slashes.
313
391
  let end = url.length;
314
392
  while (end > 0 && url[end - 1] === "/")
315
393
  end--;
316
- return url.slice(0, end);
394
+ const base = url.slice(0, end);
395
+ let protocol;
396
+ try {
397
+ protocol = new URL(base).protocol;
398
+ }
399
+ catch {
400
+ // Not a URL at all.
401
+ }
402
+ if (protocol !== "http:" && protocol !== "https:") {
403
+ throw new NoheadError("baseUrl must be an http or https URL");
404
+ }
405
+ return base;
406
+ }
407
+ /** Sets each header, replacing one of the same name in any case. */
408
+ function setHeaders(headers, values = {}) {
409
+ for (const [name, value] of Object.entries(values)) {
410
+ for (const key of Object.keys(headers))
411
+ if (key.toLowerCase() === name.toLowerCase())
412
+ delete headers[key];
413
+ headers[name] = value;
414
+ }
415
+ }
416
+ /** `ifMatch`'s revision: a number, or a record's or a draft's. */
417
+ function revisionOf(ifMatch) {
418
+ const revision = isObject(ifMatch) ? ifMatch.revision : ifMatch;
419
+ if (!Number.isInteger(revision)) {
420
+ throw new TypeError("ifMatch takes a revision number, or a record, draft or object with a revision");
421
+ }
422
+ return revision;
423
+ }
424
+ // JSON has no NaN or Infinity, which JSON.stringify would send as null.
425
+ function finiteNumbers(key, value) {
426
+ if (typeof value === "number" && !Number.isFinite(value)) {
427
+ throw new TypeError(`JSON can't carry ${value}${key ? ` (in ${key})` : ""}`);
428
+ }
429
+ return value;
317
430
  }
318
431
  function environment() {
319
432
  const process = globalThis.process;
package/dist/errors.d.ts CHANGED
@@ -3,7 +3,11 @@ import type { ErrorDetail, ErrorType } from "./generated/types.gen.ts";
3
3
  export declare class NoheadError extends Error {
4
4
  name: string;
5
5
  }
6
- /** The API answered with an error status. Subclasses follow the error `type`. */
6
+ /**
7
+ * The API answered with an error status. Subclasses follow the error `type`.
8
+ * Its `message` names the status, the type and the request's ID along with
9
+ * the API's message: `422 validation_error: Title is required (request req_…)`.
10
+ */
7
11
  export declare class APIError extends NoheadError {
8
12
  name: string;
9
13
  /** The HTTP status. */
@@ -13,6 +17,8 @@ export declare class APIError extends NoheadError {
13
17
  * none. A type newer than this SDK is kept as its string.
14
18
  */
15
19
  readonly type: ErrorType | (string & {}) | undefined;
20
+ /** The API's own message, without the status, type and ID `message` adds. */
21
+ readonly apiMessage: string;
16
22
  /** The request's ID (`req_…`), for support requests and logs. */
17
23
  readonly requestId: string | undefined;
18
24
  /** Field-level details, e.g. `[{ field: "title", code: "required", … }]`. */
@@ -85,4 +91,10 @@ export declare class WebhookVerificationError extends NoheadError {
85
91
  }
86
92
  /** The error for a response, by its `type`, or by status when it has none. */
87
93
  export declare function apiError(status: number, body: unknown, headers: Headers): APIError;
94
+ /**
95
+ * `Retry-After` in seconds: a number of seconds (`120`, `1.5`), or an HTTP
96
+ * date. Anything else (`-1`, `1e3`, `0x10`, an ISO 8601 time) is undefined,
97
+ * as the Python and Ruby SDKs read it.
98
+ */
88
99
  export declare function retryAfterSeconds(headers: Headers): number | undefined;
100
+ export declare const isObject: (value: unknown) => value is Record<string, unknown>;
package/dist/errors.js CHANGED
@@ -2,7 +2,11 @@
2
2
  export class NoheadError extends Error {
3
3
  name = "NoheadError";
4
4
  }
5
- /** The API answered with an error status. Subclasses follow the error `type`. */
5
+ /**
6
+ * The API answered with an error status. Subclasses follow the error `type`.
7
+ * Its `message` names the status, the type and the request's ID along with
8
+ * the API's message: `422 validation_error: Title is required (request req_…)`.
9
+ */
6
10
  export class APIError extends NoheadError {
7
11
  name = "APIError";
8
12
  /** The HTTP status. */
@@ -12,6 +16,8 @@ export class APIError extends NoheadError {
12
16
  * none. A type newer than this SDK is kept as its string.
13
17
  */
14
18
  type;
19
+ /** The API's own message, without the status, type and ID `message` adds. */
20
+ apiMessage;
15
21
  /** The request's ID (`req_…`), for support requests and logs. */
16
22
  requestId;
17
23
  /** Field-level details, e.g. `[{ field: "title", code: "required", … }]`. */
@@ -20,12 +26,17 @@ export class APIError extends NoheadError {
20
26
  /** The parsed response body, or its text when it was not JSON. */
21
27
  body;
22
28
  constructor(status, body, headers) {
23
- const error = errorEnvelope(body);
24
- super(error?.message ?? `Request failed with status ${status}`);
29
+ // Below 400 (and not 2xx) is a redirect, which the SDK doesn't follow:
30
+ // `0` is a browser's, which shows nothing of it.
31
+ const error = status < 400 ? redirect(headers) : errorEnvelope(body);
32
+ const requestId = error?.request_id ?? headers.get("x-request-id") ?? undefined;
33
+ const apiMessage = error?.message || `Request failed with status ${status}`;
34
+ super(`${status} ${error?.type || "error"}: ${apiMessage}` +
35
+ (requestId ? ` (request ${requestId})` : ""));
25
36
  this.status = status;
37
+ this.apiMessage = apiMessage;
26
38
  this.type = error?.type;
27
- this.requestId =
28
- error?.request_id ?? headers.get("x-request-id") ?? undefined;
39
+ this.requestId = requestId;
29
40
  this.details = error?.details ?? [];
30
41
  this.headers = headers;
31
42
  this.body = body;
@@ -118,6 +129,8 @@ const CLASSES = new Map(Object.entries({
118
129
  }));
119
130
  /** The error for a response, by its `type`, or by status when it has none. */
120
131
  export function apiError(status, body, headers) {
132
+ if (status < 400)
133
+ return new APIError(status, body, headers);
121
134
  const type = errorEnvelope(body)?.type;
122
135
  const Class = (type && CLASSES.get(type)) ||
123
136
  (status === 503
@@ -127,18 +140,49 @@ export function apiError(status, body, headers) {
127
140
  : APIError);
128
141
  return new Class(status, body, headers);
129
142
  }
143
+ /**
144
+ * `Retry-After` in seconds: a number of seconds (`120`, `1.5`), or an HTTP
145
+ * date. Anything else (`-1`, `1e3`, `0x10`, an ISO 8601 time) is undefined,
146
+ * as the Python and Ruby SDKs read it.
147
+ */
130
148
  export function retryAfterSeconds(headers) {
131
- const value = headers.get("retry-after");
149
+ const value = headers.get("retry-after")?.trim();
132
150
  if (!value)
133
151
  return undefined;
134
- const seconds = Number(value);
135
- if (Number.isFinite(seconds))
136
- return Math.max(0, seconds);
137
- const date = Date.parse(value);
138
- return Number.isNaN(date)
152
+ if (/^\d+(\.\d+)?$/.test(value))
153
+ return Number(value);
154
+ const date = httpDate(value);
155
+ return date === undefined
139
156
  ? undefined
140
157
  : Math.max(0, (date - Date.now()) / 1000);
141
158
  }
159
+ // The three forms of an HTTP date (RFC 9110), all in GMT. Not Date.parse,
160
+ // which takes more than these, and asctime's in the local time zone.
161
+ const HTTP_DATES = [
162
+ // IMF-fixdate: Sun, 06 Nov 1994 08:49:37 GMT
163
+ /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (?<day>\d{2}) (?<month>\w{3}) (?<year>\d{4}) (?<time>\d{2}:\d{2}:\d{2}) GMT$/,
164
+ // RFC 850: Sunday, 06-Nov-94 08:49:37 GMT
165
+ /^(?:Mon|Tues|Wednes|Thurs|Fri|Satur|Sun)day, (?<day>\d{2})-(?<month>\w{3})-(?<year>\d{2}) (?<time>\d{2}:\d{2}:\d{2}) GMT$/,
166
+ // asctime: Sun Nov 6 08:49:37 1994
167
+ /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?<month>\w{3}) (?<day>[ \d]\d) (?<time>\d{2}:\d{2}:\d{2}) (?<year>\d{4})$/,
168
+ ];
169
+ const MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ");
170
+ /** An HTTP date in milliseconds, or undefined if `value` isn't one. */
171
+ function httpDate(value) {
172
+ const date = HTTP_DATES.map((pattern) => pattern.exec(value)?.groups).find(Boolean);
173
+ const month = MONTHS.indexOf(date?.month ?? "");
174
+ if (!date || month < 0)
175
+ return undefined;
176
+ let year = Number(date.year);
177
+ // RFC 850's two digits: the latest such year not over 50 years ahead.
178
+ if (date.year.length === 2) {
179
+ year += 2000;
180
+ if (year > new Date().getUTCFullYear() + 50)
181
+ year -= 100;
182
+ }
183
+ const [hours, minutes, seconds] = date.time.split(":").map(Number);
184
+ return Date.UTC(year, month, Number(date.day), hours, minutes, seconds);
185
+ }
142
186
  // The error envelope's fields that have the right type: a proxy or gateway
143
187
  // in front of the API may answer with any JSON.
144
188
  function errorEnvelope(body) {
@@ -149,10 +193,24 @@ function errorEnvelope(body) {
149
193
  type: typeof type === "string" ? type : undefined,
150
194
  message: typeof message === "string" ? message : undefined,
151
195
  request_id: typeof request_id === "string" ? request_id : undefined,
152
- // Only objects: currentRevision reads each detail's fields.
153
- details: Array.isArray(details)
154
- ? details.filter(isObject)
155
- : undefined,
196
+ details: Array.isArray(details) ? details.map(errorDetail) : undefined,
197
+ };
198
+ }
199
+ // A detail that isn't an object is kept as its text, so currentRevision can
200
+ // read every detail's fields.
201
+ function errorDetail(detail) {
202
+ if (isObject(detail) && !Array.isArray(detail))
203
+ return detail;
204
+ const message = typeof detail === "string" ? detail : JSON.stringify(detail);
205
+ return { code: "unknown", message: String(message) };
206
+ }
207
+ // A redirect's error: following one would send the API key elsewhere, or a
208
+ // write as a GET.
209
+ function redirect(headers) {
210
+ const location = headers.get("location");
211
+ return {
212
+ type: "redirect",
213
+ message: `The API answered with a redirect${location ? ` to ${location}` : ""}; check the base URL`,
156
214
  };
157
215
  }
158
- const isObject = (value) => typeof value === "object" && value !== null;
216
+ export const isObject = (value) => typeof value === "object" && value !== null;
@@ -1,5 +1,5 @@
1
1
  export type ClientOptions = {
2
- baseUrl: 'https://api.nohead.io' | 'http://localhost:3000' | (string & {});
2
+ baseUrl: 'https://api.nohead.io' | (string & {});
3
3
  };
4
4
  export type Webhooks = WebhookEventWebhookRequest;
5
5
  export type RequestId = string;
@@ -1129,7 +1129,7 @@ export type Principal = {
1129
1129
  email: string;
1130
1130
  name: string | null;
1131
1131
  /**
1132
- * Nohead staff (internal tooling access).
1132
+ * Whether the user is Nohead staff.
1133
1133
  */
1134
1134
  staff: boolean;
1135
1135
  };
@@ -1550,7 +1550,10 @@ export type AssetProperties = {
1550
1550
  /**
1551
1551
  * The URL that plays a ready video or audio file inline, for
1552
1552
  * `<video>` and `<audio>` (with range requests, so players can seek),
1553
- * deleted ones included until they are purged; null for other types.
1553
+ * deleted ones included until they are purged: MP4, QuickTime, M4A,
1554
+ * 3GP, WebM, Matroska, MP3, AAC, FLAC, WAV and Ogg (whose content is
1555
+ * checked when the upload completes). Null for other types, which
1556
+ * `file_url` downloads.
1554
1557
  * Open, never expiring and cached on a CDN, like `file_url`.
1555
1558
  *
1556
1559
  */
@@ -1619,7 +1622,7 @@ export type AssetUploadTarget = {
1619
1622
  expires_at: Timestamp;
1620
1623
  };
1621
1624
  /**
1622
- * What a project API key may do. `records:write` also publishes, unpublishes and schedules records (the `records:publish` permission). `webhooks:read` includes delivery payloads and `webhooks:write` can subscribe an endpoint to every event, so both give access to the record, schema and asset data in events, unpublished records included; published-only keys cannot have them.
1625
+ * What a project API key may do. `records:write` also publishes, unpublishes and schedules records. `webhooks:read` includes delivery payloads and `webhooks:write` can subscribe an endpoint to every event, so both give access to the record, schema and asset data in events, unpublished records included; published-only keys cannot have them.
1623
1626
  */
1624
1627
  export type ApiKeyScope = 'records:read' | 'records:write' | 'schema:read' | 'schema:write' | 'assets:read' | 'assets:write' | 'search:read' | 'webhooks:read' | 'webhooks:write' | 'audit:read';
1625
1628
  /**
package/dist/images.js CHANGED
@@ -16,8 +16,13 @@ export function imageUrl(image, options = {}) {
16
16
  const at = base.indexOf("/plain/");
17
17
  if (at < 0)
18
18
  throw new TypeError(`Not a Nohead image URL: ${base}`);
19
- const { width, height, fit = "contain", format, quality, dpr, gravity, } = options;
20
- for (const option of [gravity, ...(options.options ?? [])])
19
+ // Empty options count as unset.
20
+ const { width, height, format, quality, dpr, gravity } = options;
21
+ const fit = options.fit || "contain";
22
+ if (!Object.hasOwn(FITS, fit))
23
+ throw new TypeError(`fit must be one of ${Object.keys(FITS).join(", ")}`);
24
+ const others = (options.options ?? []).filter(Boolean);
25
+ for (const option of [gravity, ...others])
21
26
  if (option && /[/@?#]/.test(option))
22
27
  throw new TypeError(`Not an imgproxy option: ${option}`);
23
28
  const segments = [];
@@ -29,7 +34,7 @@ export function imageUrl(image, options = {}) {
29
34
  segments.push(`dpr:${dpr}`);
30
35
  if (quality)
31
36
  segments.push(`q:${quality}`);
32
- segments.push(...(options.options ?? []));
37
+ segments.push(...others);
33
38
  // The source comes last; a format replaces any the URL already names.
34
39
  let source = base.slice(at);
35
40
  if (format)
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { Nohead } from "./client.ts";
2
2
  export { APIPromise, Page, PagePromise, type APIResponse, } from "./api-promise.ts";
3
- export type { ClientOptions, ConditionalRequestOptions, ConditionalWriteOptions, RequestOptions, RetryEvent, WriteOptions, } from "./core.ts";
3
+ export { DEFAULT_BASE_URL, type ClientOptions, type ConditionalRequestOptions, type ConditionalWriteOptions, type CopyOptions, type RequestOptions, type RetryEvent, type WriteOptions, } from "./core.ts";
4
4
  export { APIError, AbortError, AuthenticationError, AuthorizationError, ConflictError, ConnectionError, InternalServerError, InvalidRequestError, NoheadError, NotFoundError, PlanLimitExceededError, PreconditionFailedError, RateLimitError, ServiceUnavailableError, TimeoutError, UploadError, ValidationError, WebhookVerificationError, } from "./errors.ts";
5
5
  export type { Uploadable, UploadParams } from "./resources/assets.ts";
6
6
  export type * from "./types.ts";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { Nohead } from "./client.js";
2
2
  export { APIPromise, Page, PagePromise, } from "./api-promise.js";
3
+ export { DEFAULT_BASE_URL, } from "./core.js";
3
4
  export { APIError, AbortError, AuthenticationError, AuthorizationError, ConflictError, ConnectionError, InternalServerError, InvalidRequestError, NoheadError, NotFoundError, PlanLimitExceededError, PreconditionFailedError, RateLimitError, ServiceUnavailableError, TimeoutError, UploadError, ValidationError, WebhookVerificationError, } from "./errors.js";
4
5
  export { unwrapWebhook, } from "./webhooks.js";
5
6
  export { VERSION } from "./version.js";
@@ -8,11 +8,15 @@ export type Uploadable = Blob | ArrayBuffer | Uint8Array | ReadableStream;
8
8
  export interface UploadParams {
9
9
  /** Defaults to a `File`'s name, else `upload`. */
10
10
  filename?: string;
11
- /** Defaults to the `Blob`'s type, else `application/octet-stream`. */
11
+ /**
12
+ * Defaults to the `Blob`'s type, else the filename's extension's for
13
+ * common files, else `application/octet-stream`.
14
+ */
12
15
  content_type?: string;
13
16
  /** Required for a `ReadableStream`; known for everything else. */
14
17
  byte_size?: number;
15
18
  }
19
+ /** Files of the key's project. `asset` is an asset ID (`ast_…`). */
16
20
  export declare class Assets extends Resource {
17
21
  /**
18
22
  * Uploads a file and returns the `ready` asset: creates the upload, sends
@@ -1,6 +1,5 @@
1
1
  import { AbortError, UploadError } from "../errors.js";
2
2
  import { listQuery, Resource } from "./resource.js";
3
- /** Files of the key's project. `asset` is an asset ID (`ast_…`). */
4
3
  /** Common files' types by extension, as the other SDKs guess them. */
5
4
  const CONTENT_TYPES = {
6
5
  avif: "image/avif",
@@ -26,6 +25,7 @@ function contentTypeOf(filename) {
26
25
  ? CONTENT_TYPES[filename.slice(dot + 1).toLowerCase()]
27
26
  : undefined;
28
27
  }
28
+ /** Files of the key's project. `asset` is an asset ID (`ast_…`). */
29
29
  export class Assets extends Resource {
30
30
  /**
31
31
  * Uploads a file and returns the `ready` asset: creates the upload, sends
@@ -45,23 +45,28 @@ export class Assets extends Resource {
45
45
  * extension's for common files.
46
46
  */
47
47
  async upload(file, params = {}, options) {
48
+ // Never a path: the Python and Ruby SDKs take one, so it would be easy
49
+ // to send its text instead.
50
+ if (typeof file === "string") {
51
+ throw new TypeError("upload takes a Blob, File, ArrayBuffer, Uint8Array or ReadableStream; to upload a file from disk, pass await fs.openAsBlob(path)");
52
+ }
48
53
  const byteSize = params.byte_size ?? sizeOf(file);
49
54
  if (byteSize === undefined) {
50
55
  throw new UploadError("Uploading a stream needs `byte_size`");
51
56
  }
52
- const filename = params.filename ??
53
- (typeof File !== "undefined" && file instanceof File
54
- ? file.name
55
- : "upload");
57
+ // Empty ones count as unset.
58
+ const filename = params.filename ||
59
+ (typeof File !== "undefined" && file instanceof File && file.name) ||
60
+ "upload";
56
61
  const { upload, ...asset } = await this.createUpload({
57
62
  filename,
58
63
  // The Blob's own type, or one the filename's extension implies (a
59
64
  // Blob from Node's openAsBlob has none), as the Python and Ruby
60
65
  // SDKs do.
61
- content_type: params.content_type ??
62
- ((file instanceof Blob && file.type) ||
63
- contentTypeOf(filename) ||
64
- "application/octet-stream"),
66
+ content_type: params.content_type ||
67
+ (file instanceof Blob && file.type) ||
68
+ contentTypeOf(filename) ||
69
+ "application/octet-stream",
65
70
  byte_size: byteSize,
66
71
  }, options);
67
72
  // A replayed start returns the first run's asset, which may be done.
@@ -74,24 +79,28 @@ export class Assets extends Resource {
74
79
  return current;
75
80
  // The key replays the first run's upload URL, which expires sooner.
76
81
  if (Date.parse(upload.expires_at) <= Date.now()) {
77
- throw new UploadError(`The upload of ${asset.filename} expired: start it again with a new idempotencyKey`);
82
+ throw new UploadError(`The upload of ${filename} expired: start it again with a new idempotencyKey`);
78
83
  }
79
84
  }
80
85
  const stream = file instanceof ReadableStream;
81
86
  const response = await this.core
82
87
  .fetchWithRetries(upload.url, {
83
88
  method: upload.method,
84
- headers: upload.headers,
89
+ // The URL is signed for this size; fetch would send a stream
90
+ // in chunks, without one.
91
+ headers: stream
92
+ ? { ...upload.headers, "Content-Length": String(byteSize) }
93
+ : upload.headers,
85
94
  body: file,
86
95
  ...(stream ? { duplex: "half" } : {}),
87
- }, { ...options, retry: !stream })
96
+ }, { ...options, retry: !stream, byteSize })
88
97
  .catch((error) => {
89
98
  if (error instanceof AbortError)
90
99
  throw error;
91
- throw new UploadError(`Could not upload ${asset.filename}: ${error.message}`, undefined, { cause: error });
100
+ throw new UploadError(`Could not upload ${filename}: ${error.message}`, undefined, { cause: error });
92
101
  });
93
102
  if (!response.ok) {
94
- throw new UploadError(`Storage refused the upload of ${asset.filename} (${response.status})`, response.status);
103
+ throw new UploadError(`Storage refused the upload of ${filename} (${response.status})`, response.status);
95
104
  }
96
105
  // The key belongs to the start: the API refuses one key on two requests.
97
106
  return this.complete(asset.id, { ...options, idempotencyKey: undefined });
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.8.0";
1
+ export declare const VERSION = "0.9.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.8.0";
1
+ export const VERSION = "0.9.0";
@@ -49,8 +49,9 @@ export interface UnwrapOptions {
49
49
  /**
50
50
  * The webhook's signing secret (`whsec_…`). While a rotation overlaps,
51
51
  * requests carry a signature for each secret, so either one verifies.
52
+ * An unset one (as from a missing environment variable) throws.
52
53
  */
53
- secret: string;
54
+ secret: string | undefined;
54
55
  /** Seconds a timestamp may be off from now. Default 300. */
55
56
  tolerance?: number;
56
57
  }
package/dist/webhooks.js CHANGED
@@ -24,7 +24,11 @@ export async function unwrapWebhook(body, headers, { secret, tolerance = 300 })
24
24
  throw new WebhookVerificationError("The webhook timestamp is too far from the current time");
25
25
  }
26
26
  const text = typeof body === "string" ? body : new TextDecoder().decode(body);
27
- const key = await crypto.subtle.importKey("raw", decodeBase64(secret.replace(/^whsec_/, ""), "secret"), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
27
+ const keyBytes = decodeBase64((secret ?? "").replace(/^whsec_/, ""), "secret");
28
+ if (keyBytes.length === 0) {
29
+ throw new WebhookVerificationError("The webhook secret is empty");
30
+ }
31
+ const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
28
32
  const signed = new TextEncoder().encode(`${id}.${timestamp}.${text}`);
29
33
  for (const entry of signatures.split(" ")) {
30
34
  const [version, signature] = entry.split(",", 2);
@@ -63,8 +67,5 @@ function decodeBase64(value, what) {
63
67
  catch {
64
68
  throw new WebhookVerificationError(`The webhook ${what} is not base64`);
65
69
  }
66
- const bytes = new Uint8Array(binary.length);
67
- for (let i = 0; i < binary.length; i++)
68
- bytes[i] = binary.charCodeAt(i);
69
- return bytes;
70
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
70
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nohead/sdk",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Official TypeScript SDK for the Nohead API",
5
5
  "license": "MIT",
6
6
  "homepage": "https://nohead.io",
@@ -61,6 +61,7 @@
61
61
  "eslint": "^10.12.0",
62
62
  "globals": "^17.13.0",
63
63
  "prettier": "^3.9.9",
64
+ "standardwebhooks": "^1.1.1",
64
65
  "typescript": "^6.0.3",
65
66
  "typescript-eslint": "^8.71.0",
66
67
  "vitest": "^5.0.3"