@vercel/board 0.16.0 → 0.17.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 +27 -27
- package/dist/{a2-C41hD8SF.mjs → a2-Bck5x1Kh.mjs} +969 -25
- package/dist/access.d.mts +1 -1
- package/dist/access.mjs +3 -3
- package/dist/{actors-DjMeZpnU.mjs → actors-BOamkEuz.mjs} +3 -73
- package/dist/attempts-CLG840Oj.mjs +20 -0
- package/dist/{auth-COzc09sn.mjs → auth-Drg9FiC2.mjs} +225 -2
- package/dist/backend-B-SJ7p6p.d.mts +680 -0
- package/dist/backend-a2/contract.d.mts +873 -2
- package/dist/backend-a2/contract.mjs +2 -2
- package/dist/backend-a2/scheduler-local.d.mts +1 -1
- package/dist/backend-a2/scheduler-local.mjs +1 -1
- package/dist/backend-a2/scheduler-qstash.d.mts +78 -1
- package/dist/backend-a2/scheduler-qstash.mjs +521 -1
- package/dist/backend-a2/scheduler-vercel.d.mts +37 -1
- package/dist/backend-a2/scheduler-vercel.mjs +203 -1
- package/dist/backend-a2/store-memory.d.mts +2 -2
- package/dist/backend-a2/store-memory.mjs +3 -3
- package/dist/backend-a2/store-postgres.d.mts +1 -1
- package/dist/backend-a2/store-postgres.mjs +2850 -2
- package/dist/backend-a2/store-redis-http.d.mts +1 -1
- package/dist/backend-a2/store-redis-http.mjs +490 -2
- package/dist/backend-a2/store-redis.d.mts +10 -2
- package/dist/backend-a2/store-redis.mjs +51 -2
- package/dist/backend-a2/store-sqlite.d.mts +1 -1
- package/dist/backend-a2/store-sqlite.mjs +3 -3
- package/dist/backend-a2.d.mts +2706 -3
- package/dist/backend-a2.mjs +1 -1
- package/dist/{backend-http-Bid7RIpC.d.mts → backend-http-7cNb8nJI.d.mts} +10 -3
- package/dist/{backend-http-0roEmjl0.mjs → backend-http-C5I7Wfdw.mjs} +2 -2
- package/dist/{backend-http-BVRno-ca.mjs → backend-http-CuUIACCs.mjs} +32 -3
- package/dist/backend-http.d.mts +1 -1
- package/dist/backend-http.mjs +1 -1
- package/dist/backend-http.node.d.mts +1 -1
- package/dist/backend-http.node.mjs +1 -1
- package/dist/{chat-provider-XCnQlxq6.d.mts → chat-provider-DNJQyajS.d.mts} +16 -4
- package/dist/{chat-provider-jFSeyNmB.mjs → chat-provider-YHy-WbrB.mjs} +15 -9
- package/dist/cli.mjs +30 -10
- package/dist/{client-DGDPaN7U.mjs → client-BA-J7usH.mjs} +10 -2
- package/dist/{conformance-DRR23YNy.mjs → conformance-CT5EeHeC.mjs} +5 -2
- package/dist/conformance.d.mts +1 -1
- package/dist/conformance.mjs +1 -1
- package/dist/{engine-Bir5s8ts.mjs → engine-D0oBUBEK.mjs} +249 -163
- package/dist/events-CewD4kw7.mjs +75 -0
- package/dist/{exports-Dvc1o52I.mjs → exports-DOdA7KhF.mjs} +35 -32
- package/dist/exports-ZsgjzEnM.d.mts +4 -0
- package/dist/external-BV_cUncs.mjs +151 -0
- package/dist/folds-CRYY14kc.d.mts +534 -0
- package/dist/{reducers-mUPFuFJV.mjs → folds-Dyjput8M.mjs} +6 -73
- package/dist/ids-C6AYo1bu.mjs +31 -0
- package/dist/index.d.mts +4 -4
- package/dist/{index.default-Bkg77Pxn.mjs → index.default-n4qKN7EL.mjs} +1 -1
- package/dist/index.mjs +5 -5
- package/dist/index.node.d.mts +4 -4
- package/dist/index.node.mjs +3 -3
- package/dist/inspect/app.js +31 -31
- package/dist/inspect/engine.js +5 -5
- package/dist/internal-CvsrprnU-CRY89I-b.mjs +203 -0
- package/dist/{local-Cr_dSjvs.mjs → local-DhkrAqPP.mjs} +1 -1
- package/dist/mentions-CB6ShY4R.mjs +72 -0
- package/dist/{targets-yM5JBUgr.mjs → middleware-CqaCwucP.mjs} +1 -33
- package/dist/platform-B4TnJtWu-CwcTyKg2.mjs +32 -0
- package/dist/{project-CFiArraX.mjs → project-DMDST9kl.mjs} +1 -1
- package/dist/{provider-BGjz-mDa.d.mts → provider-B0b9Qxw4.d.mts} +170 -40
- package/dist/provider-B3DpYSDq.mjs +220 -0
- package/dist/redis-tables-DN9PfIDi.mjs +3897 -0
- package/dist/reducer-CYPHsSmd-BQIfozGi.d.mts +170 -0
- package/dist/reducer-CjmvZSAb-D_OSISmj.mjs +74 -0
- package/dist/{contract-CuJmlrhw.mjs → reducers-BFmIXilu.mjs} +260 -287
- package/dist/retryable-lazy-DZWmHpii-Bye-W8q7.mjs +19 -0
- package/dist/scheduler-task-X1SToDjP-BVoWuInI.mjs +69 -0
- package/dist/{serve-BsOOc-6_.mjs → serve-CNYvA4pT.mjs} +5 -5
- package/dist/server-DtFrWxi3-Boa6gdWO.mjs +3602 -0
- package/dist/server-qgez0Xny-D5mLfqFQ.d.mts +385 -0
- package/dist/{server-jwPY7_wi.mjs → server-snYFkITb.mjs} +4 -4
- package/dist/{serving-BNbzc96L.mjs → serving-CNkgUPW1.mjs} +1 -1
- package/dist/session-clear-wHd5GCpZ-BnKSxReK.mjs +51 -0
- package/dist/{sql-DbiEnqM0.mjs → sql-U9WTxrwJ.mjs} +2 -2
- package/dist/{sqlite-db-Cbj03vmp.mjs → sqlite-db-Ds4majtT.mjs} +1 -1
- package/dist/store-D0MhBvg6-4Z2L0Kqd.d.mts +530 -0
- package/dist/store-N8PXxDAS-BWBgzBHp.mjs +5 -0
- package/dist/store-codec-DwrWlgQx-CYxGW0aD.mjs +61 -0
- package/dist/store-memory-Bas9Uq5C.mjs +872 -0
- package/dist/store-memory-ZLiZbH3R.mjs +2 -0
- package/dist/store-polling-BZt2Rf3D-BiEM6_HB.mjs +148 -0
- package/dist/store-presence-polling-JuAy2Ck2-CYpuK75X.mjs +109 -0
- package/dist/store-sqlite-BmwL3pUR.mjs +2 -0
- package/dist/store-sqlite-CxxPCcCT.mjs +1053 -0
- package/dist/stream-buffer-DrTyiIuo-BKy-vI14.mjs +53 -0
- package/dist/sync-github.d.mts +24 -17
- package/dist/sync-github.mjs +89 -22
- package/dist/sync-kapso.d.mts +15 -13
- package/dist/sync-kapso.mjs +17 -14
- package/dist/sync-slack.d.mts +12 -17
- package/dist/sync-slack.mjs +54 -42
- package/dist/sync.chat.d.mts +1 -1
- package/dist/sync.chat.mjs +1 -1
- package/dist/sync.d.mts +3 -3
- package/dist/sync.mjs +5 -4
- package/dist/targets-C-wdR5wP.mjs +33 -0
- package/dist/{tokens-DKLn5P9B.mjs → tokens-DhuzKzAl.mjs} +1 -1
- package/dist/{types-BHCjTZdB.d.mts → types-8yq2VnKI.d.mts} +2 -2
- package/dist/{users-Dls7_ANV.mjs → users-yryHKFMo.mjs} +2 -2
- package/dist/{watch-spec-W0c7ypHJ.mjs → watchers-hash-DojIuoFg.mjs} +233 -28
- package/docs/01-board/01-mounting.md +1 -1
- package/docs/02-sync/01-overview.md +69 -47
- package/docs/02-sync/02-functions.md +395 -0
- package/docs/02-sync/03-slack.md +30 -25
- package/docs/02-sync/04-github.md +62 -26
- package/docs/02-sync/05-whatsapp.md +23 -19
- package/docs/02-sync/06-providers.md +58 -26
- package/docs/03-guides/04-triage.md +1 -1
- package/docs/04-reference/01-cli.md +26 -6
- package/docs/04-reference/02-board.md +18 -5
- package/docs/04-reference/03-http.md +5 -2
- package/docs/04-reference/05-conformance.md +1 -1
- package/docs/index.md +5 -10
- package/package.json +22 -6
- package/dist/a2-D24cvwpN.d.mts +0 -3359
- package/dist/access-CJU2-vaa.mjs +0 -226
- package/dist/attempts-Bxp9KgPf.mjs +0 -919
- package/dist/exports-DLiE7fLY.d.mts +0 -4
- package/dist/provider-CuewcDNZ.mjs +0 -205
- package/dist/redis-tables-B35vv4CZ.mjs +0 -842
- package/dist/reducers-B_vUGQRp.d.mts +0 -1172
- package/dist/watchers-hash-CLjsa6od.mjs +0 -225
- package/docs/02-sync/02-callbacks.md +0 -265
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import { t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
|
|
2
|
-
import "./schemas-Be08nyfW.mjs";
|
|
3
|
-
import { V as routes } from "./watch-spec-W0c7ypHJ.mjs";
|
|
4
|
-
import { r as badRequest, t as HttpError } from "./errors-DX3hpwWt.mjs";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
//#region src/server/backend.ts
|
|
7
|
-
/** The subscription sync's outbound runs on, in `BoardBinding.subscriptions`. No watcher's ID starts with `@`. */
|
|
8
|
-
const SYNC = "@sync";
|
|
9
|
-
//#endregion
|
|
10
|
-
//#region src/version.ts
|
|
11
|
-
const VERSION = "0.16.0";
|
|
12
|
-
//#endregion
|
|
13
|
-
//#region src/server/routing.ts
|
|
14
|
-
/** board()'s own routes: the files it serves, and sync's, which run where board() does. */
|
|
15
|
-
const BOARD_ROUTES = [
|
|
16
|
-
"avatar",
|
|
17
|
-
"openapi",
|
|
18
|
-
"webhook",
|
|
19
|
-
"signIn",
|
|
20
|
-
"signedIn",
|
|
21
|
-
"identityUnlink",
|
|
22
|
-
"sync"
|
|
23
|
-
];
|
|
24
|
-
/** Every protocol method, by the route it's served at. */
|
|
25
|
-
const METHODS = [
|
|
26
|
-
"discovery",
|
|
27
|
-
"me",
|
|
28
|
-
"channels",
|
|
29
|
-
"actors",
|
|
30
|
-
"identity",
|
|
31
|
-
"messages",
|
|
32
|
-
"threads",
|
|
33
|
-
"message",
|
|
34
|
-
"post",
|
|
35
|
-
"posted",
|
|
36
|
-
"edit",
|
|
37
|
-
"delete",
|
|
38
|
-
"react",
|
|
39
|
-
"label",
|
|
40
|
-
"schedule",
|
|
41
|
-
"schedules",
|
|
42
|
-
"cancelSchedule",
|
|
43
|
-
"upload",
|
|
44
|
-
"attachment",
|
|
45
|
-
"uploadStart",
|
|
46
|
-
"uploadFinish",
|
|
47
|
-
"watchers",
|
|
48
|
-
"register",
|
|
49
|
-
"matches",
|
|
50
|
-
"ack",
|
|
51
|
-
"events",
|
|
52
|
-
"syncImport",
|
|
53
|
-
"syncReceipt",
|
|
54
|
-
"syncThread",
|
|
55
|
-
"syncFind",
|
|
56
|
-
"syncCopies",
|
|
57
|
-
"syncRoots",
|
|
58
|
-
"inboxPush",
|
|
59
|
-
"inboxNext",
|
|
60
|
-
"inboxAck",
|
|
61
|
-
"inbox",
|
|
62
|
-
"syncCode",
|
|
63
|
-
"syncAccount",
|
|
64
|
-
"syncUnlink",
|
|
65
|
-
"typing",
|
|
66
|
-
"readSource"
|
|
67
|
-
];
|
|
68
|
-
const escape = (literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
-
function compile(method, path, target, own) {
|
|
70
|
-
const parts = path.split("/").filter(Boolean);
|
|
71
|
-
const source = parts.map((part) => part.startsWith(":") ? "/([^/]+)" : `/${escape(part)}`).join("");
|
|
72
|
-
return {
|
|
73
|
-
method,
|
|
74
|
-
pattern: new RegExp(`${source}$`),
|
|
75
|
-
names: parts.filter((part) => part.startsWith(":")).map((part) => part.slice(1)),
|
|
76
|
-
target,
|
|
77
|
-
segments: parts.length,
|
|
78
|
-
literals: parts.filter((part) => !part.startsWith(":")).length,
|
|
79
|
-
own
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* The routes a board answers, most specific first: each is matched as a suffix of the request's
|
|
84
|
-
* path, so `/api/board/messages/12` is `GET /messages/:id` under `/api/board`. A backend's own
|
|
85
|
-
* route at a protocol path takes its place. Discovery is the mount itself.
|
|
86
|
-
*/
|
|
87
|
-
function createRouter(backendRoutes, basePath) {
|
|
88
|
-
const patterns = [
|
|
89
|
-
...BOARD_ROUTES.map((name) => compile(routes[name].method, routes[name].path, {
|
|
90
|
-
kind: "board",
|
|
91
|
-
name
|
|
92
|
-
}, false)),
|
|
93
|
-
...METHODS.filter((name) => name !== "discovery").map((name) => compile(routes[name].method, routes[name].path, {
|
|
94
|
-
kind: "protocol",
|
|
95
|
-
name
|
|
96
|
-
}, false)),
|
|
97
|
-
...backendRoutes.map((route) => compile(route.method, route.path, {
|
|
98
|
-
kind: "backend",
|
|
99
|
-
route
|
|
100
|
-
}, true))
|
|
101
|
-
].sort((a, b) => b.segments - a.segments || b.literals - a.literals || Number(b.own) - Number(a.own));
|
|
102
|
-
/** A segment that isn't URI-encoded names nothing: no route. */
|
|
103
|
-
const found = (candidate, base, values) => {
|
|
104
|
-
const params = {};
|
|
105
|
-
try {
|
|
106
|
-
candidate.names.forEach((name, i) => params[name] = decodeURIComponent(values[i]));
|
|
107
|
-
} catch {
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
const first = candidate.names[0];
|
|
111
|
-
return {
|
|
112
|
-
target: candidate.target,
|
|
113
|
-
base,
|
|
114
|
-
param: first === void 0 ? null : params[first],
|
|
115
|
-
params
|
|
116
|
-
};
|
|
117
|
-
};
|
|
118
|
-
const discovery = (base) => ({
|
|
119
|
-
target: {
|
|
120
|
-
kind: "protocol",
|
|
121
|
-
name: "discovery"
|
|
122
|
-
},
|
|
123
|
-
base,
|
|
124
|
-
param: null,
|
|
125
|
-
params: {},
|
|
126
|
-
root: true
|
|
127
|
-
});
|
|
128
|
-
return function route(method, pathname) {
|
|
129
|
-
const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
|
|
130
|
-
if (basePath !== void 0) {
|
|
131
|
-
const base = basePath.replace(/\/+$/, "");
|
|
132
|
-
if (path !== base && !path.startsWith(`${base}/`)) return null;
|
|
133
|
-
const rest = path.slice(base.length) || "/";
|
|
134
|
-
if (rest === "/") return method === "GET" ? discovery(base) : null;
|
|
135
|
-
for (const candidate of patterns) {
|
|
136
|
-
const hit = candidate.method === method ? rest.match(new RegExp(`^${candidate.pattern.source}`)) : null;
|
|
137
|
-
if (hit) return found(candidate, base, hit.slice(1));
|
|
138
|
-
}
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
for (const candidate of patterns) {
|
|
142
|
-
if (candidate.method !== method) continue;
|
|
143
|
-
const hit = path.match(candidate.pattern);
|
|
144
|
-
if (hit) return found(candidate, path.slice(0, hit.index), hit.slice(1));
|
|
145
|
-
}
|
|
146
|
-
return method === "GET" ? discovery(path === "/" ? "" : path) : null;
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
function queryObject(url) {
|
|
150
|
-
const out = {};
|
|
151
|
-
for (const key of new Set(url.searchParams.keys())) {
|
|
152
|
-
const values = url.searchParams.getAll(key);
|
|
153
|
-
out[key] = values.length > 1 ? values : values[0];
|
|
154
|
-
}
|
|
155
|
-
return out;
|
|
156
|
-
}
|
|
157
|
-
async function readJson(request) {
|
|
158
|
-
const text = await request.text();
|
|
159
|
-
if (text.trim() === "") return void 0;
|
|
160
|
-
try {
|
|
161
|
-
return JSON.parse(text);
|
|
162
|
-
} catch {
|
|
163
|
-
throw badRequest("request body is not valid JSON");
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
const tooLarge = () => new HttpError(413, "too_large", `attachments are at most ${ATTACHMENT_BYTES_MAX / 1024 / 1024} MB`);
|
|
167
|
-
/** The body, refused as soon as it's past `max`: with or without a `content-length`, it never reads more. */
|
|
168
|
-
async function readCapped(request, max) {
|
|
169
|
-
if (Number(request.headers.get("content-length") ?? 0) > max) throw tooLarge();
|
|
170
|
-
const chunks = [];
|
|
171
|
-
let size = 0;
|
|
172
|
-
for await (const chunk of request.body ?? []) {
|
|
173
|
-
size += chunk.byteLength;
|
|
174
|
-
if (size > max) throw tooLarge();
|
|
175
|
-
chunks.push(chunk);
|
|
176
|
-
}
|
|
177
|
-
const body = new Uint8Array(size);
|
|
178
|
-
let at = 0;
|
|
179
|
-
for (const chunk of chunks) {
|
|
180
|
-
body.set(chunk, at);
|
|
181
|
-
at += chunk.byteLength;
|
|
182
|
-
}
|
|
183
|
-
return body;
|
|
184
|
-
}
|
|
185
|
-
function openapi() {
|
|
186
|
-
const paths = {};
|
|
187
|
-
for (const [name, definition] of Object.entries(routes)) {
|
|
188
|
-
const path = definition.path.replace(/:(\w+)/g, "{$1}");
|
|
189
|
-
const operation = { operationId: name };
|
|
190
|
-
if ("query" in definition) operation["x-query-schema"] = z.toJSONSchema(definition.query, { io: "input" });
|
|
191
|
-
if ("body" in definition) operation.requestBody = { content: { "application/json": { schema: z.toJSONSchema(definition.body, { io: "input" }) } } };
|
|
192
|
-
(paths[path] ??= {})[definition.method.toLowerCase()] = operation;
|
|
193
|
-
}
|
|
194
|
-
return {
|
|
195
|
-
openapi: "3.1.0",
|
|
196
|
-
info: {
|
|
197
|
-
title: "board",
|
|
198
|
-
version: VERSION
|
|
199
|
-
},
|
|
200
|
-
paths
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
//#endregion
|
|
204
|
-
//#region src/server/watchers-hash.ts
|
|
205
|
-
/** By UTF-16 code unit, as `sort()` orders strings: the same in every locale and runtime. */
|
|
206
|
-
const byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
207
|
-
/**
|
|
208
|
-
* What `watchersHash` hashes, the same on both ends: a board on `http()` hashes its watchers, and
|
|
209
|
-
* whether its sync subscribes, and the board it uses hashes what it holds.
|
|
210
|
-
*/
|
|
211
|
-
function canonicalWatchers(watch, sync = []) {
|
|
212
|
-
const canonical = Object.keys(watch).sort(byCodeUnit).map((id) => [
|
|
213
|
-
id,
|
|
214
|
-
watch[id].tail,
|
|
215
|
-
watch[id].when
|
|
216
|
-
]);
|
|
217
|
-
if (sync.length > 0) canonical.push(["sync", [...sync].sort((a, b) => byCodeUnit(a.key, b.key)).map((entry) => [
|
|
218
|
-
entry.key,
|
|
219
|
-
entry.platform,
|
|
220
|
-
entry.signIn
|
|
221
|
-
])]);
|
|
222
|
-
return JSON.stringify(canonical);
|
|
223
|
-
}
|
|
224
|
-
//#endregion
|
|
225
|
-
export { queryObject as a, VERSION as c, openapi as i, SYNC as l, METHODS as n, readCapped as o, createRouter as r, readJson as s, canonicalWatchers as t };
|
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Callbacks
|
|
3
|
-
description: Decide what each inbound platform event becomes, and how board messages look on each platform.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
## A function per mapping
|
|
7
|
-
|
|
8
|
-
A string mapping imports every event into one channel. A function receives every
|
|
9
|
-
inbound event for its external channel, connected threads included, and decides:
|
|
10
|
-
|
|
11
|
-
```ts app/api/board/[[...path]]/route.ts
|
|
12
|
-
import { board } from '@vercel/board'
|
|
13
|
-
import { a2 } from '@vercel/board/backend-a2'
|
|
14
|
-
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
15
|
-
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
16
|
-
import { github, GitHubPayload } from '@vercel/board/sync-github'
|
|
17
|
-
|
|
18
|
-
const backend = a2({
|
|
19
|
-
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
20
|
-
scheduler: vercelQueues({ topic: 'board' }),
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
const team = board({
|
|
24
|
-
backend,
|
|
25
|
-
sync: {
|
|
26
|
-
github: github({
|
|
27
|
-
adapter: 'github/acme',
|
|
28
|
-
repos: {
|
|
29
|
-
'acme/www': async ({ event, board }) => {
|
|
30
|
-
const channel = event.subject.type === 'pull_request' ? 'prs' : 'issues'
|
|
31
|
-
await board.sync(channel, event, {
|
|
32
|
-
labels: { area: 'www' },
|
|
33
|
-
root: ({ raw }) => {
|
|
34
|
-
const payload = GitHubPayload.parse(raw)
|
|
35
|
-
const item = payload.pull_request ?? payload.issue
|
|
36
|
-
return { text: [item?.title, item?.body, item?.html_url].filter(Boolean).join('\n\n') }
|
|
37
|
-
},
|
|
38
|
-
})
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
}),
|
|
42
|
-
},
|
|
43
|
-
})
|
|
44
|
-
export const GET = team.fetch, POST = team.fetch
|
|
45
|
-
export const { scheduler } = backend
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
`board.sync(channel, event)` applies the default import: the same thing a string
|
|
49
|
-
mapping does, with the same provenance, files, links, state labels, and retries.
|
|
50
|
-
Call it once, await it, and the event lands on the board. Don't call it, and the
|
|
51
|
-
event is skipped; the thread stays connected, so later events still reach you.
|
|
52
|
-
|
|
53
|
-
For an event in a thread that's already synced, the channel is ignored: the event
|
|
54
|
-
lands in that thread. The channel only places new roots.
|
|
55
|
-
|
|
56
|
-
The function's other writes through `board` (a reply, a label, a reaction, an
|
|
57
|
-
edit or delete of the user's own message) are the platform user's, with the
|
|
58
|
-
same provenance as the import, and go through the
|
|
59
|
-
[middleware](../01-board/08-middleware.md) like it. Its reads, and anything
|
|
60
|
-
else it does, are sync's own. It works the same on any backend, a board on
|
|
61
|
-
`http()` included: there the writes land on the board it uses as imports.
|
|
62
|
-
|
|
63
|
-
## The event
|
|
64
|
-
|
|
65
|
-
Every event is an envelope, validated and frozen before your function sees it.
|
|
66
|
-
Fields every event has:
|
|
67
|
-
|
|
68
|
-
| Field | Value |
|
|
69
|
-
| --- | --- |
|
|
70
|
-
| `id` | The inbox event's ID. Stable across retries: use it as an idempotency key. |
|
|
71
|
-
| `platform`, `key` | `slack`, `github`, or `whatsapp`, and the entry's key. |
|
|
72
|
-
| `channel`, `thread` | External IDs with the key prefix: `slack:C0123ABC`, `slack:C0123ABC:1727000000.123456`. `thread` is what `sync` accepts as a target. |
|
|
73
|
-
| `subthread` | The precise sub-thread, when the adapter grouped it: a GitHub review thread. Otherwise `null`. |
|
|
74
|
-
| `kind` | `message`, `edited`, `deleted`, `reaction`, or `lifecycle`. |
|
|
75
|
-
| `type` | The narrower name: `message` (Slack, WhatsApp), `comment` (GitHub), `message.edited`, `message.deleted`, `reaction.added`, `reaction.removed`, or a lifecycle type like `pr.opened`. |
|
|
76
|
-
| `raw` | The platform's payload. |
|
|
77
|
-
|
|
78
|
-
Then, by kind:
|
|
79
|
-
|
|
80
|
-
- **`message`:** `message` (the platform's ID), `text` (markdown), `author`
|
|
81
|
-
(`{ id, handle, name, avatar, isBot }`), `isRoot`, `context` (a GitHub review
|
|
82
|
-
comment's `path:line`), and `files` and `links` when it has them.
|
|
83
|
-
- **`edited`:** `message`, `text`, `author`, `links`.
|
|
84
|
-
- **`deleted`:** `message`.
|
|
85
|
-
- **`reaction`:** `message`, `emoji`, `added`, `author`.
|
|
86
|
-
- **`lifecycle`:** `type`, `title`, `url`, `labels`, `reply`, and `author`. Only
|
|
87
|
-
GitHub sends these: see the [events table](./04-github.md#events).
|
|
88
|
-
|
|
89
|
-
GitHub events also carry `subject: { type: 'pull_request' | 'issue', repo, number }`,
|
|
90
|
-
comments and reactions included. The types are exported: `SlackEvent` from
|
|
91
|
-
`@vercel/board/sync-slack`, `GitHubEvent` and `GitHubPayload` from
|
|
92
|
-
`@vercel/board/sync-github`, and `WhatsAppEvent` from `@vercel/board/sync-kapso`.
|
|
93
|
-
|
|
94
|
-
## Permalinks
|
|
95
|
-
|
|
96
|
-
Every callback also gets `permalink()`, the platform's own link to the event's
|
|
97
|
-
message. It's fetched on the first call and reused after, so callbacks that never
|
|
98
|
-
ask don't cost an API request:
|
|
99
|
-
|
|
100
|
-
```ts
|
|
101
|
-
// in slack({ channels: { … } }): copy a report elsewhere, with a link back
|
|
102
|
-
'#reports': async ({ event, board, permalink }) => {
|
|
103
|
-
if (event.kind === 'message' && event.isRoot)
|
|
104
|
-
await board.post('triage', `${event.text}\n\n${await permalink()}`)
|
|
105
|
-
},
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
On Slack it calls `chat.getPermalink` with the provider's client, so the URL has
|
|
109
|
-
the workspace's own host (Enterprise Grid included) and a reply's `thread_ts`. It
|
|
110
|
-
only reads the URL: the thread isn't synced and no channel is joined. It returns
|
|
111
|
-
`null` for a lifecycle event or a deleted message, and on platforms without
|
|
112
|
-
links, and throws when Slack refuses, so the callback is retried.
|
|
113
|
-
|
|
114
|
-
## Overrides
|
|
115
|
-
|
|
116
|
-
The third argument changes the default import:
|
|
117
|
-
|
|
118
|
-
| Override | Effect |
|
|
119
|
-
| --- | --- |
|
|
120
|
-
| `text` | Replaces the imported message's text. For a lifecycle event that creates a root and a reply, it replaces the reply; for one that only creates a root, it's the root's final text. |
|
|
121
|
-
| `labels` | Merged into the thread root's labels. |
|
|
122
|
-
| `unset` | Label keys removed from the root, applied last. |
|
|
123
|
-
| `root` | A synchronous function that shapes a new root. It receives the default draft (`text`, `labels`, `author`, `raw`) and returns `{ text?, labels? }`. |
|
|
124
|
-
| `noSync` | Keeps the imported reply on the board, like `vb reply --no-sync`: no other platform the thread syncs to gets it, now or in a backfill. A root it creates still syncs. |
|
|
125
|
-
|
|
126
|
-
`root` runs whenever an import creates a root: a PR opening, or a comment on a PR
|
|
127
|
-
the board hasn't seen, which recovers the root first. Its labels merge into the
|
|
128
|
-
draft, then `labels` takes precedence, then `unset` removes keys. It can't change
|
|
129
|
-
the author or other provenance.
|
|
130
|
-
|
|
131
|
-
`noSync` keeps a GitHub PR's inline review threads out of its Slack thread, while
|
|
132
|
-
top-level comments and reviews still go through:
|
|
133
|
-
|
|
134
|
-
```ts
|
|
135
|
-
// in github({ repos: { … } }):
|
|
136
|
-
'acme/www': {
|
|
137
|
-
channel: 'prs',
|
|
138
|
-
run: async ({ event, board }) => {
|
|
139
|
-
await board.sync('prs', event, { noSync: event.subthread !== null })
|
|
140
|
-
},
|
|
141
|
-
},
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
Platform reconciliation still applies on later events: GitHub's next event
|
|
145
|
-
rewrites `state`, `review`, `tag.*`, and `assignee.*` unless you override them
|
|
146
|
-
again.
|
|
147
|
-
|
|
148
|
-
## The result
|
|
149
|
-
|
|
150
|
-
`board.sync` returns `{ root, message }`, the board IDs it touched:
|
|
151
|
-
|
|
152
|
-
- `root` is the thread's root.
|
|
153
|
-
- `message` is the imported message, the message an edit, delete, or reaction
|
|
154
|
-
changed, or a lifecycle event's reply. A lifecycle event that only creates a root
|
|
155
|
-
returns the root; one that only changes labels returns `null`.
|
|
156
|
-
- An event that can't apply (a reaction on a message the board never had) returns
|
|
157
|
-
`null` where no board message exists.
|
|
158
|
-
|
|
159
|
-
Retried after it succeeded, it returns the same IDs.
|
|
160
|
-
|
|
161
|
-
## More than an import
|
|
162
|
-
|
|
163
|
-
`board` is a client of the board in this process, through `board()`, with the
|
|
164
|
-
[handle's](../04-reference/02-board.md) methods, so a function can also reply,
|
|
165
|
-
react, label, or attach:
|
|
166
|
-
|
|
167
|
-
```ts
|
|
168
|
-
// in slack({ channels: { … } }):
|
|
169
|
-
'#support': async ({ event, board }) => {
|
|
170
|
-
const { root } = await board.sync('support', event)
|
|
171
|
-
if (event.kind === 'message' && event.isRoot && root !== null)
|
|
172
|
-
await board.label(root, { set: { status: 'new' } })
|
|
173
|
-
},
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
Its writes are authored by the platform actor who triggered the event, like
|
|
177
|
-
`slack:user:U0456`. It holds no token: `board()` makes its calls.
|
|
178
|
-
|
|
179
|
-
## Reverse routing
|
|
180
|
-
|
|
181
|
-
A bare function chooses a channel when an event arrives, so the board can't know
|
|
182
|
-
which external channel a board root in `support` belongs to. Declare it:
|
|
183
|
-
|
|
184
|
-
```ts
|
|
185
|
-
// in slack({ channels: { … } }):
|
|
186
|
-
'#support': {
|
|
187
|
-
channel: 'support',
|
|
188
|
-
run: async ({ event, board }) => {
|
|
189
|
-
await board.sync('support', event)
|
|
190
|
-
},
|
|
191
|
-
},
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
Inbound events still go through `run`. A root posted in `support` on the board
|
|
195
|
-
now starts a thread in `#support`, and replies flow both ways through the
|
|
196
|
-
connection. Outgoing delivery never runs your function.
|
|
197
|
-
|
|
198
|
-
## Retries and idempotency
|
|
199
|
-
|
|
200
|
-
A function that throws is retried. Writes made through `board` take IDs derived
|
|
201
|
-
from the inbox event and the order of calls, so a retry that writes in the same
|
|
202
|
-
order doesn't post twice, and a default import that already succeeded returns its
|
|
203
|
-
existing message. Two rules follow:
|
|
204
|
-
|
|
205
|
-
- Writes made in parallel (`Promise.all`) need explicit `id`s.
|
|
206
|
-
- Your own side effects aren't covered. Key them on `event.id`:
|
|
207
|
-
|
|
208
|
-
```ts
|
|
209
|
-
// in a callback, your side effect:
|
|
210
|
-
// await createTicket(event, { idempotencyKey: event.id })
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
On `a2()`, a function that keeps throwing dead-letters after 10 tries. That
|
|
214
|
-
holds up only its thread's lane in that channel's inbox; other threads and the
|
|
215
|
-
board keep flowing. A board on `http()` gives the event up after 5. Calling
|
|
216
|
-
`board.sync` after the function returns, twice, or with an event other than the
|
|
217
|
-
one it received throws.
|
|
218
|
-
|
|
219
|
-
## Render
|
|
220
|
-
|
|
221
|
-
Every provider takes `render`, which decides the body of each board message it
|
|
222
|
-
posts. It receives immutable copies of the message, as clients see it (its IDs
|
|
223
|
-
are strings), and its author, the default label, and the resolved destination,
|
|
224
|
-
and returns markdown:
|
|
225
|
-
|
|
226
|
-
```ts
|
|
227
|
-
// in kapso({ … }): post the text alone, without the author label
|
|
228
|
-
render: ({ message }) => message.text,
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
The default is `**${label}**: ${message.text}`, where the label is the author's
|
|
232
|
-
name: `claude via julian` for an agent profile, `ada (slack)` for a message that
|
|
233
|
-
came from a platform. board still adds a line per attachment the platform
|
|
234
|
-
didn't take, and converts mentions to the platform's own form after your
|
|
235
|
-
function runs.
|
|
236
|
-
|
|
237
|
-
A renderer can read labels. When a label change changes what a delivered copy
|
|
238
|
-
renders to, board edits that copy, so a PR's root in Slack can show its state:
|
|
239
|
-
|
|
240
|
-
```ts
|
|
241
|
-
// in slack({ … }):
|
|
242
|
-
render: ({ message, label }) =>
|
|
243
|
-
message.re === null && message.labels.state === 'merged'
|
|
244
|
-
? `:pr-merged: ${message.text}`
|
|
245
|
-
: `**${label}**: ${message.text}`,
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
`mentionOf(actor)` returns how the platform mentions an actor (`<@U0456>` in
|
|
249
|
-
Slack), through any account their person linked, or null. The default label is
|
|
250
|
-
the author's name, so nobody gets pinged unless a renderer asks:
|
|
251
|
-
|
|
252
|
-
```ts
|
|
253
|
-
// in slack({ … }): a PR's root mentions its owner, or its author
|
|
254
|
-
render: ({ message, label, mentionOf }) => {
|
|
255
|
-
const who = (message.labels.owner && mentionOf(message.labels.owner)) ?? mentionOf(message.by.actor)
|
|
256
|
-
return message.re === null && message.labels.pr && who
|
|
257
|
-
? `${message.text} · ${who}`
|
|
258
|
-
: `**${label}**: ${message.text}`
|
|
259
|
-
},
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
The default renderer reads no labels, so labels alone never edit a copy. Rendering
|
|
263
|
-
applies to roots, replies, backfills, and edits; changing it affects future
|
|
264
|
-
deliveries, not copies already sent. A subclass can override `render(input)`
|
|
265
|
-
instead; see [custom providers](./06-providers.md).
|