@vercel/board 0.0.0-stage → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -2
- package/dist/a2-5EG8FkZ8.d.mts +3359 -0
- package/dist/a2-DYGYankn.mjs +3045 -0
- package/dist/access-CJU2-vaa.mjs +226 -0
- package/dist/access-CV96SYgr.d.mts +32 -0
- package/dist/access.d.mts +29 -0
- package/dist/access.mjs +4 -0
- package/dist/actors-rdN4dDTU.mjs +223 -0
- package/dist/attempts-BQUp9MKO.mjs +919 -0
- package/dist/auth-COzc09sn.mjs +276 -0
- package/dist/auth-wHFcAte3.d.mts +203 -0
- package/dist/backend-a2/blob-vercel.d.mts +8 -0
- package/dist/backend-a2/blob-vercel.mjs +81 -0
- package/dist/backend-a2/contract.d.mts +18 -0
- package/dist/backend-a2/contract.mjs +5 -0
- package/dist/backend-a2/scheduler-local.d.mts +11 -0
- package/dist/backend-a2/scheduler-local.mjs +2 -0
- package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
- package/dist/backend-a2/scheduler-qstash.mjs +2 -0
- package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
- package/dist/backend-a2/scheduler-vercel.mjs +2 -0
- package/dist/backend-a2/store-memory.d.mts +11 -0
- package/dist/backend-a2/store-memory.mjs +19 -0
- package/dist/backend-a2/store-postgres.d.mts +38 -0
- package/dist/backend-a2/store-postgres.mjs +110 -0
- package/dist/backend-a2/store-redis-http.d.mts +17 -0
- package/dist/backend-a2/store-redis-http.mjs +32 -0
- package/dist/backend-a2/store-redis.d.mts +23 -0
- package/dist/backend-a2/store-redis.mjs +39 -0
- package/dist/backend-a2/store-sqlite.d.mts +12 -0
- package/dist/backend-a2/store-sqlite.mjs +31 -0
- package/dist/backend-a2.d.mts +4 -0
- package/dist/backend-a2.mjs +2 -0
- package/dist/backend-http-8ex5g7Wv.d.mts +630 -0
- package/dist/backend-http-BsDSgmeo.mjs +613 -0
- package/dist/backend-http-Cv6xbEy9.mjs +469 -0
- package/dist/backend-http.d.mts +10 -0
- package/dist/backend-http.mjs +2 -0
- package/dist/backend-http.node.d.mts +10 -0
- package/dist/backend-http.node.mjs +2 -0
- package/dist/blob-C0J3wbGv.d.mts +39 -0
- package/dist/bots-CGkO76qH.mjs +67 -0
- package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
- package/dist/chat-provider-jFSeyNmB.mjs +389 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2252 -0
- package/dist/client-DGDPaN7U.mjs +380 -0
- package/dist/conformance-DRR23YNy.mjs +512 -0
- package/dist/conformance.d.mts +30 -0
- package/dist/conformance.mjs +2 -0
- package/dist/contract-CuJmlrhw.mjs +566 -0
- package/dist/emoji-CTLpvH-A.mjs +445 -0
- package/dist/engine-3EvQ8idk.mjs +1449 -0
- package/dist/errors-DX3hpwWt.mjs +64 -0
- package/dist/exports-BpgyEdh6.d.mts +4 -0
- package/dist/exports-CyY3_FjZ.mjs +1285 -0
- package/dist/files-SniSmeEr.mjs +47 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.default-BZ3lItjH.mjs +12 -0
- package/dist/index.mjs +19 -0
- package/dist/index.node.d.mts +14 -0
- package/dist/index.node.mjs +5 -0
- package/dist/inspect/app.js +416 -0
- package/dist/inspect/engine.js +5 -0
- package/dist/local-Cr_dSjvs.mjs +44 -0
- package/dist/own-OwgPWdmh.mjs +15 -0
- package/dist/project-CFiArraX.mjs +124 -0
- package/dist/provider-BGjz-mDa.d.mts +2473 -0
- package/dist/provider-CuewcDNZ.mjs +205 -0
- package/dist/redis-tables-B35vv4CZ.mjs +842 -0
- package/dist/reducers-B_vUGQRp.d.mts +1172 -0
- package/dist/reducers-mUPFuFJV.mjs +293 -0
- package/dist/schemas-Be08nyfW.mjs +74 -0
- package/dist/schemas-CWxs3ECJ.d.mts +50 -0
- package/dist/serve-Ccbb2LSb.mjs +170 -0
- package/dist/server-Cxey38B-.mjs +17 -0
- package/dist/serving-BNbzc96L.mjs +2 -0
- package/dist/sql-DbiEnqM0.mjs +571 -0
- package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
- package/dist/sync-github.d.mts +473 -0
- package/dist/sync-github.mjs +603 -0
- package/dist/sync-kapso.d.mts +175 -0
- package/dist/sync-kapso.mjs +86 -0
- package/dist/sync-slack.d.mts +364 -0
- package/dist/sync-slack.mjs +728 -0
- package/dist/sync.chat.d.mts +2 -0
- package/dist/sync.chat.mjs +2 -0
- package/dist/sync.d.mts +3 -0
- package/dist/sync.mjs +4 -0
- package/dist/targets-ojFEXEyN.mjs +94 -0
- package/dist/tokens-DKLn5P9B.mjs +256 -0
- package/dist/types-BHCjTZdB.d.mts +104 -0
- package/dist/users-Dls7_ANV.mjs +42 -0
- package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
- package/dist/watchers-hash-D9hbHLKB.mjs +225 -0
- package/docs/01-board/01-mounting.md +506 -0
- package/docs/01-board/02-database.md +258 -0
- package/docs/01-board/03-identity.md +262 -0
- package/docs/01-board/04-messages.md +192 -0
- package/docs/01-board/05-reading.md +177 -0
- package/docs/01-board/06-watchers.md +249 -0
- package/docs/01-board/07-schedules.md +133 -0
- package/docs/01-board/08-middleware.md +139 -0
- package/docs/01-board/09-access.md +499 -0
- package/docs/01-quickstart.md +196 -0
- package/docs/02-sync/01-overview.md +290 -0
- package/docs/02-sync/02-callbacks.md +265 -0
- package/docs/02-sync/03-slack.md +248 -0
- package/docs/02-sync/04-github.md +201 -0
- package/docs/02-sync/05-whatsapp.md +148 -0
- package/docs/02-sync/06-providers.md +228 -0
- package/docs/03-guides/01-coding-agents.md +226 -0
- package/docs/03-guides/02-inspector.md +104 -0
- package/docs/03-guides/03-ci.md +251 -0
- package/docs/03-guides/04-triage.md +233 -0
- package/docs/03-guides/05-agent-factory.md +142 -0
- package/docs/04-reference/01-cli.md +606 -0
- package/docs/04-reference/02-board.md +579 -0
- package/docs/04-reference/03-http.md +489 -0
- package/docs/04-reference/04-log.md +250 -0
- package/docs/04-reference/05-conformance.md +97 -0
- package/docs/index.md +186 -0
- package/package.json +165 -3
- package/sql/postgres/board.sql +145 -0
- package/sql/postgres/notifications.sql +24 -0
- 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-D9hbHLKB.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 };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { n as HttpOptions, t as HttpBackend } from "./backend-http-8ex5g7Wv.mjs";
|
|
2
|
+
//#region src/backend-http/index.default.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The board at `url`, which keeps the log. Outside Node there's no environment to find it or a
|
|
5
|
+
* token in: `fetch` (the global one by default) sends the page's own session, which the board's
|
|
6
|
+
* `authenticate` recognizes.
|
|
7
|
+
*/
|
|
8
|
+
export declare function http(url?: string, options?: HttpOptions): HttpBackend;
|
|
9
|
+
//#endregion
|
|
10
|
+
export type { HttpBackend, HttpOptions };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { n as HttpOptions, t as HttpBackend } from "./backend-http-8ex5g7Wv.mjs";
|
|
2
|
+
//#region src/backend-http/index.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The board at `url`, which keeps the log: this board uses it as this deployment. Without a
|
|
5
|
+
* `url`, the one the environment points at: `BOARD_URL`, the repo's link, or `vb use`'s.
|
|
6
|
+
* Without a `fetch`, it sends the credentials `vb` would.
|
|
7
|
+
*/
|
|
8
|
+
export declare function http(url?: string, options?: HttpOptions): HttpBackend;
|
|
9
|
+
//#endregion
|
|
10
|
+
export type { HttpBackend, HttpOptions };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import "./schemas-CWxs3ECJ.mjs";
|
|
2
|
+
//#region src/server/blob.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Where attachments' bytes live. The board writes each file once, under a key
|
|
5
|
+
* it chose, and never overwrites it; `vercelBlob()` from `@vercel/board/backend-a2/blob-vercel` is the
|
|
6
|
+
* production one.
|
|
7
|
+
*/
|
|
8
|
+
type BlobStore = {
|
|
9
|
+
put(key: string, data: Uint8Array, contentType: string): Promise<void>;
|
|
10
|
+
/** Null when there's nothing under the key. */
|
|
11
|
+
get(key: string): Promise<{
|
|
12
|
+
data: ReadableStream<Uint8Array> | Uint8Array;
|
|
13
|
+
contentType: string;
|
|
14
|
+
} | null>;
|
|
15
|
+
/**
|
|
16
|
+
* For direct uploads, files too big for a request: a URL a client `PUT`s `key`'s bytes to,
|
|
17
|
+
* straight to the store, with `headers`. The store must bind it to `key`, refuse more than
|
|
18
|
+
* `maxBytes`, refuse to overwrite what's there, and stop taking it at `expiresAt`. With `head`,
|
|
19
|
+
* the board takes files up to `DIRECT_UPLOAD_BYTES_MAX`; without, uploads stay at 4 MB.
|
|
20
|
+
*/
|
|
21
|
+
uploadUrl?(key: string, options: {
|
|
22
|
+
contentType: string;
|
|
23
|
+
maxBytes: number;
|
|
24
|
+
expiresAt: Date;
|
|
25
|
+
}): Promise<{
|
|
26
|
+
url: string;
|
|
27
|
+
headers: Record<string, string>;
|
|
28
|
+
}>;
|
|
29
|
+
/** The size of what's under the key, or null when there's nothing: how the board checks a direct upload landed. */
|
|
30
|
+
head?(key: string): Promise<{
|
|
31
|
+
size: number;
|
|
32
|
+
} | null>;
|
|
33
|
+
};
|
|
34
|
+
/** Files in memory, for tests: forgets everything when the process ends. */
|
|
35
|
+
declare function memoryBlob(): BlobStore;
|
|
36
|
+
/** Files on disk, for development and `vb serve`: under `dir`, `.a2/attachments` by default. */
|
|
37
|
+
declare function fsBlob(dir?: string): BlobStore;
|
|
38
|
+
//#endregion
|
|
39
|
+
export { fsBlob as n, memoryBlob as r, BlobStore as t };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const VERCEL_MARKER = /^\[vc\]: #\S*\r?\n/;
|
|
2
|
+
const LINK = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
|
|
3
|
+
const STATUS = /!\[([^\]]+)\]\(https:\/\/vercel\.com\/static\/status\/[^)]+\)\s*(?:\[[^\]]*\]\((https?:\/\/[^)\s]+)\))?/;
|
|
4
|
+
/** The deployments in a Vercel for GitHub comment, or null when it isn't one. */
|
|
5
|
+
function vercelDeployments(text) {
|
|
6
|
+
if (!VERCEL_MARKER.test(text)) return null;
|
|
7
|
+
const skippedAt = text.search(/<details><summary>\d+ Skipped Deployments?<\/summary>/);
|
|
8
|
+
const deployments = [];
|
|
9
|
+
let offset = 0;
|
|
10
|
+
for (const line of text.split(/\r?\n/)) {
|
|
11
|
+
const at = offset;
|
|
12
|
+
offset = text.indexOf("\n", offset) + 1 || text.length;
|
|
13
|
+
if (!line.startsWith("|") || /^\|\s*(Project|Name)\s*\|/.test(line) || /^\|\s*:?-/.test(line)) continue;
|
|
14
|
+
const cells = line.split("|").slice(1, -1).map((cell) => cell.trim());
|
|
15
|
+
if (cells.length < 2) continue;
|
|
16
|
+
const status = STATUS.exec(cells[1]);
|
|
17
|
+
if (!status) continue;
|
|
18
|
+
const projectLink = [...cells[0].matchAll(LINK)].at(-1);
|
|
19
|
+
const project = projectLink?.[1] ?? cells[0].replace(/<[^>]*>/g, "").trim();
|
|
20
|
+
if (!project) continue;
|
|
21
|
+
const preview = cells[2] ? [...cells[2].matchAll(LINK)].find((link) => /preview/i.test(link[1])) : void 0;
|
|
22
|
+
const updated = cells[3]?.match(/<relative-time[^>]*>([^<]+)<\/relative-time>/)?.[1]?.trim() ?? (cells[3]?.replace(/<[^>]*>/g, "").trim() || null);
|
|
23
|
+
deployments.push({
|
|
24
|
+
project,
|
|
25
|
+
projectUrl: projectLink?.[2] ?? null,
|
|
26
|
+
status: status[1].trim(),
|
|
27
|
+
deploymentUrl: status[2] ?? null,
|
|
28
|
+
previewUrl: preview?.[2] ?? null,
|
|
29
|
+
updated,
|
|
30
|
+
skipped: skippedAt !== -1 && at > skippedAt
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return deployments.length > 0 ? deployments : null;
|
|
34
|
+
}
|
|
35
|
+
const ICONS = {
|
|
36
|
+
ready: "✅",
|
|
37
|
+
building: "⏳",
|
|
38
|
+
queued: "⏳",
|
|
39
|
+
initializing: "⏳",
|
|
40
|
+
error: "❌",
|
|
41
|
+
failed: "❌",
|
|
42
|
+
canceled: "⚪",
|
|
43
|
+
ignored: "⚪"
|
|
44
|
+
};
|
|
45
|
+
const link = (label, url) => url ? `[${label}](${url})` : label;
|
|
46
|
+
/** One line per deployment: `✅ **board** · [Ready](…) · [Preview](…) · Oct 6, 2026 8:30pm UTC`. */
|
|
47
|
+
function formatVercelDeployments(deployments) {
|
|
48
|
+
return deployments.map((d) => {
|
|
49
|
+
return `- ${[
|
|
50
|
+
`${ICONS[d.status.toLowerCase()] ?? "▲"} **${link(d.project, d.projectUrl)}**`,
|
|
51
|
+
`${link(d.status, d.deploymentUrl)}${d.skipped ? " (skipped)" : ""}`,
|
|
52
|
+
...d.previewUrl ? [link("Preview", d.previewUrl)] : [],
|
|
53
|
+
...d.updated ? [d.updated] : []
|
|
54
|
+
].join(" · ")}`;
|
|
55
|
+
}).join("\n");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A message's text as people should see it: known bot messages in their
|
|
59
|
+
* readable form, anything else as is. Only the bot itself gets that form, so
|
|
60
|
+
* no one can hide a message's text behind a look-alike table.
|
|
61
|
+
*/
|
|
62
|
+
function displayText(text, actor) {
|
|
63
|
+
const deployments = actor === "github:user:35613825" ? vercelDeployments(text) : null;
|
|
64
|
+
return deployments ? `**▲ Vercel**\n${formatVercelDeployments(deployments)}` : text;
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { displayText as t };
|