diff --git a/docs-src/spectrum-ts/getting-started.mdx.vel b/docs-src/spectrum-ts/getting-started.mdx.vel index 1cc2dfa..7731a2d 100644 --- a/docs-src/spectrum-ts/getting-started.mdx.vel +++ b/docs-src/spectrum-ts/getting-started.mdx.vel @@ -90,11 +90,14 @@ const app = await Spectrum({ app.messages // AsyncIterable<[Space, Message]> await app.send(space, ...) // send into a space await app.responding(space, fn) // run fn with a typing indicator +await app.webhook(req, handler) // handle an inbound webhook delivery await app.stop() // graceful shutdown ``` Custom events emitted by providers are exposed as flat async iterables on the same object — see [Custom events and lifecycle](/spectrum-ts/custom-events-and-lifecycle). +`app.webhook()` handles both native Spectrum webhooks and Fusor webhooks through the same method. See [Webhooks](/spectrum-ts/webhooks) for setup and framework adapters. + ## Multi-platform in three lines Combine providers to receive and send across platforms simultaneously: diff --git a/docs-src/spectrum-ts/introduction.mdx.vel b/docs-src/spectrum-ts/introduction.mdx.vel index bbd002a..43f2f2e 100644 --- a/docs-src/spectrum-ts/introduction.mdx.vel +++ b/docs-src/spectrum-ts/introduction.mdx.vel @@ -112,6 +112,9 @@ You do not have to manage Spectrum only from the dashboard. Use the dashboard fo Install `spectrum-ts` and send your first message. + + Receive messages via HTTP with native and Fusor webhook support, plus Hono, Express, and Elysia adapters. + Add a new interface with Spectrum's provider model. diff --git a/docs-src/spectrum-ts/webhooks.mdx.vel b/docs-src/spectrum-ts/webhooks.mdx.vel new file mode 100644 index 0000000..243f9a5 --- /dev/null +++ b/docs-src/spectrum-ts/webhooks.mdx.vel @@ -0,0 +1,192 @@ +--- +title: "Webhooks" +description: "Receive messages via HTTP instead of a long-lived process" +--- + +import { TypeTooltip } from "/snippets/type-tooltip.mdx"; + +`app.webhook()` lets you receive messages through HTTP `POST` requests instead of the `app.messages` stream. It handles two webhook formats through the same method: + +| | Native Spectrum webhook | Fusor webhook | +|---|---|---| +| Body | HMAC-signed, normalized JSON | Protobuf envelope (raw provider request) | +| Auth | HMAC over body, verified with `webhookSecret` | Platform's own signature via provider `verify()` | +| Requires a Fusor provider | No | Yes | + +Detection is by payload shape (JSON vs protobuf), not headers. Your handler receives the same `(space, message)` pair either way. + +## Configuring a webhook secret + +Native Spectrum webhooks require a signing secret for HMAC verification. Pass it to `Spectrum()`: + +```ts +const app = await Spectrum({ + projectId: process.env.PROJECT_ID!, + projectSecret: process.env.PROJECT_SECRET!, + providers: [imessage.config()], + webhookSecret: process.env.SPECTRUM_WEBHOOK_SECRET, +}); +``` + +The `webhookSecret` option can also be supplied via the `SPECTRUM_WEBHOOK_SECRET` environment variable (the explicit option takes precedence). A native delivery that arrives without a configured secret is answered `500`. + +## Receiving deliveries + +Call `app.webhook()` from your HTTP server's `POST` route. The method has two overloads: + + + + ```ts + server.post("/spectrum/webhook", (c) => + app.webhook(c.req.raw, async (space, message) => { + if (message.content.type === "text") { + await space.send(`echo: ${message.content.text}`); + } + }) + ); + ``` + + + ```ts + server.post( + "/spectrum/webhook", + express.raw({ type: "*/*" }), + async (req, res) => { + const result = await app.webhook( + { body: req.body, headers: req.headers }, + async (space, message) => { + if (message.content.type === "text") { + await space.send(`echo: ${message.content.text}`); + } + } + ); + res.status(result.status).set(result.headers).send(Buffer.from(result.body)); + } + ); + ``` + + + +The handler is invoked **fire-and-forget** — it runs after the HTTP response is sent. A throw is logged, never surfaced. Dedupe on `message.id` for exactly-once side effects. + + +Pass the raw body bytes. The HMAC is computed over the exact bytes on the wire. If your framework parses the body to JSON and you re-stringify it, the bytes change and verification fails. + + +## Framework adapters + +First-party adapters mount the endpoint for you and handle raw-body parsing correctly. Each is an optional subpath import — install the framework as a peer dependency only if you use it. + + + + ```ts + import { Hono } from "hono"; + import { Spectrum } from "spectrum-ts"; + import { imessage } from "spectrum-ts/providers/imessage"; + import { spectrum } from "spectrum-ts/hono"; + + const app = await Spectrum({ + projectId: process.env.PROJECT_ID!, + projectSecret: process.env.PROJECT_SECRET!, + providers: [imessage.config()], + webhookSecret: process.env.SPECTRUM_WEBHOOK_SECRET, + }); + + const server = new Hono().route( + "/", + spectrum({ + app, + onMessage: async (space, message) => { + if (message.content.type === "text") { + await space.send(`echo: ${message.content.text}`); + } + }, + }) + ); + + export default server; + ``` + + + ```ts + import express from "express"; + import { Spectrum } from "spectrum-ts"; + import { imessage } from "spectrum-ts/providers/imessage"; + import { spectrum } from "spectrum-ts/express"; + + const app = await Spectrum({ + projectId: process.env.PROJECT_ID!, + projectSecret: process.env.PROJECT_SECRET!, + providers: [imessage.config()], + webhookSecret: process.env.SPECTRUM_WEBHOOK_SECRET, + }); + + const server = express(); + + server.use( + spectrum({ + app, + onMessage: async (space, message) => { + if (message.content.type === "text") { + await space.send(`echo: ${message.content.text}`); + } + }, + }) + ); + + server.use(express.json()); + server.listen(3000); + ``` + + Mount the adapter **before** any global `express.json()`. A global JSON parser consumes the body stream first, breaking signature verification. + + + ```ts + import { Elysia } from "elysia"; + import { Spectrum } from "spectrum-ts"; + import { imessage } from "spectrum-ts/providers/imessage"; + import { spectrum } from "spectrum-ts/elysia"; + + const app = await Spectrum({ + projectId: process.env.PROJECT_ID!, + projectSecret: process.env.PROJECT_SECRET!, + providers: [imessage.config()], + webhookSecret: process.env.SPECTRUM_WEBHOOK_SECRET, + }); + + new Elysia() + .use( + spectrum({ + app, + onMessage: async (space, message) => { + if (message.content.type === "text") { + await space.send(`echo: ${message.content.text}`); + } + }, + }) + ) + .listen(3000); + ``` + + + +All three adapters accept the same options: + +| Option | Type | Default | Description | +|---|---|---|---| +| `app` | `Spectrum` instance | — | The instance returned by `await Spectrum({...})`. | +| `onMessage` | `(space, message) => void \| Promise` | — | Invoked once per inbound message, fire-and-forget. | +| `path` | `string` | `"/spectrum/webhook"` | Route the endpoint is mounted on. | + +## What the SDK handles + +- **Signature verification.** Native webhooks are verified with `HMAC-SHA256` over `v0::`, with a 5-minute replay window. Bad signature returns `401`, missing headers return `400`. +- **Payload deserialization.** Native webhook JSON is deserialized into normal `Message` / `Space` objects, including reactions and grouped items. +- **Attachment rehydration.** Native webhooks carry attachment metadata only. `read()` and `stream()` fetch the bytes lazily via the platform. +- **Format detection.** Native vs Fusor is detected per request by payload shape — JSON for native, protobuf for Fusor. + +## Delivery semantics + +`app.webhook()` is stateless and request-scoped — it does **not** feed `app.messages`, and it never opens the streaming connection. Both formats deliver at-least-once, so dedupe on `message.id` for exactly-once side effects. + +For more on Spectrum's webhook delivery model, see the [Webhooks documentation](/webhooks/overview). diff --git a/docs.json b/docs.json index 2f789fc..a894278 100644 --- a/docs.json +++ b/docs.json @@ -35,6 +35,7 @@ "spectrum-ts/spaces-and-users", "spectrum-ts/reactions-and-replies", "spectrum-ts/platform-narrowing", + "spectrum-ts/webhooks", { "group": "Providers", "pages": [ diff --git a/package.json b/package.json index 3fbbcea..e51dd35 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "eslint-plugin-format": "^2.0.1", "husky": "^9.1.7", "oxfmt": "^0.44.0", - "spectrum-ts": "3.1.0", + "spectrum-ts": "4.2.0", "tsx": "^4.21.0", "typescript": "^5.9.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f006a36..14208f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,8 +57,8 @@ importers: specifier: ^0.44.0 version: 0.44.0 spectrum-ts: - specifier: 3.1.0 - version: 3.1.0(typescript@5.9.3) + specifier: 4.2.0 + version: 4.2.0(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -594,10 +594,6 @@ packages: '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} - '@msgpack/msgpack@3.1.3': - resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==} - engines: {node: '>= 18'} - '@opentelemetry/api-logs@0.216.0': resolution: {integrity: sha512-KmGTgvxTJ0J01d4mOeX1wMV5NUTNf9HebIuOOGDfIn0a/IrnXIQbOnlylDyl9tkDv4h0DUpdI/GqCdLzfTkUXg==} engines: {node: '>=8.0.0'} @@ -1284,9 +1280,6 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - async-mutex@0.5.0: - resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1308,12 +1301,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - better-grpc@0.3.2: - resolution: {integrity: sha512-e+u6C4zHwjE5g7vOvDpFeMe7Nas7FU+xa6FktiheRTcOpEdD5nag+uoIw7L5bPXdxxg995feBAXLwIay/npEqw==} - engines: {node: '>=18.0.0'} - peerDependencies: - typescript: ^5 - better-sqlite3@12.10.0: resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} @@ -2048,9 +2035,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - it-pushable@3.2.4: - resolution: {integrity: sha512-WSD7Ss4oCRfDZJT4ldLWr0Bom/muY90xxoJ5PQnU3uSKf0kxCOeehqZtiJX1ARqn+ymXGh1bxpDW9bDNHp2ivQ==} - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -2380,10 +2364,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - p-defer@4.0.1: - resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==} - engines: {node: '>=12'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -2602,14 +2582,23 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} - spectrum-ts@3.1.0: - resolution: {integrity: sha512-Dv5rsXATxGUXFnKf3VPK0VpkMPyVkf4HHUYkti0V2AKhz2m+ut3I1UPNMsvZOsiqmF+5hW8Xvrw+u/I82+XcDA==} + spectrum-ts@4.2.0: + resolution: {integrity: sha512-2oCHMX+YjeRNaYI+aGamrK16k2v6I5MN83xgTKf8z1ouK4RklNohl+MH7+May9ZxMchr10ZkpdnsyVdUQenDrA==} peerDependencies: + elysia: ^1 + express: ^4 || ^5 ffmpeg-static: ^5 + hono: ^4 typescript: ^5 || ^6.0.0 peerDependenciesMeta: + elysia: + optional: true + express: + optional: true ffmpeg-static: optional: true + hono: + optional: true string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} @@ -3254,8 +3243,6 @@ snapshots: '@microsoft/tsdoc@0.15.1': {} - '@msgpack/msgpack@3.1.3': {} - '@opentelemetry/api-logs@0.216.0': dependencies: '@opentelemetry/api': 1.9.1 @@ -3904,10 +3891,6 @@ snapshots: asap@2.0.6: {} - async-mutex@0.5.0: - dependencies: - tslib: 2.8.1 - asynckit@0.4.0: {} axios@1.15.0: @@ -3927,15 +3910,6 @@ snapshots: baseline-browser-mapping@2.10.18: {} - better-grpc@0.3.2(typescript@5.9.3): - dependencies: - '@msgpack/msgpack': 3.1.3 - async-mutex: 0.5.0 - it-pushable: 3.2.4 - nice-grpc: 2.1.16 - typescript: 5.9.3 - zod: 4.4.3 - better-sqlite3@12.10.0: dependencies: bindings: 1.5.0 @@ -4742,10 +4716,6 @@ snapshots: isexe@2.0.0: {} - it-pushable@3.2.4: - dependencies: - p-defer: 4.0.1 - jiti@2.6.1: {} js-yaml@4.1.1: @@ -5288,8 +5258,6 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.44.0 '@oxfmt/binding-win32-x64-msvc': 0.44.0 - p-defer@4.0.1: {} - p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -5576,7 +5544,7 @@ snapshots: spdx-license-ids@3.0.23: {} - spectrum-ts@3.1.0(typescript@5.9.3): + spectrum-ts@4.2.0(typescript@5.9.3): dependencies: '@photon-ai/advanced-imessage': 0.11.2 '@photon-ai/imessage-kit': 3.0.0 @@ -5586,12 +5554,9 @@ snapshots: '@photon-ai/telegram-ts': 10.0.0 '@photon-ai/whatsapp-business': 0.1.1 '@repeaterjs/repeater': 3.0.6 - better-grpc: 0.3.2(typescript@5.9.3) lru-cache: 11.5.1 marked: 18.0.5 mime-types: 3.0.2 - nice-grpc: 2.1.16 - nice-grpc-common: 2.0.3 open-graph-scraper: 6.11.0 type-fest: 5.6.0 typescript: 5.9.3 @@ -5678,7 +5643,8 @@ snapshots: ts-error@1.0.6: {} - tslib@2.8.1: {} + tslib@2.8.1: + optional: true tsx@4.21.0: dependencies: