@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 +82 -0
- package/README.md +52 -24
- package/dist/client.d.ts +9 -1
- package/dist/client.js +14 -2
- package/dist/core.d.ts +22 -5
- package/dist/core.js +158 -45
- package/dist/errors.d.ts +13 -1
- package/dist/errors.js +74 -16
- package/dist/generated/types.gen.d.ts +7 -4
- package/dist/images.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/resources/assets.d.ts +5 -1
- package/dist/resources/assets.js +23 -14
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webhooks.d.ts +2 -1
- package/dist/webhooks.js +6 -5
- package/package.json +2 -1
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
|
|
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
|
|
50
|
-
|
|
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`
|
|
57
|
-
| `baseUrl` | `
|
|
58
|
-
| `projectId` | the key's project
|
|
59
|
-
| `maxRetries` | `2`
|
|
60
|
-
| `timeout` | `60000`
|
|
61
|
-
| `fetch` | `globalThis.fetch`
|
|
62
|
-
| `headers` | `{}`
|
|
63
|
-
| `warnings` | `true`
|
|
64
|
-
| `onRetry` |
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
|
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
|
|
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`.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// Empty environment variables count as unset.
|
|
29
|
-
const apiKey = options.apiKey
|
|
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 =
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
62
|
+
this.#project.id = undefined;
|
|
53
63
|
throw error;
|
|
54
64
|
});
|
|
55
|
-
return this.#
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
128
|
+
});
|
|
129
|
+
setHeaders(headers, this.#headers);
|
|
130
|
+
setHeaders(headers, options.headers);
|
|
131
|
+
const own = {};
|
|
106
132
|
if (input.body !== undefined)
|
|
107
|
-
|
|
133
|
+
own["Content-Type"] = "application/json";
|
|
108
134
|
if (method !== "GET") {
|
|
109
|
-
|
|
135
|
+
own["Idempotency-Key"] = options.idempotencyKey || crypto.randomUUID();
|
|
110
136
|
}
|
|
111
|
-
if (options.ifMatch !== undefined) {
|
|
112
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
164
|
-
|
|
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
|
|
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
|
|
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
|
|
312
|
-
function
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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' |
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
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(...
|
|
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
|
|
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
|
-
/**
|
|
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
|
package/dist/resources/assets.js
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
(
|
|
63
|
-
|
|
64
|
-
|
|
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 ${
|
|
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
|
-
|
|
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 ${
|
|
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 ${
|
|
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.
|
|
1
|
+
export declare const VERSION = "0.9.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.9.0";
|
package/dist/webhooks.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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"
|