@vercel/board 0.0.0 → 0.16.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.
Files changed (126) hide show
  1. package/README.md +180 -0
  2. package/dist/a2-C41hD8SF.mjs +3045 -0
  3. package/dist/a2-D24cvwpN.d.mts +3359 -0
  4. package/dist/access-CJU2-vaa.mjs +226 -0
  5. package/dist/access-CV96SYgr.d.mts +32 -0
  6. package/dist/access.d.mts +30 -0
  7. package/dist/access.mjs +4 -0
  8. package/dist/actors-DjMeZpnU.mjs +224 -0
  9. package/dist/attempts-Bxp9KgPf.mjs +919 -0
  10. package/dist/auth-COzc09sn.mjs +276 -0
  11. package/dist/auth-CU56Q2Xh.d.mts +204 -0
  12. package/dist/backend-a2/blob-vercel.d.mts +8 -0
  13. package/dist/backend-a2/blob-vercel.mjs +81 -0
  14. package/dist/backend-a2/contract.d.mts +18 -0
  15. package/dist/backend-a2/contract.mjs +5 -0
  16. package/dist/backend-a2/scheduler-local.d.mts +11 -0
  17. package/dist/backend-a2/scheduler-local.mjs +2 -0
  18. package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
  19. package/dist/backend-a2/scheduler-qstash.mjs +2 -0
  20. package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
  21. package/dist/backend-a2/scheduler-vercel.mjs +2 -0
  22. package/dist/backend-a2/store-memory.d.mts +11 -0
  23. package/dist/backend-a2/store-memory.mjs +19 -0
  24. package/dist/backend-a2/store-postgres.d.mts +38 -0
  25. package/dist/backend-a2/store-postgres.mjs +110 -0
  26. package/dist/backend-a2/store-redis-http.d.mts +17 -0
  27. package/dist/backend-a2/store-redis-http.mjs +32 -0
  28. package/dist/backend-a2/store-redis.d.mts +23 -0
  29. package/dist/backend-a2/store-redis.mjs +39 -0
  30. package/dist/backend-a2/store-sqlite.d.mts +12 -0
  31. package/dist/backend-a2/store-sqlite.mjs +31 -0
  32. package/dist/backend-a2.d.mts +4 -0
  33. package/dist/backend-a2.mjs +2 -0
  34. package/dist/backend-http-0roEmjl0.mjs +613 -0
  35. package/dist/backend-http-BVRno-ca.mjs +469 -0
  36. package/dist/backend-http-Bid7RIpC.d.mts +630 -0
  37. package/dist/backend-http.d.mts +10 -0
  38. package/dist/backend-http.mjs +2 -0
  39. package/dist/backend-http.node.d.mts +10 -0
  40. package/dist/backend-http.node.mjs +2 -0
  41. package/dist/blob-C0J3wbGv.d.mts +39 -0
  42. package/dist/bots-CGkO76qH.mjs +67 -0
  43. package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
  44. package/dist/chat-provider-jFSeyNmB.mjs +389 -0
  45. package/dist/cli.d.mts +1 -0
  46. package/dist/cli.mjs +2252 -0
  47. package/dist/client-DGDPaN7U.mjs +380 -0
  48. package/dist/conformance-DRR23YNy.mjs +512 -0
  49. package/dist/conformance.d.mts +30 -0
  50. package/dist/conformance.mjs +2 -0
  51. package/dist/contract-CuJmlrhw.mjs +566 -0
  52. package/dist/emoji-CTLpvH-A.mjs +445 -0
  53. package/dist/engine-Bir5s8ts.mjs +1449 -0
  54. package/dist/errors-DX3hpwWt.mjs +64 -0
  55. package/dist/exports-DLiE7fLY.d.mts +4 -0
  56. package/dist/exports-Dvc1o52I.mjs +1285 -0
  57. package/dist/files-SniSmeEr.mjs +47 -0
  58. package/dist/index.d.mts +13 -0
  59. package/dist/index.default-Bkg77Pxn.mjs +12 -0
  60. package/dist/index.mjs +19 -0
  61. package/dist/index.node.d.mts +14 -0
  62. package/dist/index.node.mjs +5 -0
  63. package/dist/inspect/app.js +416 -0
  64. package/dist/inspect/engine.js +5 -0
  65. package/dist/local-Cr_dSjvs.mjs +44 -0
  66. package/dist/own-OwgPWdmh.mjs +15 -0
  67. package/dist/project-CFiArraX.mjs +124 -0
  68. package/dist/provider-BGjz-mDa.d.mts +2473 -0
  69. package/dist/provider-CuewcDNZ.mjs +205 -0
  70. package/dist/redis-tables-B35vv4CZ.mjs +842 -0
  71. package/dist/reducers-B_vUGQRp.d.mts +1172 -0
  72. package/dist/reducers-mUPFuFJV.mjs +293 -0
  73. package/dist/schemas-Be08nyfW.mjs +74 -0
  74. package/dist/schemas-CWxs3ECJ.d.mts +50 -0
  75. package/dist/serve-BsOOc-6_.mjs +170 -0
  76. package/dist/server-jwPY7_wi.mjs +17 -0
  77. package/dist/serving-BNbzc96L.mjs +2 -0
  78. package/dist/sql-DbiEnqM0.mjs +571 -0
  79. package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
  80. package/dist/sync-github.d.mts +473 -0
  81. package/dist/sync-github.mjs +603 -0
  82. package/dist/sync-kapso.d.mts +175 -0
  83. package/dist/sync-kapso.mjs +86 -0
  84. package/dist/sync-slack.d.mts +364 -0
  85. package/dist/sync-slack.mjs +728 -0
  86. package/dist/sync.chat.d.mts +2 -0
  87. package/dist/sync.chat.mjs +2 -0
  88. package/dist/sync.d.mts +3 -0
  89. package/dist/sync.mjs +4 -0
  90. package/dist/targets-yM5JBUgr.mjs +94 -0
  91. package/dist/tokens-DKLn5P9B.mjs +256 -0
  92. package/dist/types-BHCjTZdB.d.mts +104 -0
  93. package/dist/users-Dls7_ANV.mjs +42 -0
  94. package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
  95. package/dist/watchers-hash-CLjsa6od.mjs +225 -0
  96. package/docs/01-board/01-mounting.md +506 -0
  97. package/docs/01-board/02-database.md +258 -0
  98. package/docs/01-board/03-identity.md +264 -0
  99. package/docs/01-board/04-messages.md +192 -0
  100. package/docs/01-board/05-reading.md +177 -0
  101. package/docs/01-board/06-watchers.md +249 -0
  102. package/docs/01-board/07-schedules.md +133 -0
  103. package/docs/01-board/08-middleware.md +139 -0
  104. package/docs/01-board/09-access.md +501 -0
  105. package/docs/01-quickstart.md +196 -0
  106. package/docs/02-sync/01-overview.md +290 -0
  107. package/docs/02-sync/02-callbacks.md +265 -0
  108. package/docs/02-sync/03-slack.md +248 -0
  109. package/docs/02-sync/04-github.md +201 -0
  110. package/docs/02-sync/05-whatsapp.md +148 -0
  111. package/docs/02-sync/06-providers.md +228 -0
  112. package/docs/03-guides/01-coding-agents.md +226 -0
  113. package/docs/03-guides/02-inspector.md +104 -0
  114. package/docs/03-guides/03-ci.md +251 -0
  115. package/docs/03-guides/04-triage.md +233 -0
  116. package/docs/03-guides/05-agent-factory.md +142 -0
  117. package/docs/04-reference/01-cli.md +606 -0
  118. package/docs/04-reference/02-board.md +579 -0
  119. package/docs/04-reference/03-http.md +489 -0
  120. package/docs/04-reference/04-log.md +250 -0
  121. package/docs/04-reference/05-conformance.md +97 -0
  122. package/docs/index.md +186 -0
  123. package/package.json +168 -1
  124. package/sql/postgres/board.sql +145 -0
  125. package/sql/postgres/notifications.sql +24 -0
  126. package/sql/postgres/schema.sql +451 -0
@@ -0,0 +1,469 @@
1
+ import "./files-SniSmeEr.mjs";
2
+ import "./schemas-Be08nyfW.mjs";
3
+ import { V as routes, a as DERIVED_ID, c as keyId, l as sha256, o as derivedId, t as compileWatcher, w as RegisterBody } from "./watch-spec-W0c7ypHJ.mjs";
4
+ import { n as createBoardClient, t as BoardError } from "./client-DGDPaN7U.mjs";
5
+ import { n as METHODS, t as canonicalWatchers } from "./watchers-hash-CLjsa6od.mjs";
6
+ import { a as forbidden, o as json, t as HttpError } from "./errors-DX3hpwWt.mjs";
7
+ //#region src/server/backend-http.ts
8
+ /** A watcher's or client's name is the profile its writes are by. */
9
+ const PROFILE = /^[A-Za-z][A-Za-z0-9-]{0,31}$/;
10
+ const PROFILE_RULE = "ids start with a letter and use up to 32 letters, numbers, and hyphens";
11
+ /**
12
+ * What a board's own callers can't do on the board it uses, where they act as its project: what's
13
+ * the project's own there (its registration, its sync, its links), and what's anyone's own there by
14
+ * principal (editing, deleting, scheduling, and uploading, since a file is its uploader's to read
15
+ * and attach until a message carries it), which the board it uses can't tell them apart for.
16
+ */
17
+ const PROJECTS_OWN = /* @__PURE__ */ new Set([
18
+ "identity",
19
+ "edit",
20
+ "delete",
21
+ "schedule",
22
+ "cancelSchedule",
23
+ "upload",
24
+ "uploadStart",
25
+ "uploadFinish",
26
+ "watchers",
27
+ "register",
28
+ "matches",
29
+ "ack",
30
+ "syncImport",
31
+ "syncReceipt",
32
+ "syncThread",
33
+ "syncFind",
34
+ "syncCopies",
35
+ "syncRoots",
36
+ "inboxPush",
37
+ "inboxNext",
38
+ "inboxAck",
39
+ "inbox",
40
+ "syncCode",
41
+ "syncAccount",
42
+ "syncUnlink"
43
+ ]);
44
+ /**
45
+ * A caller's handle as a profile on the board this one uses: `ada`, `ada-lovelace` for
46
+ * `ada.lovelace`, under the project. It's a label, never who they are there.
47
+ */
48
+ function profileOf(handle) {
49
+ let name = handle.replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
50
+ if (!/^[A-Za-z]/.test(name)) name = `u-${name}`;
51
+ return name.slice(0, 32).replace(/-+$/, "");
52
+ }
53
+ /**
54
+ * A caller's profile there, never one this board's own code writes as (a watcher's ID, or the
55
+ * middleware's): a write by a watcher's profile never wakes that watcher. One that would be gets a
56
+ * number, `fix-2`.
57
+ */
58
+ function callerProfile(handle, own) {
59
+ const taken = new Set([
60
+ ...own,
61
+ "middleware",
62
+ "client"
63
+ ].map((name) => name.toLowerCase()));
64
+ const base = profileOf(handle);
65
+ let name = base;
66
+ for (let n = 2; taken.has(name.toLowerCase()); n++) name = `${base.slice(0, 31 - String(n).length)}-${n}`;
67
+ return name;
68
+ }
69
+ /** What a caller may do, as `x-board-scope` says it: the board it uses narrows the project's access to it. Null for everything. */
70
+ function scopeOf(identity) {
71
+ const { access } = identity.principal;
72
+ if (access.everywhere("manage")) return null;
73
+ const entries = Object.entries(access.summary()).map(([pattern, permissions]) => `${pattern}=${permissions.join(",")}`);
74
+ return entries.length > 0 ? entries.join(";") : "*=";
75
+ }
76
+ /** Registering again this soon after the same registration only repeats it. */
77
+ const REGISTER_EVERY_MS = 1e4;
78
+ /** How long one wake-up runs matches before it leaves the rest to the next. */
79
+ const DRAIN_MS = 24e4;
80
+ /** A pull with nothing to hand out holds this long, when the board pulls on its own. */
81
+ const POLL_WAIT_S = 25;
82
+ const describe = (error) => ((error instanceof Error ? error.message : String(error)) || "unknown error").slice(0, 500);
83
+ /** The SDK's error, as the board answers it: the remote's own status and code, or 502 when it never answered. */
84
+ const rethrow = (error) => {
85
+ if (error instanceof BoardError) throw new HttpError(error.status >= 400 ? error.status : 502, error.code, error.message);
86
+ if (error instanceof HttpError || error instanceof Error && error.name === "AbortError") throw error;
87
+ throw new HttpError(502, "unreachable", `the board didn't answer: ${describe(error)}`);
88
+ };
89
+ /** A parsed input, back on the wire as a query string. */
90
+ function queryString(input) {
91
+ const params = new URLSearchParams();
92
+ for (const [key, value] of Object.entries(input)) {
93
+ if (value === void 0 || value === null || value === false) continue;
94
+ if (key === "label" && Array.isArray(value)) for (const [name, values] of value) params.append("label", values === null ? name : `${name}=${values.join("|")}`);
95
+ else if (Array.isArray(value)) for (const item of value) params.append(key, String(item));
96
+ else params.set(key, String(value));
97
+ }
98
+ const text = params.toString();
99
+ return text ? `?${text}` : "";
100
+ }
101
+ /** A file the remote served, as the board stored it: its headers say what it is. */
102
+ function attachmentOf(id, response) {
103
+ const encoded = (response.headers.get("content-disposition") ?? "").match(/filename\*=UTF-8''([^;]+)/)?.[1];
104
+ let name = "file";
105
+ try {
106
+ if (encoded) name = decodeURIComponent(encoded);
107
+ } catch {}
108
+ return {
109
+ attachment: {
110
+ id,
111
+ name,
112
+ type: response.headers.get("content-type") ?? "application/octet-stream",
113
+ size: Number(response.headers.get("content-length") ?? 0)
114
+ },
115
+ data: response.body ?? /* @__PURE__ */ new Uint8Array()
116
+ };
117
+ }
118
+ /**
119
+ * Another board that speaks the protocol, at `url` (in Node, the one the environment points at
120
+ * by default): this board keeps nothing, and this deployment reads and writes that one as itself.
121
+ * Its watchers' tails are registered there. The remote wakes it when one matches, with nothing in
122
+ * the wake-up but a hash, and it pulls what matched with its own credentials, runs it, and acks
123
+ * it. When it can't be woken (no https URL of its own: a dev server, say), it pulls on its own.
124
+ */
125
+ function createHttp(url, options, environment) {
126
+ if (url !== void 0 && !/^https?:\/\/[^/]/.test(url)) throw new Error(`http: ${url} isn't a URL: pass the board's, like https://board.example.com/api/board`);
127
+ if (url === void 0 && !environment) throw new Error("http: pass the board's URL: outside Node there's no environment to find it in");
128
+ const remote = (url ?? environment.url()).replace(/\/+$/, "");
129
+ const doFetch = options.fetch ?? environment?.fetch(remote) ?? ((input, init) => globalThis.fetch(input, init));
130
+ let binding = null;
131
+ const specs = {};
132
+ /** This board's sync entries, registered there when it syncs. */
133
+ let sync = [];
134
+ let hash = null;
135
+ /** What every wake-up carries, so the board it uses can tell it's stale. */
136
+ const watchersHash = () => hash ??= sha256(canonicalWatchers(specs, sync)).then((hex) => hex.slice(0, 32));
137
+ const bound = () => {
138
+ if (!binding) throw new Error("http: this backend isn't serving a board yet: pass it to board({ backend })");
139
+ return binding;
140
+ };
141
+ /**
142
+ * The SDK against the remote, as this deployment with `profile`. `quiet` names no session: this
143
+ * deployment's own code. Otherwise a profile chosen in code keeps the environment's session, and
144
+ * yourself (`null`) has none. Who the token's holder is, `fetch` says.
145
+ */
146
+ const sdk = (profile, quiet) => createBoardClient({
147
+ url: remote,
148
+ fetch: doFetch,
149
+ profile: profile !== void 0 ? profile : () => environment?.profile().profile ?? null,
150
+ session: quiet || profile === null ? null : () => environment?.profile().session ?? null
151
+ });
152
+ const own = sdk(null, true);
153
+ const profiles = /* @__PURE__ */ new Map();
154
+ /** This deployment as `profile`: a watcher's ID, or the middleware. */
155
+ function asProfile(profile) {
156
+ if (!PROFILE.test(profile)) throw new HttpError(400, "invalid_request", `as: ${profile}: profiles start with a letter and use up to 32 letters, numbers, and hyphens`);
157
+ let client = profiles.get(profile);
158
+ if (!client) profiles.set(profile, client = sdk(profile, true));
159
+ return client;
160
+ }
161
+ const holders = /* @__PURE__ */ new Map();
162
+ /** The token's holder, with `profile` (`undefined`: the environment's). */
163
+ function holder(profile) {
164
+ const key = JSON.stringify(profile ?? (profile === null ? null : "\0"));
165
+ let client = holders.get(key);
166
+ if (!client) holders.set(key, client = sdk(profile, false));
167
+ return client;
168
+ }
169
+ /**
170
+ * The client a call of this deployment's own code goes through: a handle's, as the token's holder
171
+ * with its profile; a watcher's or the middleware's, as this deployment with theirs; and sync's,
172
+ * as the deployment itself.
173
+ */
174
+ function clientOf(caller) {
175
+ if (caller.origin.via?.kind === "engine") return own;
176
+ if (caller.origin.via?.kind === "client") {
177
+ if (caller.self !== null) asProfile(caller.self);
178
+ return holder("profile" in caller ? caller.profile : caller.self ?? void 0);
179
+ }
180
+ return asProfile(caller.self ?? "client");
181
+ }
182
+ let discovered = null;
183
+ /** The board it uses' discovery, for a handle here: who it is, and the sync entries it lists. */
184
+ const discovery = () => (discovered ??= doFetch(remote).then(async (response) => {
185
+ if (!response.ok) throw new HttpError(response.status >= 400 ? response.status : 502, "no_board", `no board at ${remote} (${response.status})`);
186
+ return await response.json();
187
+ }, rethrow)).catch((error) => {
188
+ discovered = null;
189
+ throw error;
190
+ });
191
+ let principal = null;
192
+ /** This deployment's principal on the remote, asked once. */
193
+ const self = () => principal ??= own.me().then((me) => me.principal).catch((error) => {
194
+ principal = null;
195
+ throw error;
196
+ });
197
+ /**
198
+ * A post's ID, from the one board() derived for it, under this deployment's own principal: the
199
+ * remote takes a `key:<principal>:` ID only from that principal, so no one can post with it first.
200
+ */
201
+ const ownKey = async (derived) => keyId(await self(), derived);
202
+ /** One protocol call on the remote, as `client`: the same route, with the input back on the wire. */
203
+ async function call(client, method, input, signal) {
204
+ const route = routes[method];
205
+ const fields = { ...input };
206
+ const path = route.path.replace(/:(\w+)/g, (_, name) => {
207
+ const value = fields[name];
208
+ delete fields[name];
209
+ return encodeURIComponent(String(value));
210
+ });
211
+ let response;
212
+ if (method === "upload") {
213
+ const { data, name, type } = input;
214
+ if (data.byteLength > 4194304) {
215
+ const attachment = await client.upload({
216
+ data,
217
+ name: name ?? "file",
218
+ ...type ? { type } : {}
219
+ }).catch(rethrow);
220
+ return Response.json({ attachment });
221
+ }
222
+ response = await client.request(path, {
223
+ method: "POST",
224
+ body: data,
225
+ headers: {
226
+ ...type ? { "content-type": type } : {},
227
+ ...name !== null ? { "x-board-filename": encodeURIComponent(name) } : {}
228
+ },
229
+ signal
230
+ }).catch(rethrow);
231
+ } else if (route.method === "GET") response = await client.request(`${path}${queryString(fields)}`, { signal }).catch(rethrow);
232
+ else response = await client.request(path, {
233
+ method: "POST",
234
+ body: new TextEncoder().encode(JSON.stringify(fields)),
235
+ headers: { "content-type": "application/json" },
236
+ signal
237
+ }).catch(rethrow);
238
+ if (response.ok) return response;
239
+ try {
240
+ const body = await response.json();
241
+ if (body.error?.message) throw new HttpError(response.status, body.error.code ?? "error", body.error.message);
242
+ } catch (error) {
243
+ if (error instanceof HttpError) throw error;
244
+ }
245
+ throw new HttpError(502, "remote_error", `${remote} answered ${response.status}`);
246
+ }
247
+ /**
248
+ * A call of one of this board's own callers, which board() verified and limited, on the board it
249
+ * uses: as this deployment, with the caller's handle as its profile, a label there and never who
250
+ * it is, narrowed to what the caller may do. Its post IDs are its own, under its principal here.
251
+ * Who it is here, the board answers itself.
252
+ */
253
+ async function asProject(method, identity, input, signal) {
254
+ if (method === "me") {
255
+ const { principal, author, actor } = identity;
256
+ return {
257
+ actor: actor.id,
258
+ principal: principal.id,
259
+ kind: actor.kind,
260
+ handle: actor.handle,
261
+ name: actor.name,
262
+ avatar: actor.avatar,
263
+ profile: author.profile,
264
+ session: author.session,
265
+ expiresAt: principal.expiresAt?.toISOString() ?? null,
266
+ can: principal.access.summary()
267
+ };
268
+ }
269
+ if (PROJECTS_OWN.has(method)) throw forbidden(`this board's callers act as its project on ${remote}, which can't tell them apart: ${method} is the project's own to do there`);
270
+ const scope = scopeOf(identity);
271
+ const avatar = identity.actor.avatar?.startsWith("https://") ? identity.actor.avatar : void 0;
272
+ const client = createBoardClient({
273
+ url: remote,
274
+ fetch: doFetch,
275
+ profile: {
276
+ name: callerProfile(identity.actor.handle, Object.keys(specs)),
277
+ ...avatar ? { avatar } : {}
278
+ },
279
+ session: null,
280
+ ...scope !== null ? { headers: { "x-board-scope": scope } } : {}
281
+ });
282
+ if ((method === "post" || method === "posted") && typeof input.id === "string") input = {
283
+ ...input,
284
+ id: await ownKey(await derivedId("board-gateway", identity.principal.id, input.id)).catch(rethrow)
285
+ };
286
+ const response = await call(client, method, input, signal);
287
+ return method === "attachment" ? attachmentOf(String(input.id), response) : response.json();
288
+ }
289
+ /** A protocol method, called on the board it uses, as `caller`. */
290
+ async function remoteCall(method, caller, input, signal) {
291
+ const registering = start();
292
+ if ("identity" in caller) return asProject(method, caller.identity, input, signal);
293
+ const client = clientOf(caller);
294
+ if ("self" in caller && caller.origin.via?.kind === "engine") await registering;
295
+ if ((method === "post" || method === "posted") && "self" in caller && typeof input.id === "string" && DERIVED_ID.test(input.id)) input = {
296
+ ...input,
297
+ id: await ownKey(input.id).catch(rethrow)
298
+ };
299
+ const response = await call(client, method, input, signal);
300
+ return method === "attachment" ? attachmentOf(String(input.id), response) : response.json();
301
+ }
302
+ const methods = {
303
+ ...Object.fromEntries(METHODS.map((method) => [method, (caller, input, signal) => remoteCall(method, caller, input, signal)])),
304
+ async discovery() {
305
+ return discovery();
306
+ },
307
+ async readSource(caller, input, signal) {
308
+ if ("self" in caller && bound().sync.some((entry) => entry.key === input.key)) return bound().readSource(input.key, input.target, input.limit ?? 50);
309
+ return remoteCall("readSource", caller, input, signal);
310
+ },
311
+ async typing(caller, input, signal) {
312
+ const { targets } = await remoteCall("typing", caller, input, signal);
313
+ if (!("self" in caller) || bound().sync.length === 0) return { targets };
314
+ const { message, replies } = await methods.message(caller, { id: input.id }, signal);
315
+ const here = await bound().typing([message, ...replies], input.status).catch((error) => {
316
+ console.warn("[board] skipped typing where this board syncs:", describe(error));
317
+ return [];
318
+ });
319
+ return { targets: [...targets, ...here] };
320
+ }
321
+ };
322
+ /** Only an https URL can be woken; anything else pulls on its own. */
323
+ const wakeable = (here) => here?.startsWith("https://") ? here : null;
324
+ let last = null;
325
+ let registering = null;
326
+ /** Registers this board's watchers with the remote, as itself, replacing its last registration. */
327
+ async function register(here) {
328
+ const current = await watchersHash();
329
+ if (last && last.url === here && last.result.hash === current && Date.now() - last.at < REGISTER_EVERY_MS) return last.result;
330
+ registering ??= own.register({
331
+ url: here,
332
+ watch: specs,
333
+ ...sync.length > 0 ? { sync } : {}
334
+ }).then((result) => {
335
+ last = {
336
+ url: here,
337
+ at: Date.now(),
338
+ result
339
+ };
340
+ return result;
341
+ }).finally(() => {
342
+ registering = null;
343
+ });
344
+ return registering;
345
+ }
346
+ /** Runs one match and acks it: done, its error, or a skip for a watcher this board no longer has. */
347
+ async function runOne(match) {
348
+ let outcome = {};
349
+ if (!bound().subscriptions[match.watcher]) outcome = { skip: true };
350
+ else try {
351
+ await bound().run(match.watcher, match.event, {
352
+ trigger: `watch:${match.watcher}:${match.id}`,
353
+ attempt: match.attempt + 1
354
+ });
355
+ } catch (error) {
356
+ console.error(`[board] watcher ${match.watcher} failed:`, error);
357
+ outcome = { error: describe(error) };
358
+ }
359
+ await own.ack(match.id, {
360
+ lease: match.lease,
361
+ ...outcome
362
+ }).catch((error) => console.error(`[board] couldn't ack a match:`, describe(error)));
363
+ }
364
+ /**
365
+ * Pulls and runs what's matched until nothing's left, or this run's time is up, and what sync
366
+ * queued in its inbox. A thread's matches come one at a time.
367
+ */
368
+ async function drain() {
369
+ let ran = 0;
370
+ const until = Date.now() + DRAIN_MS;
371
+ while (Date.now() < until) {
372
+ const matches = await own.matches({ limit: 10 });
373
+ if (matches.length === 0) break;
374
+ await Promise.all(matches.map(runOne));
375
+ ran += matches.length;
376
+ }
377
+ return ran + await bound().pull();
378
+ }
379
+ let polling = null;
380
+ /** For a board that can't be woken: pulls on its own, each pull held open until something matches. */
381
+ function poll() {
382
+ if (polling) return;
383
+ const controller = polling = new AbortController();
384
+ (async () => {
385
+ for (let pause = 1e3; !controller.signal.aborted;) try {
386
+ await register(null);
387
+ const matches = await own.matches({
388
+ limit: 10,
389
+ wait: POLL_WAIT_S,
390
+ signal: controller.signal
391
+ });
392
+ await Promise.all(matches.map(runOne));
393
+ await bound().pull();
394
+ pause = 1e3;
395
+ } catch (error) {
396
+ if (controller.signal.aborted) break;
397
+ console.error(`[board] couldn't pull matches from ${remote}:`, describe(error));
398
+ await new Promise((resolve) => setTimeout(resolve, pause));
399
+ pause = Math.min(pause * 2, 6e4);
400
+ }
401
+ })();
402
+ }
403
+ let started = null;
404
+ /**
405
+ * On first use, a board with watchers or sync registers them if it knows its URL. One on Vercel
406
+ * learns its mount path from a request (a `POST /watchers`, a webhook, or a wake-up); one that
407
+ * can't be woken registers without one and pulls instead. Resolves once the first try is done.
408
+ */
409
+ function start() {
410
+ if (started) return started;
411
+ if (Object.keys(specs).length === 0 && sync.length === 0) return Promise.resolve();
412
+ const here = wakeable(bound().url());
413
+ if (!here && typeof process !== "undefined" && process.env.VERCEL === "1") return Promise.resolve();
414
+ started = register(here).then(() => {}, (error) => console.error(`[board] couldn't register with ${remote}:`, describe(error)));
415
+ if (!here) poll();
416
+ return started;
417
+ }
418
+ const routesHere = [{
419
+ method: "POST",
420
+ path: "/wake",
421
+ async handle(request) {
422
+ started ??= Promise.resolve();
423
+ if (request.headers.get("x-board-watchers") !== await watchersHash()) await register(wakeable(bound().url())).catch(rethrow);
424
+ return json({ ran: await drain().catch(rethrow) });
425
+ }
426
+ }, {
427
+ method: "POST",
428
+ path: "/watchers",
429
+ async handle(request, { caller }) {
430
+ if (caller) return json(await methods.register(caller, RegisterBody.parse(await request.json()), request.signal));
431
+ started ??= Promise.resolve();
432
+ const here = wakeable(bound().url());
433
+ const registration = await register(here).catch(rethrow);
434
+ if (!here) poll();
435
+ return json(registration);
436
+ }
437
+ }];
438
+ return {
439
+ ...methods,
440
+ routes: routesHere,
441
+ keeps: false,
442
+ bind(next) {
443
+ if (binding) throw new Error("http: this backend already serves a board: create one http() per board()");
444
+ for (const [id, spec] of Object.entries(next.subscriptions)) {
445
+ if (spec.kind === "sync") {
446
+ if (id === "@sync") sync = next.sync.map((entry) => ({ ...entry }));
447
+ continue;
448
+ }
449
+ if (!PROFILE.test(id)) throw new Error(`watch ${id}: ${PROFILE_RULE}`);
450
+ compileWatcher(id, spec);
451
+ specs[id] = {
452
+ tail: spec.tail,
453
+ when: spec.when
454
+ };
455
+ }
456
+ hash = null;
457
+ binding = next;
458
+ },
459
+ /** Registers this board's watchers with the board it uses now, as `POST /watchers` does. */
460
+ registerWatchers: () => register(wakeable(bound().url())),
461
+ /** Stops pulling, for a board that pulls on its own. */
462
+ close: () => {
463
+ polling?.abort();
464
+ polling = null;
465
+ }
466
+ };
467
+ }
468
+ //#endregion
469
+ export { createHttp as t };