@calmlens/sdk 0.9.1 → 0.10.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/LICENSE +21 -0
- package/README.md +156 -60
- package/cjs/CalmLensTypes-BfweJTMS.d.ts +8584 -0
- package/cjs/CalmLensWebhooks-DJnaLpRy.js +741 -0
- package/cjs/index.d.ts +276 -7525
- package/cjs/index.js +662 -1184
- package/cjs/testing.d.ts +83 -8
- package/cjs/testing.js +991 -596
- package/esm/CalmLensTypes-BfweJTMS.d.ts +8584 -0
- package/esm/CalmLensWebhooks-DLUozliX.js +556 -0
- package/esm/index.d.ts +276 -7525
- package/esm/index.js +648 -1168
- package/esm/testing.d.ts +83 -8
- package/esm/testing.js +960 -592
- package/package.json +24 -18
- package/cjs/CalmLensTypes-CZqyni4p.d.ts +0 -3633
- package/cjs/CalmLensUtils-B6vwJijv.js +0 -657
- package/esm/CalmLensTypes-CZqyni4p.d.ts +0 -3633
- package/esm/CalmLensUtils-O1xk_Ts0.js +0 -492
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CalmLens
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -10,7 +10,9 @@ CalmLens provides content moderation and classification for text, images, docume
|
|
|
10
10
|
npm install @calmlens/sdk
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Requires Node.js 22 or later
|
|
13
|
+
Requires Node.js 22 or later, or another server runtime with `fetch` and Web Crypto, such as Bun, Deno or Cloudflare Workers. The package ships ES modules and CommonJS with type declarations and loads no other packages at runtime: importing only `verifyWebhookSignature` adds about 5 KB (minified) to a bundle, and the whole client about 25 KB.
|
|
14
|
+
|
|
15
|
+
Use it on your server. API keys are secret, so `CalmLensClient` refuses to start in a web page; serve private content to browsers with [signed URLs](#assetscreatesignedurloptions).
|
|
14
16
|
|
|
15
17
|
Every public SDK operation accepts one options object. This keeps call sites stable as optional fields are added.
|
|
16
18
|
|
|
@@ -71,12 +73,16 @@ new CalmLensClient(options: CalmLensClientOptions)
|
|
|
71
73
|
- `projectId` (string, required): Your CalmLens project ID
|
|
72
74
|
- `baseUrl` (string, optional): API base URL (defaults to `https://api.calmlens.com/v1`)
|
|
73
75
|
- `cdnBaseUrl` (string, optional): CDN base URL (defaults to `https://cdn.calmlens.com/v1`)
|
|
74
|
-
- `
|
|
76
|
+
- `fetch` (optional): The `fetch` implementation for every request, for example one with a proxy or instrumentation. Defaults to the global `fetch`
|
|
77
|
+
- `uploadFetch`, `cdnFetch` (optional): Override `fetch` for content uploads or for `cdn.serve` only
|
|
78
|
+
- `timeoutMs` (positive integer, optional): Deadline for each API request attempt, including reading the response body (defaults to `120000`). For `cdn.serve` it ends when response headers arrive
|
|
79
|
+
- `uploadTimeoutMs` (positive integer, optional): Deadline for each binary upload attempt. Unset by default, so large files are never cut off; text content uses `timeoutMs`
|
|
80
|
+
- `maxRetries` (integer, optional): Retries for failed requests that are safe to repeat (defaults to `2`, `0` disables them). See [Retries and idempotency](#retries-and-idempotency)
|
|
81
|
+
- `maxResponseBytes` (positive integer, optional): Largest JSON response the client reads (defaults to 32 MiB)
|
|
75
82
|
- `signal` (AbortSignal, optional): Cancels every request made by this client
|
|
76
|
-
- `
|
|
77
|
-
- `requestBackend` (optional): An `api-def` request backend for the JSON API, for example `new FetchRequestBackend(customFetch)`
|
|
83
|
+
- `dangerouslyAllowBrowser` (boolean, optional): Lets the client run in a web page. Only for trusted internal tools: anyone who can open the page can read the API key
|
|
78
84
|
|
|
79
|
-
Options are validated before anything else is set up: a missing `apiKey` or `projectId`, a `timeoutMs` that is not a positive integer, or a `baseUrl`/`cdnBaseUrl` that is not an absolute `http(s)` URL throws a `CalmLensError`.
|
|
85
|
+
Options are validated before anything else is set up: a missing `apiKey` or `projectId`, a `timeoutMs` that is not a positive integer, or a `baseUrl`/`cdnBaseUrl` that is not an absolute `http(s)` URL throws a `CalmLensError` with the code `sdk/invalid-config`. The client keeps its API key out of `console.log`, `util.inspect` and `JSON.stringify`.
|
|
80
86
|
|
|
81
87
|
#### Methods
|
|
82
88
|
|
|
@@ -223,6 +229,14 @@ result.items.forEach(asset => {
|
|
|
223
229
|
});
|
|
224
230
|
```
|
|
225
231
|
|
|
232
|
+
To read every matching asset, iterate instead of paging by hand. Pages are fetched as you go; assets created or deleted meanwhile can shift pages, so one may be skipped or repeated.
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
for await (const asset of client.assets.iterate({ filter: "status==rejected", pageSize: 100 })) {
|
|
236
|
+
console.log(asset.id);
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
226
240
|
##### assets.listChildren(options)
|
|
227
241
|
|
|
228
242
|
List assets extracted from a website or multimedia document.
|
|
@@ -233,6 +247,10 @@ const children = await client.assets.listChildren({
|
|
|
233
247
|
pageIndex: 0,
|
|
234
248
|
pageSize: 20,
|
|
235
249
|
});
|
|
250
|
+
|
|
251
|
+
for await (const child of client.assets.iterateChildren({ assetId: parentAssetId })) {
|
|
252
|
+
console.log(child.name);
|
|
253
|
+
}
|
|
236
254
|
```
|
|
237
255
|
|
|
238
256
|
##### assets.createSignedUrl(options)
|
|
@@ -362,12 +380,16 @@ const response = await client.cdn.serve({
|
|
|
362
380
|
});
|
|
363
381
|
```
|
|
364
382
|
|
|
383
|
+
`serve` returns the `Response` as soon as headers arrive, so you can stream the body (for example `return new Response(response.body, response)`); `timeoutMs` covers only the wait for headers. Non-2xx responses are returned rather than thrown. Pass `init` for extra `fetch` options such as `method: "HEAD"`.
|
|
384
|
+
|
|
365
385
|
Private CDN content cannot be read directly with API keys. Use a short-lived signed URL for application clients.
|
|
366
386
|
|
|
367
387
|
##### Multipart upload flow
|
|
368
388
|
|
|
369
389
|
Use `create` with `part` to start multipart uploads, and `uploadPart` for subsequent chunks. `partNumber` defaults to `1` and `isFinal` defaults to `false` when starting an upload.
|
|
370
390
|
|
|
391
|
+
The queue mode is chosen when the upload starts and saved by CalmLens: an upload started with `create` is queued when its final part arrives, one started with `moderate` waits for the decision. Later parts can come from another process that only knows the asset ID. Pass `queue` to `uploadPart` to override the saved choice for the final part. Pass `totalSizeInBytes` when you start the upload if you know the file's size, so CalmLens rejects an upload that could never finish before you send the rest.
|
|
392
|
+
|
|
371
393
|
Image upload sizes use distinct direct and multipart paths:
|
|
372
394
|
|
|
373
395
|
- Direct image uploads must be smaller than `10MB`.
|
|
@@ -376,7 +398,7 @@ Image upload sizes use distinct direct and multipart paths:
|
|
|
376
398
|
|
|
377
399
|
Each non-final multipart part must be at least `5MiB`; only the final part may be smaller. If you send base64, split the original bytes first, then base64-encode each part. Do not split one full base64 string into substrings.
|
|
378
400
|
|
|
379
|
-
Payloads can be sent as `{ plainText: string }` (text content only), `{ base64: string }`, `{ uint8Array: number[] | Uint8Array }`, `{ buffer: Buffer | ArrayBuffer }`, `{ data: Blob | Response | ReadableStream }`. Use `{ data: file }` for `File` objects. Upload metadata is URL query data and the SDK sends the binary payload as the raw request body without constructing multipart form data. Multipart `ReadableStream` uploads must include the exact `sizeInBytes` so CalmLens can stream each part without buffering it.
|
|
401
|
+
Payloads can be sent as `{ plainText: string }` (text content only), `{ base64: string }`, `{ uint8Array: number[] | Uint8Array }`, `{ buffer: Buffer | ArrayBuffer }`, `{ data: Blob | Response | ReadableStream }`. Use `{ data: file }` for `File` objects. To upload a file from disk in Node.js without reading it into memory, pass `{ data: await openAsBlob(path), type }` (`openAsBlob` from `node:fs`); unlike a stream, a `Blob` can be sent again if a retry is needed. Upload metadata is URL query data and the SDK sends the binary payload as the raw request body without constructing multipart form data. Multipart `ReadableStream` uploads must include the exact `sizeInBytes` so CalmLens can stream each part without buffering it.
|
|
380
402
|
|
|
381
403
|
Use a concrete asset MIME type such as `image/png`, `application/pdf`, or `text/plain`. The SDK rejects ambiguous/container request MIME types such as `application/octet-stream` and `multipart/*`.
|
|
382
404
|
|
|
@@ -425,37 +447,21 @@ console.log(analyzed.status);
|
|
|
425
447
|
|
|
426
448
|
##### verifyWebhookSignature(options)
|
|
427
449
|
|
|
428
|
-
Verify that a webhook delivery came from CalmLens and is fresh. Also exported as a standalone `verifyWebhookSignature` function.
|
|
450
|
+
Verify that a webhook delivery came from CalmLens and is fresh. Also exported as a standalone `verifyWebhookSignature` function; see [Webhooks](#webhooks) for `parseWebhookEvent`, which verifies and parses in one step.
|
|
429
451
|
|
|
430
452
|
```typescript
|
|
431
453
|
static async verifyWebhookSignature(options: {
|
|
432
|
-
payload: string |
|
|
433
|
-
signature
|
|
434
|
-
timestamp
|
|
454
|
+
payload: string | Uint8Array | ArrayBuffer; // the raw request body (a Buffer works too)
|
|
455
|
+
signature?: string | null; // x-calmlens-signature header
|
|
456
|
+
timestamp?: string | number | null; // x-calmlens-timestamp header
|
|
457
|
+
headers?: Headers | Record<string, string | string[] | undefined>; // read when signature/timestamp are omitted
|
|
435
458
|
secret: string;
|
|
436
|
-
toleranceSeconds?: number;
|
|
437
|
-
now?: Date | number;
|
|
459
|
+
toleranceSeconds?: number; // default WEBHOOK_SIGNATURE_TOLERANCE_SECONDS (300)
|
|
460
|
+
now?: Date | number; // default Date.now(), for tests
|
|
438
461
|
}): Promise<boolean>
|
|
439
462
|
```
|
|
440
463
|
|
|
441
|
-
It returns `true` only when the `x-calmlens-signature` header contains a `v1=` HMAC-SHA256 of `"<timestamp>.<raw body>"` (one match in a comma-separated list is enough) and the timestamp is within `toleranceSeconds` of now.
|
|
442
|
-
|
|
443
|
-
**Example:**
|
|
444
|
-
```typescript
|
|
445
|
-
// In your webhook handler (Fetch API Request)
|
|
446
|
-
const payload = await request.text();
|
|
447
|
-
const isValid = await CalmLensClient.verifyWebhookSignature({
|
|
448
|
-
payload,
|
|
449
|
-
signature: request.headers.get('x-calmlens-signature'),
|
|
450
|
-
timestamp: request.headers.get('x-calmlens-timestamp'),
|
|
451
|
-
secret: WEBHOOK_SECRET,
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
if (!isValid) {
|
|
455
|
-
return new Response('Unauthorized', { status: 401 });
|
|
456
|
-
}
|
|
457
|
-
const event: WebhookEventPayload = JSON.parse(payload);
|
|
458
|
-
```
|
|
464
|
+
It returns `true` only when the `x-calmlens-signature` header contains a `v1=` HMAC-SHA256 of `"<timestamp>.<raw body>"` (one match in a comma-separated list is enough) and the timestamp is within `toleranceSeconds` of now. Passing parsed JSON as `payload` throws `sdk/invalid-argument`: re-serializing it can change the signed bytes.
|
|
459
465
|
|
|
460
466
|
## Asset Types
|
|
461
467
|
|
|
@@ -499,26 +505,74 @@ See the [moderation report guide](https://calmlens.com/docs/guides/moderation-re
|
|
|
499
505
|
|
|
500
506
|
## Error Handling
|
|
501
507
|
|
|
502
|
-
|
|
508
|
+
Every failure is a `CalmLensError`, whether the API returned an error or the request never completed. Branch on its stable `code`:
|
|
503
509
|
|
|
504
510
|
```typescript
|
|
511
|
+
import { CALMLENS_ERROR_CODES, CalmLensError } from "@calmlens/sdk";
|
|
512
|
+
|
|
505
513
|
try {
|
|
506
|
-
|
|
507
|
-
name: "test.jpg",
|
|
508
|
-
// Missing required content, URL, or multipart part
|
|
509
|
-
});
|
|
514
|
+
await client.assets.retrieve({ assetId });
|
|
510
515
|
} catch (error) {
|
|
511
|
-
|
|
516
|
+
if (!CalmLensError.isCalmLensError(error)) throw error;
|
|
517
|
+
if (error.code === CALMLENS_ERROR_CODES.ASSET_NOT_FOUND) return undefined;
|
|
518
|
+
console.error(error.code, error.status, error.requestId, error.details);
|
|
519
|
+
throw error;
|
|
512
520
|
}
|
|
513
521
|
```
|
|
514
522
|
|
|
515
|
-
|
|
523
|
+
| Property | Meaning |
|
|
524
|
+
| --- | --- |
|
|
525
|
+
| `code` | A stable identifier such as `asset/not-found` or `sdk/timeout`. `CALMLENS_ERROR_CODES` lists the documented ones |
|
|
526
|
+
| `status` | The HTTP status, when the API responded |
|
|
527
|
+
| `requestId` | The `X-Request-Id` response header. Include it when you contact support |
|
|
528
|
+
| `headers` | The response headers, when the API responded |
|
|
529
|
+
| `retryAfter` | Seconds to wait, from the `Retry-After` header (on `429` and `idempotency/request-in-progress`) |
|
|
530
|
+
| `details` | Structured diagnostics from the API, such as validation issues and `traceId` |
|
|
531
|
+
| `cause` | The underlying error, for transport failures |
|
|
532
|
+
|
|
533
|
+
`error.isNotFound()` is true for `404`, and `error.isRetryable()` for transport failures, `429`, `502`, `503`, `504` and `idempotency/request-in-progress`. `instanceof CalmLensError` and `CalmLensError.isCalmLensError(error)` recognise errors from any copy of the SDK, including the ES module and CommonJS builds loaded side by side.
|
|
534
|
+
|
|
535
|
+
Codes the SDK raises itself:
|
|
536
|
+
|
|
537
|
+
| Code | Meaning |
|
|
538
|
+
| --- | --- |
|
|
539
|
+
| `sdk/invalid-argument` | A method argument is missing or invalid, for example an empty `assetId` |
|
|
540
|
+
| `sdk/invalid-config` | The client options are invalid |
|
|
541
|
+
| `sdk/transport-error` | The request could not be completed (DNS, TLS, refused or reset connections, truncated bodies, decompression errors) |
|
|
542
|
+
| `sdk/timeout` | A deadline passed |
|
|
543
|
+
| `sdk/cancelled` | Your `AbortSignal` cancelled the request |
|
|
544
|
+
| `sdk/invalid-response` | A successful response whose body is not the expected JSON |
|
|
545
|
+
| `sdk/http-error` | An HTTP failure without a CalmLens error body, for example from a proxy |
|
|
546
|
+
| `sdk/response-too-large` | A response larger than `maxResponseBytes` |
|
|
547
|
+
| `sdk/invalid-webhook-signature`, `sdk/invalid-webhook-payload` | Thrown by `parseWebhookEvent` |
|
|
548
|
+
|
|
549
|
+
Upload content is checked before it is sent with the same codes the API uses, such as `upload/invalid-payload` and `upload/invalid-mime-type`.
|
|
550
|
+
|
|
551
|
+
## Retries and idempotency
|
|
552
|
+
|
|
553
|
+
The client retries a request when repeating it is safe: reads (`GET`), and creates and uploads, which always carry an `Idempotency-Key` header. It retries failures that happened before any response, `429`, `502`, `503`, `504` and `idempotency/request-in-progress`, up to `maxRetries` times (default 2) with jittered exponential backoff, waiting for `Retry-After` when the API sends one (up to 60 seconds; longer waits are returned to you as the error's `retryAfter`). Other writes, such as `assets.move`, and uploads of a `ReadableStream`, which the first attempt consumed, are never retried.
|
|
554
|
+
|
|
555
|
+
`assets.create`, `assets.moderate`, `text.moderate` and `assets.uploadPart` generate a new key for each call and reuse it for their own retries, so a retry returns the original response instead of creating a second asset. To retry safely across processes or restarts, pass your own key:
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
await client.text.moderate({
|
|
559
|
+
text: comment.body,
|
|
560
|
+
externalId: `comment-${comment.id}`,
|
|
561
|
+
idempotencyKey: `comment-${comment.id}`,
|
|
562
|
+
});
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
The API keeps a response for 24 hours. Reusing a key for a different request fails with `idempotency/key-reused`.
|
|
566
|
+
|
|
567
|
+
## Forward compatibility
|
|
568
|
+
|
|
569
|
+
The SDK does not validate responses against its types, so fields and values added to the API later reach your code unchanged. `AssetStatus`, `AssetType` and `AssetVisibility` include `string` for that reason: handle values you don't know (for example, treat an unknown status as "not ready"). Event types and fact keys are typed for the ones this version knows; keep a default branch when you switch on them.
|
|
516
570
|
|
|
517
571
|
## Testing
|
|
518
572
|
|
|
519
|
-
`@calmlens/sdk/testing` exports a matching object-parameter `MockCalmLensClient`, in-memory storage implementations,
|
|
573
|
+
`@calmlens/sdk/testing` exports a matching object-parameter `MockCalmLensClient`, in-memory storage implementations, mock API key helpers, webhook delivery builders and `generateMediaTestCode`, for testing without network requests.
|
|
520
574
|
|
|
521
|
-
The main `@calmlens/sdk` entry point does not load or export testing helpers to keep production bundles
|
|
575
|
+
The main `@calmlens/sdk` entry point does not load or export testing helpers, to keep production bundles small. Keep `/testing` imports in test files.
|
|
522
576
|
|
|
523
577
|
```typescript
|
|
524
578
|
import { MockCalmLensClient, createMockApiKey } from "@calmlens/sdk/testing";
|
|
@@ -531,6 +585,8 @@ const client = new MockCalmLensClient({
|
|
|
531
585
|
|
|
532
586
|
`MockCalmLensClient` accepts the same options as `CalmLensClient` (`apiKey`, `projectId`, `baseUrl`, `cdnBaseUrl`, `timeoutMs`, ...) and validates them the same way, plus `mockOptions`. `cdn.getAssetUrl` uses `cdnBaseUrl` (default `mock://calmlens/v1`).
|
|
533
587
|
|
|
588
|
+
It behaves like the API where tests depend on it: errors carry the API's code, HTTP `status`, a `requestId` and `headers`; `assets.list` applies `ordering`, `folderId` and simple `filter` expressions (comparisons joined by `;` and `,`; anything else throws `sdk/invalid-argument` rather than returning wrong results); `assets.search` matches names, IDs, external IDs, descriptions and tags; multipart parts must arrive in order and the queue mode chosen at the start applies to the final part; a reused `idempotencyKey` replays the first result; and `cdn.serve` returns CDN-like responses with JSON error bodies. `client.enqueueEvent({ type, ... })` adds an event that `events.list` and `events.pages` then return.
|
|
589
|
+
|
|
534
590
|
Every call is recorded in `client.calls` as `{ operation, options }`, where `operation` is the method path (`"text.moderate"`, `"assets.retrieve"`, `"events.list"`, ...) and `options` is the object you passed. `client.callsTo(operation)` filters them. To test failure handling, `client.failNextRequest()` makes the next call throw the same `CalmLensError` the real client throws when the network fails (`sdk/transport-error`); pass `"timeout"` for `sdk/timeout`, or any `Error` to throw it as is, and `{ operation }` to fail only that method. Each queued failure is used once.
|
|
535
591
|
|
|
536
592
|
```typescript
|
|
@@ -543,6 +599,17 @@ await postComment(client, "hello");
|
|
|
543
599
|
expect(client.callsTo("text.moderate")[0]?.options).toMatchObject({ text: "hello" });
|
|
544
600
|
```
|
|
545
601
|
|
|
602
|
+
To test a webhook handler, build a signed delivery the way CalmLens sends it:
|
|
603
|
+
|
|
604
|
+
```typescript
|
|
605
|
+
import { createWebhookHeaders, toWebhookEventPayload } from "@calmlens/sdk/testing";
|
|
606
|
+
|
|
607
|
+
const event = client.enqueueEvent({ type: "asset.classified", assetId: asset.id });
|
|
608
|
+
const body = JSON.stringify(toWebhookEventPayload(event));
|
|
609
|
+
const headers = await createWebhookHeaders({ secret, body, deliveryId: "delivery-1" });
|
|
610
|
+
const response = await app.request("/api/webhooks/calmlens", { method: "POST", body, headers });
|
|
611
|
+
```
|
|
612
|
+
|
|
546
613
|
For `mockOptions`, deterministic timestamps and IDs, storage inspection, and testing recipes, see the [JavaScript SDK Testing Guide](https://calmlens.com/docs/sdk/javascript/testing).
|
|
547
614
|
|
|
548
615
|
## Webhooks
|
|
@@ -574,23 +641,53 @@ Each delivery has these headers (the names are exported as constants):
|
|
|
574
641
|
| `x-calmlens-timestamp` | `WEBHOOK_TIMESTAMP_HEADER` | Unix time in seconds when the delivery was signed |
|
|
575
642
|
| `x-calmlens-signature` | `WEBHOOK_SIGNATURE_HEADER` | `v1=<lowercase hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>` |
|
|
576
643
|
|
|
644
|
+
Verify the exact raw request body before parsing it; re-serializing parsed JSON can change the signed bytes. `parseWebhookEvent` verifies the delivery and returns the parsed event, or throws `sdk/invalid-webhook-signature` (bad signature or a timestamp more than `WEBHOOK_SIGNATURE_TOLERANCE_SECONDS`, 5 minutes, from your clock, which stops replays) or `sdk/invalid-webhook-payload`. `verifyWebhookSignature` takes the same options and returns a boolean. Both accept the body as a string, `Uint8Array`/`Buffer` or `ArrayBuffer`, and read the two headers from `headers` (a `Headers` object or Node's `req.headers`) when you don't pass `signature` and `timestamp`.
|
|
645
|
+
|
|
646
|
+
The result is typed as `WebhookEventPayload`; narrow it on `type` to get that event type's typed `data.facts` (`EventType` and `EventFacts<Type>` are exported too, and [the webhooks guide](https://calmlens.com/docs/guides/webhooks#event-types) lists every type). Deliveries are retried, so make your handler idempotent by `event.id`.
|
|
647
|
+
|
|
648
|
+
**Fetch API** (Next.js route handlers, Remix, Cloudflare Workers, Bun, Deno):
|
|
649
|
+
|
|
577
650
|
```typescript
|
|
578
|
-
import {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
651
|
+
import { CalmLensError, parseWebhookEvent } from "@calmlens/sdk";
|
|
652
|
+
|
|
653
|
+
export async function POST(request: Request) {
|
|
654
|
+
let event;
|
|
655
|
+
try {
|
|
656
|
+
event = await parseWebhookEvent({
|
|
657
|
+
payload: await request.text(),
|
|
658
|
+
headers: request.headers,
|
|
659
|
+
secret: process.env.CALMLENS_WEBHOOK_SECRET!,
|
|
660
|
+
});
|
|
661
|
+
} catch (error) {
|
|
662
|
+
if (CalmLensError.isCalmLensError(error)) return new Response("Invalid delivery", { status: 401 });
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
// Queue the work, keyed by event.id, then respond quickly.
|
|
666
|
+
return new Response(null, { status: 204 });
|
|
667
|
+
}
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
**Express**: use `express.raw()` for this route so `req.body` is the unparsed `Buffer`:
|
|
583
671
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
672
|
+
```typescript
|
|
673
|
+
app.post("/api/webhooks/calmlens", express.raw({ type: "application/json" }), async (req, res) => {
|
|
674
|
+
try {
|
|
675
|
+
const event = await parseWebhookEvent({
|
|
676
|
+
payload: req.body,
|
|
677
|
+
headers: req.headers,
|
|
678
|
+
secret: process.env.CALMLENS_WEBHOOK_SECRET!,
|
|
679
|
+
});
|
|
680
|
+
// ...
|
|
681
|
+
res.sendStatus(204);
|
|
682
|
+
} catch {
|
|
683
|
+
res.sendStatus(401);
|
|
684
|
+
}
|
|
590
685
|
});
|
|
591
686
|
```
|
|
592
687
|
|
|
593
|
-
|
|
688
|
+
**Hono**: `payload: await c.req.text()`, `headers: c.req.raw.headers`.
|
|
689
|
+
|
|
690
|
+
**Fastify**: register a raw body parser for the route, for example `fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => done(null, body))`, then pass `payload: request.body` and `headers: request.headers`.
|
|
594
691
|
|
|
595
692
|
## Command-line tool
|
|
596
693
|
|
|
@@ -621,19 +718,18 @@ The same feed is available in code with [`events.list`](#events-feed).
|
|
|
621
718
|
- Keep passing the latest `cursor` as `after` until `hasMore` is `false`, then wait before polling again. Store the cursor if you want to resume after a restart.
|
|
622
719
|
- An unknown or malformed cursor fails with the `CalmLensError` code `events/invalid-cursor`.
|
|
623
720
|
|
|
721
|
+
`client.events.pages({ after, limit })` follows the cursor for you: it yields each page until no more events are waiting.
|
|
722
|
+
|
|
624
723
|
```typescript
|
|
625
724
|
let { cursor } = await client.events.list(); // no events, cursor = "now"
|
|
626
725
|
|
|
627
726
|
async function drainEvents() {
|
|
628
|
-
|
|
629
|
-
while (hasMore) {
|
|
630
|
-
const page = await client.events.list({ after: cursor, limit: 100 });
|
|
727
|
+
for await (const page of client.events.pages({ after: cursor, limit: 100 })) {
|
|
631
728
|
for (const event of page.events) {
|
|
632
729
|
// narrow on event.type to read its typed facts
|
|
633
730
|
console.log(event.type, event.facts);
|
|
634
731
|
}
|
|
635
|
-
cursor = page.cursor;
|
|
636
|
-
hasMore = page.hasMore;
|
|
732
|
+
cursor = page.cursor; // save it to resume after a restart
|
|
637
733
|
}
|
|
638
734
|
}
|
|
639
735
|
|
|
@@ -642,13 +738,13 @@ setInterval(drainEvents, 5_000);
|
|
|
642
738
|
|
|
643
739
|
Events carry `facts: { key, value, url? }[]`; webhook deliveries carry the same events reshaped as `{ id, type, createdAt, projectId, data }`.
|
|
644
740
|
|
|
645
|
-
## Request budgets
|
|
741
|
+
## Request budgets
|
|
646
742
|
|
|
647
|
-
Set `timeoutMs` (default 120000) or `signal` on the client. Use `client.withRequestOptions({ signal, timeoutMs: 30000 })` to scope them to an operation without changing the original client
|
|
743
|
+
Set `timeoutMs` (default 120000), `uploadTimeoutMs`, `maxRetries` or `signal` on the client. Use `client.withRequestOptions({ signal, timeoutMs: 30000 })` to scope them to an operation without changing the original client; the scoped client shares the original's credentials and lens cache. `timeoutMs` applies to each attempt and includes reading the response body; binary uploads have no deadline unless you set `uploadTimeoutMs`, and `cdn.serve`'s deadline ends when headers arrive, so large transfers are never cut off. Requests never follow redirects, so the API key is never sent to another origin.
|
|
648
744
|
|
|
649
|
-
|
|
745
|
+
Requests send `User-Agent: calmlens-js/<version> (<runtime>/<version>)` from server runtimes; `SDK_VERSION` exports the version. Core model types (`Asset`, `Folder`, `Lens`, `AssetPage`, `EventsPage`, `ClassificationReport`) and request types are exported from the package root.
|
|
650
746
|
|
|
651
|
-
`text.moderate` is a convenience wrapper around asset moderation: it supplies `text/plain` content and defaults the asset name to `text`. `assets.create` queues processing; `assets.moderate` waits for classification.
|
|
747
|
+
`text.moderate` is a convenience wrapper around asset moderation: it supplies `text/plain` content and defaults the asset name to `text`. `assets.create` queues processing; `assets.moderate` waits for classification.
|
|
652
748
|
|
|
653
749
|
## Releasing
|
|
654
750
|
|