@polymorfa/nextjs 0.1.0-dev.20260922094209

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Polymorfa
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 ADDED
@@ -0,0 +1,224 @@
1
+ # `@polymorfa/nextjs`
2
+
3
+ Server-only helpers for Next.js App Router handlers. The package uses the Web
4
+ `Request` and `Response` APIs, so it does not add Next.js as a runtime dependency.
5
+
6
+ ## Drop-in handler
7
+
8
+ `createPolymorfaHandler` serves every drop-in route from one catch-all
9
+ route, `app/api/polymorfa/[...route]/route.ts`:
10
+
11
+ ```ts
12
+ import { MessagingClient, constructWebhookEvent } from "@polymorfa/sdk";
13
+ import { createPolymorfaHandler } from "@polymorfa/nextjs";
14
+
15
+ export const { GET, POST } = createPolymorfaHandler({
16
+ polymorfa: new MessagingClient({
17
+ credential: { type: "apiKey", value: process.env.POLYMORFA_API_KEY! },
18
+ }),
19
+ authenticate: (request) => currentUser(request), // your auth; null → 401
20
+ mint: (user) => ({
21
+ session: "support",
22
+ conversations: user.assignedConversationIds, // or "all"
23
+ allow: ["read_messages", "subscribe_events", "send_message"],
24
+ ttlSeconds: 600,
25
+ }),
26
+ webhooks: {
27
+ secret: process.env.POLYMORFA_WEBHOOK_SECRET!,
28
+ constructEvent: constructWebhookEvent,
29
+ onEvent: saveEvent,
30
+ },
31
+ history: { conversations: listConversations, messages: listMessages },
32
+ events: streamEvents,
33
+ });
34
+ ```
35
+
36
+ `mint` is required and is the only place permissions are chosen. It returns
37
+ `null` to refuse (`403`). There is no default that grants everything: `allow`
38
+ and `conversations` must be explicit. The handler calls `authenticate` and
39
+ `mint` on every request, so revoked access takes effect on the next call. The
40
+ browser never sends permissions, and the API key never leaves the server.
41
+
42
+ | Route | Method | Needs | Purpose |
43
+ | ------------------------------------ | ------ | ----------------------------------- | --------------------------------------------------------- |
44
+ | `token` | POST | signed-in user | Mints a client token and returns it with the grant |
45
+ | `webhooks` | POST | signature | Verifies the raw body, then calls `onEvent` |
46
+ | `media/:id` | GET | `read_messages` + `media.authorize` | Reuses `createMediaDownloadRoute` modes |
47
+ | `history/conversations` | GET | `read_messages` | Your list, filtered to the grant |
48
+ | `history/conversations/:id/messages` | GET | `read_messages` | `404` outside the grant |
49
+ | `history/conversations/:id/contact` | GET | `read_contact` | Contact panel data |
50
+ | `events` | GET | `subscribe_events` | Server-sent events, filtered by session and conversations |
51
+ | `connect` | POST | `connect_whatsapp` | Creates a QuickLink; the browser gets only the hosted URL |
52
+ | `templates` | POST | `manage_templates` | The template builder route plus `list` |
53
+
54
+ Enforcement: the Polymorfa API enforces the client-token actions (`send_*`,
55
+ `read_presence`, `subscribe_presence`, `read_contact`, `voip_*`). Session
56
+ tokens carry the session's client rules; `allow` narrows them in the API only
57
+ for Customer tokens (`customer`, beta), where the handler forwards the API
58
+ actions in `allow`. The handler enforces `read_messages`,
59
+ `subscribe_events`, `connect_whatsapp`, `manage_templates` and
60
+ `conversations` on its own routes. A Customer grant with no API action is
61
+ refused rather than minted with the session rules. For Customer grants your
62
+ `events` source must return only that Customer's events.
63
+
64
+ `createDevelopmentInboxStore()` keeps webhook events in memory and serves
65
+ them as `history` and `events`, so `<Inbox/>` works before you build
66
+ storage. It is lost on restart and not shared between instances; replace it
67
+ with your database before production.
68
+
69
+ ### Express and Hono
70
+
71
+ The handler uses Fetch `Request` and `Response`, so other servers need only an
72
+ adapter:
73
+
74
+ ```ts
75
+ import { toExpress } from "@polymorfa/nextjs/express";
76
+ app.use("/api/polymorfa", toExpress(handler)); // before express.json()
77
+
78
+ import { toHono } from "@polymorfa/nextjs/hono";
79
+ app.all("/api/polymorfa/*", toHono(handler));
80
+ ```
81
+
82
+ Mount `toExpress` before `express.json()`: webhook signatures are checked
83
+ against the raw body.
84
+
85
+ `createClientTokenRoute` requires an application-owned authorization callback.
86
+ `createMessagingClientTokenMint` adapts the real server SDK response to the
87
+ browser claim shape. Never send a server API key or messaging credential to the
88
+ browser.
89
+
90
+ ```ts
91
+ import { MessagingClient } from "@polymorfa/sdk";
92
+ import {
93
+ createClientTokenRoute,
94
+ createMessagingClientTokenMint,
95
+ } from "@polymorfa/nextjs";
96
+
97
+ const messaging = new MessagingClient({
98
+ credential: { type: "apiKey", value: process.env.POLYMORFA_API_KEY! },
99
+ });
100
+
101
+ export const POST = createClientTokenRoute({
102
+ authorize: async (request) => authorizeApplicationUser(request),
103
+ mint: createMessagingClientTokenMint({
104
+ clientTokens: messaging.clientTokens,
105
+ resolve: async (subject) => ({
106
+ session: "support",
107
+ ephemeralId: subject.userId,
108
+ ttlSeconds: 600,
109
+ }),
110
+ }),
111
+ });
112
+ ```
113
+
114
+ Browser calls use this same route. `POST /platform/client-tokens` is the only
115
+ way to mint a client token, so `@polymorfa/sdk/calls` and `@polymorfa/browser`
116
+ receive the token from `messaging.clientTokens.mint`. The path the browser
117
+ posts to is your own route; `createClientTokenProvider` defaults to
118
+ `/api/polymorfa/token`. Grant the session's client rules the Calls actions
119
+ the browser needs: `voip_place` to place calls and add participants,
120
+ `voip_answer` to accept or decline, and `voip_signal` for signaling, ending a
121
+ call, and the call lifecycle socket.
122
+
123
+ The server key that mints tokens needs all client delegation scopes:
124
+ `sessions:manage`, `messages:write`, `contacts:read`, `presence:read`,
125
+ `presence:observe`, and `mcp`.
126
+
127
+ To mint one token for the numbers a Customer owns (beta), return `customer`
128
+ instead of `session` from `resolve`. The server key also needs
129
+ `customers:read`. Resolve the Customer ID on your server from the signed-in
130
+ user; never take it from the request body.
131
+
132
+ ```ts
133
+ resolve: async (subject) => ({
134
+ customer: await customerIdForUser(subject.userId),
135
+ ephemeralId: subject.userId,
136
+ allow: ["send_message", "read_presence"],
137
+ }),
138
+ ```
139
+
140
+ The adapter throws before minting when both or neither of `session` and
141
+ `customer` are set, or when `allow` is set without `customer`; the route then
142
+ answers `500 token_mint_failed`.
143
+
144
+ `createTemplateBuilderRoute` pairs the browser template transport with
145
+ `MessagingClient.templates`. The application authorizes every request and
146
+ resolves both project scope and the Cloud API submission session on the server.
147
+
148
+ ```ts
149
+ import { MessagingClient } from "@polymorfa/sdk";
150
+ import { createTemplateBuilderRoute } from "@polymorfa/nextjs";
151
+
152
+ const messaging = new MessagingClient({
153
+ credential: { type: "apiKey", value: process.env.POLYMORFA_API_KEY! },
154
+ });
155
+
156
+ export const POST = createTemplateBuilderRoute({
157
+ templates: messaging.templates,
158
+ authorize: async (request) => authorizeApplicationUser(request),
159
+ resolveProjectSlug: async (subject) => projectSlugFor(subject.projectId),
160
+ resolveSubmissionSession: async (subject) =>
161
+ cloudSessionFor(subject.projectId),
162
+ });
163
+ ```
164
+
165
+ Browser request bodies cannot override either resolver. Responses are private,
166
+ non-cacheable, and server failures are returned without credential or internal
167
+ error details.
168
+
169
+ `createMediaDownloadRoute` serves Messaging media to signed-in users. Its
170
+ `authorize` callback is required, and the route denies with `403` when the
171
+ callback returns `null`, throws, or returns an invalid grant. Look up the
172
+ media in your own records inside `authorize` instead of trusting an ID from
173
+ the URL.
174
+
175
+ ```ts
176
+ import { MessagingClient } from "@polymorfa/sdk";
177
+ import { createMediaDownloadRoute } from "@polymorfa/nextjs";
178
+
179
+ const messaging = new MessagingClient({
180
+ credential: { type: "apiKey", value: process.env.POLYMORFA_API_KEY! },
181
+ });
182
+
183
+ export const GET = createMediaDownloadRoute({
184
+ client: messaging,
185
+ mode: "proxy", // or "redirect" | "whatsapp"
186
+ authorize: async (request) => {
187
+ const attachment = await attachmentForUser(request);
188
+ return attachment ? { mediaId: attachment.polymorfaMediaId } : null;
189
+ },
190
+ });
191
+ ```
192
+
193
+ | Mode | Behavior |
194
+ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
195
+ | `redirect` | Answers `302` with the short-lived signed storage URL. If the API streams that file instead, the route proxies it. |
196
+ | `proxy` | Streams the bytes through your route. |
197
+ | `whatsapp` | Downloads and decrypts the file from the WhatsApp CDN on the server. `authorize` returns `{ message: { type, media } }`, loaded from your stored webhook payload. |
198
+
199
+ The route accepts only `GET`. Every response carries `Cache-Control: private,
200
+ no-store`, `X-Content-Type-Options: nosniff` and `Referrer-Policy:
201
+ no-referrer`. Proxied responses also carry `Content-Security-Policy: sandbox`.
202
+ Common image, audio and video types (`INLINE_MEDIA_TYPES`, which excludes SVG)
203
+ are served `inline` with their normalized MIME type. All other types are served
204
+ as `application/octet-stream` with `Content-Disposition: attachment`. Filenames
205
+ are sanitized and sent with an RFC 6266 `filename*` parameter. The route
206
+ returns `404` when the media is missing and `502` for any other upstream
207
+ failure, with no upstream details. Redirect mode hands the signed URL to the
208
+ browser, so use it only when the user may hold that link until it expires.
209
+
210
+ `readVerifiedWebhook` preserves the raw request body and delegates verification
211
+ to `constructWebhookEvent` from `@polymorfa/sdk`.
212
+
213
+ ```ts
214
+ import { constructWebhookEvent } from "@polymorfa/sdk";
215
+ import { readVerifiedWebhook } from "@polymorfa/nextjs";
216
+
217
+ export async function POST(request: Request) {
218
+ const event = await readVerifiedWebhook(request, {
219
+ constructEvent: constructWebhookEvent,
220
+ secret: process.env.POLYMORFA_WEBHOOK_SECRET!,
221
+ });
222
+ return Response.json({ received: event.id });
223
+ }
224
+ ```
@@ -0,0 +1,35 @@
1
+ import type { HistoryConversation, HistoryMessage, HistoryPage, PolymorfaRouteContext, RelayEvent } from "./handler.js";
2
+ export interface DevelopmentInboxStoreOptions {
3
+ /** Conversations kept. Default 200. */
4
+ readonly maxConversations?: number;
5
+ /** Messages kept per conversation. Default 500. */
6
+ readonly maxMessages?: number;
7
+ /** Events kept for `Last-Event-ID` resume. Default 500. */
8
+ readonly replay?: number;
9
+ }
10
+ export interface DevelopmentInboxStore {
11
+ /** Pass as `webhooks.onEvent`: files verified webhook events. */
12
+ readonly record: (event: unknown) => void;
13
+ /** Pass as `history`. */
14
+ readonly history: {
15
+ conversations(context: PolymorfaRouteContext<unknown>, page: {
16
+ readonly cursor?: string;
17
+ }): Promise<HistoryPage<HistoryConversation>>;
18
+ messages(context: PolymorfaRouteContext<unknown>, conversationId: string, page: {
19
+ readonly cursor?: string;
20
+ }): Promise<HistoryPage<HistoryMessage>>;
21
+ };
22
+ /** Pass as `events`: live relay with `Last-Event-ID` resume. */
23
+ readonly events: (context: PolymorfaRouteContext<unknown> & {
24
+ readonly lastEventId?: string;
25
+ }) => AsyncIterable<RelayEvent>;
26
+ }
27
+ /**
28
+ * An in-memory inbox for local development and the quickstart: it files
29
+ * webhook events so `<Inbox/>` has history and live updates before you
30
+ * build your own storage. Data lives in this process only and is lost on
31
+ * restart; each server instance has its own copy. Replace it with your
32
+ * database before production.
33
+ */
34
+ export declare function createDevelopmentInboxStore(options?: DevelopmentInboxStoreOptions): DevelopmentInboxStore;
35
+ //# sourceMappingURL=development-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"development-store.d.ts","sourceRoot":"","sources":["../src/development-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,mBAAmB,EACnB,cAAc,EACd,WAAW,EACX,qBAAqB,EACrB,UAAU,EACX,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,4BAA4B;IAC3C,uCAAuC;IACvC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,mDAAmD;IACnD,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,qBAAqB;IACpC,iEAAiE;IACjE,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IAC1C,yBAAyB;IACzB,QAAQ,CAAC,OAAO,EAAE;QAChB,aAAa,CACX,OAAO,EAAE,qBAAqB,CAAC,OAAO,CAAC,EACvC,IAAI,EAAE;YAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,GACjC,OAAO,CAAC,WAAW,CAAC,mBAAmB,CAAC,CAAC,CAAC;QAC7C,QAAQ,CACN,OAAO,EAAE,qBAAqB,CAAC,OAAO,CAAC,EACvC,cAAc,EAAE,MAAM,EACtB,IAAI,EAAE;YAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,GACjC,OAAO,CAAC,WAAW,CAAC,cAAc,CAAC,CAAC,CAAC;KACzC,CAAC;IACF,gEAAgE;IAChE,QAAQ,CAAC,MAAM,EAAE,CACf,OAAO,EAAE,qBAAqB,CAAC,OAAO,CAAC,GAAG;QAAE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,KACxE,aAAa,CAAC,UAAU,CAAC,CAAC;CAChC;AASD;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,GAAE,4BAAiC,GACzC,qBAAqB,CAuJvB"}
@@ -0,0 +1,186 @@
1
+ const PAGE = 50;
2
+ /**
3
+ * An in-memory inbox for local development and the quickstart: it files
4
+ * webhook events so `<Inbox/>` has history and live updates before you
5
+ * build your own storage. Data lives in this process only and is lost on
6
+ * restart; each server instance has its own copy. Replace it with your
7
+ * database before production.
8
+ */
9
+ export function createDevelopmentInboxStore(options = {}) {
10
+ const maxConversations = options.maxConversations ?? 200;
11
+ const maxMessages = options.maxMessages ?? 500;
12
+ const replay = options.replay ?? 500;
13
+ const conversations = new Map();
14
+ const recent = [];
15
+ const listeners = new Set();
16
+ const record = (value) => {
17
+ const event = asEvent(value);
18
+ if (event === undefined)
19
+ return;
20
+ recent.push(event);
21
+ if (recent.length > replay)
22
+ recent.shift();
23
+ fileMessage(event);
24
+ for (const listener of [...listeners])
25
+ listener(event);
26
+ };
27
+ function fileMessage(event) {
28
+ if (event.event !== "message.received" && event.event !== "message.sent")
29
+ return;
30
+ const payload = event.payload;
31
+ const target = payload?.conversation;
32
+ if (typeof payload?.id !== "string" || typeof target?.id !== "string")
33
+ return;
34
+ const createdAt = time(payload.timestamp) ?? time(event.timestamp) ?? Date.now();
35
+ const direction = payload.fromMe === true || event.event === "message.sent"
36
+ ? "outbound"
37
+ : "inbound";
38
+ const text = typeof payload.text === "string"
39
+ ? payload.text
40
+ : typeof payload.caption === "string"
41
+ ? payload.caption
42
+ : "";
43
+ let conversation = conversations.get(target.id);
44
+ if (conversation === undefined) {
45
+ conversation = {
46
+ row: { id: target.id, lastActivity: 0, unreadCount: 0 },
47
+ messages: [],
48
+ };
49
+ conversations.set(target.id, conversation);
50
+ if (conversations.size > maxConversations) {
51
+ const oldest = [...conversations.values()].sort((a, b) => a.row.lastActivity - b.row.lastActivity)[0];
52
+ if (oldest !== undefined)
53
+ conversations.delete(oldest.row.id);
54
+ }
55
+ }
56
+ const existing = conversation.messages.findIndex((message) => message.id === payload.id);
57
+ const message = {
58
+ id: payload.id,
59
+ text,
60
+ createdAt,
61
+ direction,
62
+ status: "sent",
63
+ };
64
+ if (existing >= 0)
65
+ conversation.messages[existing] = message;
66
+ else {
67
+ conversation.messages.push(message);
68
+ conversation.messages.sort((a, b) => a.createdAt - b.createdAt);
69
+ if (conversation.messages.length > maxMessages)
70
+ conversation.messages.shift();
71
+ }
72
+ const name = direction === "inbound" && typeof payload.pushName === "string"
73
+ ? payload.pushName
74
+ : conversation.row.name;
75
+ const phone = typeof target.phoneNumber === "string"
76
+ ? target.phoneNumber
77
+ : conversation.row.phoneNumber;
78
+ conversation.row = {
79
+ id: target.id,
80
+ session: event.session,
81
+ ...(name === undefined ? {} : { name }),
82
+ ...(phone === undefined ? {} : { phoneNumber: phone }),
83
+ lastMessage: { text, createdAt, direction },
84
+ lastActivity: Math.max(conversation.row.lastActivity, createdAt),
85
+ unreadCount: conversation.row.unreadCount +
86
+ (direction === "inbound" && existing < 0 ? 1 : 0),
87
+ };
88
+ }
89
+ return {
90
+ record,
91
+ history: {
92
+ async conversations(_context, page) {
93
+ const rows = [...conversations.values()]
94
+ .map((conversation) => conversation.row)
95
+ .sort((a, b) => b.lastActivity - a.lastActivity);
96
+ return slice(rows, page.cursor);
97
+ },
98
+ async messages(_context, conversationId, page) {
99
+ // Newest page first; the cursor walks back in time.
100
+ const messages = conversations.get(conversationId)?.messages ?? [];
101
+ const end = page.cursor === undefined ? messages.length : Number(page.cursor);
102
+ const start = Math.max(0, end - PAGE);
103
+ return {
104
+ data: messages.slice(start, end),
105
+ nextCursor: start > 0 ? String(start) : null,
106
+ };
107
+ },
108
+ },
109
+ events(context) {
110
+ return {
111
+ [Symbol.asyncIterator]() {
112
+ const queue = [];
113
+ let wake;
114
+ let done = false;
115
+ const resumeAt = context.lastEventId === undefined
116
+ ? -1
117
+ : recent.findIndex((event) => event.id === context.lastEventId);
118
+ if (resumeAt >= 0)
119
+ queue.push(...recent.slice(resumeAt + 1));
120
+ const listener = (event) => {
121
+ queue.push(event);
122
+ wake?.();
123
+ };
124
+ listeners.add(listener);
125
+ const finish = () => {
126
+ done = true;
127
+ listeners.delete(listener);
128
+ wake?.();
129
+ };
130
+ context.signal.addEventListener("abort", finish, { once: true });
131
+ return {
132
+ async next() {
133
+ while (queue.length === 0 && !done)
134
+ await new Promise((resolve) => (wake = resolve));
135
+ wake = undefined;
136
+ const value = queue.shift();
137
+ return value === undefined
138
+ ? { done: true, value: undefined }
139
+ : { done: false, value };
140
+ },
141
+ async return() {
142
+ finish();
143
+ return { done: true, value: undefined };
144
+ },
145
+ };
146
+ },
147
+ };
148
+ },
149
+ };
150
+ }
151
+ function slice(rows, cursor) {
152
+ const start = cursor === undefined ? 0 : Number(cursor) || 0;
153
+ const end = start + PAGE;
154
+ return {
155
+ data: rows.slice(start, end),
156
+ nextCursor: end < rows.length ? String(end) : null,
157
+ };
158
+ }
159
+ function time(value) {
160
+ if (typeof value === "number" && Number.isFinite(value))
161
+ // Webhook timestamps are Unix seconds.
162
+ return value < 1e12 ? value * 1000 : value;
163
+ if (typeof value === "string") {
164
+ const parsed = Date.parse(value);
165
+ return Number.isFinite(parsed) ? parsed : undefined;
166
+ }
167
+ return undefined;
168
+ }
169
+ function asEvent(value) {
170
+ if (typeof value !== "object" || value === null)
171
+ return undefined;
172
+ const event = value;
173
+ if (typeof event.id !== "string" ||
174
+ typeof event.event !== "string" ||
175
+ typeof event.session !== "string")
176
+ return undefined;
177
+ return {
178
+ id: event.id,
179
+ session: event.session,
180
+ event: event.event,
181
+ timestamp: typeof event.timestamp === "string"
182
+ ? event.timestamp
183
+ : new Date().toISOString(),
184
+ payload: event.payload,
185
+ };
186
+ }
@@ -0,0 +1,21 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { PolymorfaHandler } from "./handler.js";
3
+ type NodeRequest = IncomingMessage & {
4
+ /** Express sets this to the URL including the mount path. */
5
+ readonly originalUrl?: string;
6
+ /** Set by a body parser. Only a raw `Buffer` or string can be forwarded. */
7
+ readonly body?: unknown;
8
+ };
9
+ /**
10
+ * Serves a Polymorfa handler from Express or plain `node:http`:
11
+ *
12
+ * ```ts
13
+ * app.use("/api/polymorfa", toExpress(handler));
14
+ * ```
15
+ *
16
+ * Mount it before `express.json()`, or use `express.raw()` for this path:
17
+ * webhook signatures are checked against the raw body.
18
+ */
19
+ export declare function toExpress(handler: PolymorfaHandler): (request: NodeRequest, response: ServerResponse, next?: (error?: unknown) => void) => Promise<void>;
20
+ export {};
21
+ //# sourceMappingURL=express.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"express.d.ts","sourceRoot":"","sources":["../src/express.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGjE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,KAAK,WAAW,GAAG,eAAe,GAAG;IACnC,6DAA6D;IAC7D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,4EAA4E;IAC5E,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CACvB,OAAO,EAAE,gBAAgB,GACxB,CACD,OAAO,EAAE,WAAW,EACpB,QAAQ,EAAE,cAAc,EACxB,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,KAC7B,OAAO,CAAC,IAAI,CAAC,CAuCjB"}
@@ -0,0 +1,92 @@
1
+ import { Readable } from "node:stream";
2
+ /**
3
+ * Serves a Polymorfa handler from Express or plain `node:http`:
4
+ *
5
+ * ```ts
6
+ * app.use("/api/polymorfa", toExpress(handler));
7
+ * ```
8
+ *
9
+ * Mount it before `express.json()`, or use `express.raw()` for this path:
10
+ * webhook signatures are checked against the raw body.
11
+ */
12
+ export function toExpress(handler) {
13
+ return async (request, response, next) => {
14
+ const abort = new AbortController();
15
+ response.on("close", () => {
16
+ if (!response.writableFinished)
17
+ abort.abort();
18
+ });
19
+ try {
20
+ const webResponse = await handler.handle(toRequest(request, abort.signal));
21
+ response.statusCode = webResponse.status;
22
+ webResponse.headers.forEach((value, name) => {
23
+ response.setHeader(name, value);
24
+ });
25
+ if (webResponse.body === null) {
26
+ response.end();
27
+ return;
28
+ }
29
+ response.flushHeaders?.();
30
+ const reader = webResponse.body.getReader();
31
+ abort.signal.addEventListener("abort", () => {
32
+ void reader.cancel().catch(() => undefined);
33
+ });
34
+ for (;;) {
35
+ const { done, value } = await reader.read();
36
+ if (done)
37
+ break;
38
+ if (!response.write(value))
39
+ await new Promise((resolve) => response.once("drain", resolve));
40
+ }
41
+ response.end();
42
+ }
43
+ catch (error) {
44
+ if (abort.signal.aborted)
45
+ return;
46
+ if (next !== undefined)
47
+ next(error);
48
+ else if (!response.headersSent) {
49
+ response.statusCode = 500;
50
+ response.end();
51
+ }
52
+ else
53
+ response.destroy();
54
+ }
55
+ };
56
+ }
57
+ function toRequest(request, signal) {
58
+ const host = request.headers.host ?? "localhost";
59
+ const encrypted = request.socket.encrypted;
60
+ const url = new URL(request.originalUrl ?? request.url ?? "/", `${encrypted === true ? "https" : "http"}://${host}`);
61
+ const headers = new Headers();
62
+ for (const [name, value] of Object.entries(request.headers)) {
63
+ if (value === undefined)
64
+ continue;
65
+ if (Array.isArray(value))
66
+ for (const item of value)
67
+ headers.append(name, item);
68
+ else
69
+ headers.set(name, value);
70
+ }
71
+ const method = (request.method ?? "GET").toUpperCase();
72
+ if (method === "GET" || method === "HEAD")
73
+ return new Request(url, { method, headers, signal });
74
+ let body;
75
+ if (Buffer.isBuffer(request.body))
76
+ body = new Uint8Array(request.body);
77
+ else if (typeof request.body === "string")
78
+ body = request.body;
79
+ else if (request.readableEnded)
80
+ // A JSON body parser already ran. Re-encoding works for JSON routes;
81
+ // webhook signatures fail, so mount toExpress() before express.json().
82
+ body = request.body === undefined ? "" : JSON.stringify(request.body);
83
+ else
84
+ body = Readable.toWeb(request);
85
+ return new Request(url, {
86
+ method,
87
+ headers,
88
+ body,
89
+ signal,
90
+ duplex: "half",
91
+ });
92
+ }