@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 +21 -0
- package/README.md +224 -0
- package/dist/development-store.d.ts +35 -0
- package/dist/development-store.d.ts.map +1 -0
- package/dist/development-store.js +186 -0
- package/dist/express.d.ts +21 -0
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +92 -0
- package/dist/handler.d.ts +229 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +434 -0
- package/dist/hono.d.ts +15 -0
- package/dist/hono.d.ts.map +1 -0
- package/dist/hono.js +11 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/media-route.d.ts +76 -0
- package/dist/media-route.d.ts.map +1 -0
- package/dist/media-route.js +151 -0
- package/dist/messaging-token-mint.d.ts +40 -0
- package/dist/messaging-token-mint.d.ts.map +1 -0
- package/dist/messaging-token-mint.js +38 -0
- package/dist/template-builder-route.d.ts +123 -0
- package/dist/template-builder-route.d.ts.map +1 -0
- package/dist/template-builder-route.js +187 -0
- package/dist/token-route.d.ts +19 -0
- package/dist/token-route.d.ts.map +1 -0
- package/dist/token-route.js +60 -0
- package/dist/webhook.d.ts +10 -0
- package/dist/webhook.d.ts.map +1 -0
- package/dist/webhook.js +16 -0
- package/package.json +54 -0
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"}
|
package/dist/express.js
ADDED
|
@@ -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
|
+
}
|