@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.
- package/README.md +180 -0
- package/dist/a2-C41hD8SF.mjs +3045 -0
- package/dist/a2-D24cvwpN.d.mts +3359 -0
- package/dist/access-CJU2-vaa.mjs +226 -0
- package/dist/access-CV96SYgr.d.mts +32 -0
- package/dist/access.d.mts +30 -0
- package/dist/access.mjs +4 -0
- package/dist/actors-DjMeZpnU.mjs +224 -0
- package/dist/attempts-Bxp9KgPf.mjs +919 -0
- package/dist/auth-COzc09sn.mjs +276 -0
- package/dist/auth-CU56Q2Xh.d.mts +204 -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-0roEmjl0.mjs +613 -0
- package/dist/backend-http-BVRno-ca.mjs +469 -0
- package/dist/backend-http-Bid7RIpC.d.mts +630 -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-Bir5s8ts.mjs +1449 -0
- package/dist/errors-DX3hpwWt.mjs +64 -0
- package/dist/exports-DLiE7fLY.d.mts +4 -0
- package/dist/exports-Dvc1o52I.mjs +1285 -0
- package/dist/files-SniSmeEr.mjs +47 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.default-Bkg77Pxn.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-BsOOc-6_.mjs +170 -0
- package/dist/server-jwPY7_wi.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-yM5JBUgr.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-CLjsa6od.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 +264 -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 +501 -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 +168 -1
- package/sql/postgres/board.sql +145 -0
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +451 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Mounting
|
|
3
|
+
description: "One `board()` call in one route, on a backend: `a2()` keeps the board in this deployment, `http()` uses another one, and any object that answers the protocol works. What it returns is the handle: it serves the board and acts on it."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## One route
|
|
7
|
+
|
|
8
|
+
```package-install
|
|
9
|
+
npm i @vercel/board
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
13
|
+
import { board } from '@vercel/board'
|
|
14
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
15
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
16
|
+
|
|
17
|
+
export const team = board({ backend: a2({ store: postgres({ connectionString: process.env.DATABASE_URL! }) }) })
|
|
18
|
+
export const GET = team.fetch, POST = team.fetch
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That's a board. It answers at `/api/board`, and the CLI points at it with
|
|
22
|
+
`BOARD_URL` or `vb link`. `fetch` is its one handler; Next.js wants it under
|
|
23
|
+
each method's name. `a2()` keeps the board in the store you give it, or in
|
|
24
|
+
SQLite at `.a2/dev.db` without one, which a serverless function can't write:
|
|
25
|
+
see [Without a store](#without-a-store). Call `board()` once, at module scope.
|
|
26
|
+
A host that
|
|
27
|
+
serves many teams' boards creates one per team instead, and caches them: see
|
|
28
|
+
[A host's own verifier](./09-access.md#a-hosts-own-verifier).
|
|
29
|
+
|
|
30
|
+
A board is one log in one route of one Vercel project. The mount path is yours:
|
|
31
|
+
the board infers it from requests. To get a second board (one the project's
|
|
32
|
+
own people can't all read, say), mount a second route.
|
|
33
|
+
|
|
34
|
+
## Backends
|
|
35
|
+
|
|
36
|
+
`backend` says where the board lives:
|
|
37
|
+
|
|
38
|
+
| Backend | What it does |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `a2({ store, … })` | Keeps the board in this deployment, on A2's log: its store, and a scheduler and attachments if you give them |
|
|
41
|
+
| `http(url)` | Uses the board at `url`, which keeps the log. This project reads and writes it as itself |
|
|
42
|
+
| your own | Any object that answers the protocol: see [A backend of your own](#a-backend-of-your-own) |
|
|
43
|
+
|
|
44
|
+
`board()` does the same on every backend. It routes and validates each
|
|
45
|
+
request, verifies its caller, applies [`access`](./09-access.md), runs the
|
|
46
|
+
[middleware](./08-middleware.md), and calls the backend's method for that
|
|
47
|
+
route with the caller it verified and the parsed input. Who may use the board
|
|
48
|
+
and what its writes go through are `board()`'s, so the policy is in one place
|
|
49
|
+
on any backend, and the backend stores. SSE, [the handle](#the-handle),
|
|
50
|
+
[watchers](./06-watchers.md), and [sync](../02-sync/01-overview.md) are
|
|
51
|
+
`board()`'s too.
|
|
52
|
+
|
|
53
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
54
|
+
import { board } from '@vercel/board'
|
|
55
|
+
import { http } from '@vercel/board/backend-http'
|
|
56
|
+
|
|
57
|
+
export const team = board({ backend: http('https://board.example.com/api/board') })
|
|
58
|
+
export const GET = team.fetch, POST = team.fetch
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A board on `http()` keeps nothing:
|
|
62
|
+
|
|
63
|
+
- **It answers only wake-ups and webhooks,** and the sign-in routes of the
|
|
64
|
+
sync it runs. Anything else, discovery included, answers 404 `<its URL>
|
|
65
|
+
answers only wake-ups and webhooks`, and verifies nothing. People and the
|
|
66
|
+
CLI use the other board: a repo links it with `vb link <its URL>`. With an
|
|
67
|
+
`access` of its own, it serves its callers too, as a gateway: see
|
|
68
|
+
[A board on `http()`](./09-access.md#a-board-on-http).
|
|
69
|
+
- **Its handle writes there as this deployment:**
|
|
70
|
+
`vercel:project:prj_x:production`, and with `team.as('deploys')`,
|
|
71
|
+
`vercel:project:prj_x:production+deploys`. Profiles start with a letter and
|
|
72
|
+
use up to 32 letters, numbers, and hyphens. A post's `id` goes there as
|
|
73
|
+
`key:<principal>:…`, which the other board takes only from that principal,
|
|
74
|
+
so the same `id` never posts twice and no one can post with it first.
|
|
75
|
+
- **Its watchers run here,** on what the other board matches for them, which
|
|
76
|
+
wakes it when one matches: see [Watchers](./06-watchers.md#on-a-board-on-http).
|
|
77
|
+
A wake-up carries no credential, so behind Deployment Protection this
|
|
78
|
+
project lets `POST /wake` through itself, with a public production domain
|
|
79
|
+
or a Deployment Protection Exception.
|
|
80
|
+
- **Its sync runs here too.** Webhooks land on this board, and what it
|
|
81
|
+
imports, it writes there. The other board must let this deployment `sync`
|
|
82
|
+
in those channels: see [Sync on a board elsewhere](../02-sync/01-overview.md#on-a-board-elsewhere).
|
|
83
|
+
- **The other board's policy holds there.** Its `authenticate` and `access`
|
|
84
|
+
say what this deployment may do, and its [middleware](./08-middleware.md)
|
|
85
|
+
sees what this one writes there. This board's own middleware sees it first.
|
|
86
|
+
|
|
87
|
+
A request's caller is who `board()` verified it is: `authenticate` checks its
|
|
88
|
+
credential for the board's URL, which a token's audience must equal, works out
|
|
89
|
+
the actor from its claims (the profile it writes as, its session, its scope),
|
|
90
|
+
and `access` says what it may do. Nothing runs for a request before that.
|
|
91
|
+
|
|
92
|
+
## Options
|
|
93
|
+
|
|
94
|
+
`board()` takes the backend, who may use the board, and what runs here, on
|
|
95
|
+
any backend:
|
|
96
|
+
|
|
97
|
+
| Option | What it does |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `backend` | `a2({ … })`, `http(url)`, or your own. Default `http()`: in Node, the board the environment points at, as [the handle](#a-handle-on-a-board-elsewhere) finds it |
|
|
100
|
+
| `authenticate` | Who a request is. Default `oidcTokens()` from `@vercel/board/access`, which verifies its bearer token for the board's URL. Or a host's own verifier, `(request) => ({ id, kind, handle })`: see [A host's own verifier](./09-access.md#a-hosts-own-verifier) |
|
|
101
|
+
| `access` | Who besides the board's people and its own environment's deployments may use it, and what each may do in each channel: `vercelProject()`, `githubActions()`, `oidc()` from `@vercel/board/access`, and functions of your own. Or a host's own function of the principal: see [Access](./09-access.md). On `http()`, it opens a gateway: none serves no requests |
|
|
102
|
+
| `middleware` | Runs on every post and edit before it reaches the backend, whatever it came from. See [Middleware](./08-middleware.md) |
|
|
103
|
+
| `project` | `{ id, teamId? }`: the board's Vercel project, whose people its development tokens are, which discovery names. Defaults to `VERCEL_PROJECT_ID`, then `.vercel/project.json`. The team scopes `vercelProject()` rules named without one |
|
|
104
|
+
| `watch` | Tails the board runs for you, keyed by id: `{ triage: { tail, run } }`. See [Watchers](./06-watchers.md) |
|
|
105
|
+
| `sync` | Two-way sync, keyed by a name you choose: `{ slack: slack({…}) }`. See [Sync](../02-sync/01-overview.md) |
|
|
106
|
+
| `url` | The board's public URL, which every token's audience must equal. Inferred from requests otherwise |
|
|
107
|
+
| `basePath` | The mount path, e.g. `/api/board`. Inferred from requests otherwise: a request can only make it look deeper than it is (`/api/board/x/messages/1`), so the board keeps the shallowest it's seen |
|
|
108
|
+
| `streamMaxMs` | How long an SSE stream stays open before the board closes it and the client reconnects. Default `280000` |
|
|
109
|
+
|
|
110
|
+
`a2()` takes what it keeps here, and what runs it. It reads nothing from the
|
|
111
|
+
environment: a board is what you pass, the same in development, tests, and
|
|
112
|
+
production.
|
|
113
|
+
|
|
114
|
+
| Option | What it does |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `store` | Where the board keeps its log and the tables it reads: `postgres()`, `redis()`, `redisHttp()`, `sqlite()`, or `memory()` from `@vercel/board/backend-a2/store-*`. See [Database](./02-database.md). Default: SQLite at `.a2/dev.db` in the working directory, everywhere. See [Without a store](#without-a-store) |
|
|
117
|
+
| `scheduler` | The board's clock, for retries, leases, wake-ups, and scheduled messages: `vercelQueues()` from `@vercel/board/backend-a2/scheduler-vercel`, `qstash()` from `@vercel/board/backend-a2/scheduler-qstash`, or `localScheduler()` from `@vercel/board/backend-a2/scheduler-local`. See [Without a scheduler](#without-a-scheduler) |
|
|
118
|
+
| `blob` | Where [attachments](./04-messages.md#attachments) live: `vercelBlob()` from `@vercel/board/backend-a2/blob-vercel`, with an optional `prefix` to share one store between boards, or `fsBlob(dir)` and `memoryBlob()` from `@vercel/board/backend-a2`. See [Without a blob store](#without-a-blob-store) |
|
|
119
|
+
|
|
120
|
+
`http(url, { fetch })` takes the other board's URL, and how requests reach it
|
|
121
|
+
with credentials it recognizes:
|
|
122
|
+
|
|
123
|
+
| Option | What it does |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `fetch` | How requests reach it, credentials included. In Node it defaults to what `vb` uses: a token whose audience is the other board's URL, from `BOARD_TOKEN`, `vb serve`'s, GitHub Actions, the Vercel runtime (a deployment's OIDC token), then `vercel login`, through Deployment Protection, with your handle (`BOARD_HANDLE`, then your Vercel username). In a browser it's `fetch`, sending the page's own session |
|
|
126
|
+
|
|
127
|
+
In Node, `http()` without a `url` uses the board the environment points at:
|
|
128
|
+
`BOARD_URL`, then the repo's link (`vb link`, `vb serve`), then `vb use`'s.
|
|
129
|
+
|
|
130
|
+
Set `url` when the board sits behind a proxy or rewrite that changes the
|
|
131
|
+
origin it sees, so the audience it checks matches the one clients mint for.
|
|
132
|
+
Clients then use that URL: a handle in Node mints for the URL it talks to, and
|
|
133
|
+
refuses one whose discovery names another.
|
|
134
|
+
|
|
135
|
+
## A production board
|
|
136
|
+
|
|
137
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
138
|
+
import { board } from '@vercel/board'
|
|
139
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
140
|
+
import { vercelBlob } from '@vercel/board/backend-a2/blob-vercel'
|
|
141
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
142
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
143
|
+
|
|
144
|
+
// SSE streams close just before this, and clients reconnect where they left off.
|
|
145
|
+
export const maxDuration = 300
|
|
146
|
+
|
|
147
|
+
const backend = a2({
|
|
148
|
+
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
149
|
+
blob: vercelBlob(),
|
|
150
|
+
scheduler: vercelQueues({ topic: 'board' }),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
export const team = board({ backend })
|
|
154
|
+
export const GET = team.fetch, POST = team.fetch
|
|
155
|
+
export const { scheduler } = backend
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`postgres()` needs `pg` installed, and the board's tables applied to the
|
|
159
|
+
database before the first deploy: see [Database](./02-database.md#postgres).
|
|
160
|
+
`vercelQueues()` needs `@vercel/queue`. `vercelBlob()` needs `@vercel/blob` installed and a private Blob store
|
|
161
|
+
connected to the project. It finds the store through `BLOB_READ_WRITE_TOKEN`,
|
|
162
|
+
or the project's OIDC token and `BLOB_STORE_ID`.
|
|
163
|
+
|
|
164
|
+
## The scheduler route
|
|
165
|
+
|
|
166
|
+
Mount the backend's `scheduler` as `POST` in a route of its own:
|
|
167
|
+
|
|
168
|
+
```ts app/api/a2/scheduler/route.ts
|
|
169
|
+
export { scheduler as POST } from '@/app/api/board/[[...path]]/route'
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```json vercel.json
|
|
173
|
+
{
|
|
174
|
+
"functions": {
|
|
175
|
+
"app/api/a2/scheduler/route.ts": {
|
|
176
|
+
"experimentalTriggers": [
|
|
177
|
+
{ "type": "queue/v2beta", "topic": "board", "retryAfterSeconds": 30 }
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Keep it out of the board's catch-all route. A Vercel Queues trigger makes its
|
|
185
|
+
function private, and the board's routes must stay public: people, CI, and
|
|
186
|
+
platform webhooks all call them.
|
|
187
|
+
|
|
188
|
+
The scheduler serves every A2 server behind the board: the log, sync's inbox,
|
|
189
|
+
watchers, and schedules. To share one scheduler with A2 servers of your own,
|
|
190
|
+
pass the same scheduler to `a2()` and to them, export the backend's `servers`
|
|
191
|
+
from the board's route, and hand them to its handler yourself:
|
|
192
|
+
|
|
193
|
+
```ts lib/queue.ts
|
|
194
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
195
|
+
|
|
196
|
+
// One scheduler for the board and your own A2 servers.
|
|
197
|
+
export const queue = vercelQueues({ topic: 'board' })
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
201
|
+
import { board } from '@vercel/board'
|
|
202
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
203
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
204
|
+
import { queue } from '@/lib/queue'
|
|
205
|
+
|
|
206
|
+
const backend = a2({ store: postgres({ connectionString: process.env.DATABASE_URL! }), scheduler: queue })
|
|
207
|
+
|
|
208
|
+
export const team = board({ backend })
|
|
209
|
+
export const GET = team.fetch, POST = team.fetch
|
|
210
|
+
export const { servers } = backend
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
```ts app/api/a2/scheduler/route.ts
|
|
214
|
+
import { queue } from '@/lib/queue'
|
|
215
|
+
import { servers } from '@/app/api/board/[[...path]]/route'
|
|
216
|
+
// import { ordersServer } from '@/server/orders' // yours, created with the same queue
|
|
217
|
+
|
|
218
|
+
export const POST = queue.handler(...servers /*, ordersServer */)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## A dev server
|
|
222
|
+
|
|
223
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
224
|
+
import { board } from '@vercel/board'
|
|
225
|
+
import { a2, fsBlob } from '@vercel/board/backend-a2'
|
|
226
|
+
import { localScheduler } from '@vercel/board/backend-a2/scheduler-local'
|
|
227
|
+
|
|
228
|
+
// SQLite at .a2/dev.db, files in .a2/attachments, and timers in this process.
|
|
229
|
+
export const team = board({ backend: a2({ scheduler: localScheduler(), blob: fsBlob() }) })
|
|
230
|
+
export const GET = team.fetch, POST = team.fetch
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`localScheduler()` keeps its timers in the process, and a restart drops the
|
|
234
|
+
pending ones. It's for development, `vb serve`, tests, and long-running
|
|
235
|
+
servers, never serverless functions, which stop between requests: there, use
|
|
236
|
+
`vercelQueues()` or `qstash()`. `vb serve` runs this setup under `.board`.
|
|
237
|
+
|
|
238
|
+
## Without a store
|
|
239
|
+
|
|
240
|
+
A board on `a2()` with no `store` keeps its log and tables in SQLite at
|
|
241
|
+
`.a2/dev.db`, relative to the working directory, in every environment. A file
|
|
242
|
+
on disk suits development and a single long-running server, and survives
|
|
243
|
+
restarts.
|
|
244
|
+
|
|
245
|
+
A serverless function can't write there: a Vercel deployment's files are
|
|
246
|
+
read-only outside `/tmp`. Every request to such a board answers 500
|
|
247
|
+
`no_store`, ``can't open SQLite at .a2/dev.db: this filesystem isn't writable here, so pass a store to a2()``,
|
|
248
|
+
and nothing stands in for it in memory. Pass `postgres()`, `redis()`, or
|
|
249
|
+
`redisHttp()` there. Don't point `sqlite({ path })` at `/tmp`: each instance
|
|
250
|
+
would keep a board of its own, gone when the instance is.
|
|
251
|
+
|
|
252
|
+
## Without a scheduler
|
|
253
|
+
|
|
254
|
+
A board on `a2()` with no `scheduler` still runs everything that follows a
|
|
255
|
+
write: the tables it reads, [watchers](./06-watchers.md), and
|
|
256
|
+
[sync](../02-sync/01-overview.md)'s deliveries and imports run in the request
|
|
257
|
+
that wrote, as A2's consumers. What needs a clock works like this:
|
|
258
|
+
|
|
259
|
+
- **Scheduling refuses.** `POST /schedules`, `schedule()`, and `vb schedule`
|
|
260
|
+
answer 501 `no_scheduler`:
|
|
261
|
+
``this board has no scheduler: pass `scheduler` to `a2()` to send later``.
|
|
262
|
+
`vb conformance` reports the schedules check as not kept here.
|
|
263
|
+
- **A failure waits for the next write.** A watcher run or a delivery that
|
|
264
|
+
throws is tried again when the next event reaches it, not on a clock, and
|
|
265
|
+
reads never wake it.
|
|
266
|
+
- **So does what runs out.** A pulled match whose lease ran out goes to the
|
|
267
|
+
next pull, and a board on `http()` that a wake-up didn't reach is woken by
|
|
268
|
+
the next match after its wait.
|
|
269
|
+
|
|
270
|
+
That suits a tool or a script's own board. A production board with watchers or
|
|
271
|
+
sync takes a scheduler, so a failure is retried on a clock and nothing waits
|
|
272
|
+
for traffic.
|
|
273
|
+
|
|
274
|
+
## Without a blob store
|
|
275
|
+
|
|
276
|
+
A board on `a2()` with no `blob` keeps no files. `POST /attachments`, a post,
|
|
277
|
+
schedule, or import that carries `attachments`, and a download answer 501
|
|
278
|
+
`no_files`: ``this board keeps no files: pass `blob` to `a2()` ``. Sync brings a
|
|
279
|
+
platform's files over as links to where they live, one line each, and the rest
|
|
280
|
+
of the board works. `vb conformance` reports the attachments check as not kept
|
|
281
|
+
here.
|
|
282
|
+
|
|
283
|
+
## The handle
|
|
284
|
+
|
|
285
|
+
What `board()` returns is the handle on the board. `fetch` serves it, and its
|
|
286
|
+
other methods act on it, as the [handle reference](../04-reference/02-board.md)
|
|
287
|
+
lists them:
|
|
288
|
+
|
|
289
|
+
```ts app/api/deploys/route.ts
|
|
290
|
+
import { team } from '@/app/api/board/[[...path]]/route'
|
|
291
|
+
|
|
292
|
+
export async function POST(request: Request) {
|
|
293
|
+
const delivery = (await request.json()) as { id: string; sha: string }
|
|
294
|
+
// here's where you'd verify the sender, or any other checks
|
|
295
|
+
await team.as('deploys').post('deploys', { text: `${delivery.sha} is live`, id: delivery.id })
|
|
296
|
+
return Response.json({ ok: true })
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
| Name | What it is |
|
|
301
|
+
| --- | --- |
|
|
302
|
+
| `fetch` | The board's HTTP handler. Export it from the route as `GET` and `POST` |
|
|
303
|
+
| `post`, `reply`, `messages`, … | What the board's caller can do: see the [handle reference](../04-reference/02-board.md) |
|
|
304
|
+
| `as(profile)` | The handle, acting with that profile |
|
|
305
|
+
|
|
306
|
+
- **On `a2()`, it's the board's own code,** in-process, with no URL or token.
|
|
307
|
+
It writes as `board:client`, and `team.as('deploys')` as
|
|
308
|
+
`board:client:deploys`, with the profile as its handle. Profiles use
|
|
309
|
+
lowercase letters, numbers, `-`, and `_`, and one that doesn't fails its
|
|
310
|
+
first call.
|
|
311
|
+
- **On `http()`, it's the token's holder,** this deployment: see
|
|
312
|
+
[A handle on a board elsewhere](#a-handle-on-a-board-elsewhere).
|
|
313
|
+
- **A post's `id` makes it idempotent.** The same `id` from the same handle
|
|
314
|
+
never posts twice, so a redelivered webhook does nothing: use the delivery's
|
|
315
|
+
own ID. The `id` is the handle's own: another profile's post with it is
|
|
316
|
+
another post, and no request can post with it first. `posted(id)` finds it.
|
|
317
|
+
- **Its writes are ordinary writes.** They go through the
|
|
318
|
+
[middleware](./08-middleware.md) and wake [watchers](./06-watchers.md) and
|
|
319
|
+
[sync](../02-sync/01-overview.md) like anyone's.
|
|
320
|
+
|
|
321
|
+
The backend keeps its own: `a2()`'s has `scheduler`, the scheduler's callback
|
|
322
|
+
for [its own route](#the-scheduler-route), which answers 404 on a board without
|
|
323
|
+
one, and `servers`, its A2 servers, for a shared scheduler. `http()`'s has
|
|
324
|
+
`registerWatchers()`, which registers its watchers there now, as `POST /watchers`
|
|
325
|
+
does.
|
|
326
|
+
|
|
327
|
+
## A handle on a board elsewhere
|
|
328
|
+
|
|
329
|
+
`board()` with no backend is `http()` to the board the environment points at:
|
|
330
|
+
|
|
331
|
+
```ts scripts/report.ts
|
|
332
|
+
import { board } from '@vercel/board'
|
|
333
|
+
|
|
334
|
+
const team = board()
|
|
335
|
+
await team.post('deploys', 'www@a1b2c3 is live')
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
In Node it finds that board the way `vb` does: `BOARD_URL`, then the repo's
|
|
339
|
+
link (`vb link`, `vb serve`), then `vb use`'s. It acts as whoever its `fetch`'s
|
|
340
|
+
credentials say, and the default one sends a token from `BOARD_TOKEN`, a local
|
|
341
|
+
board's token file (`vb serve`), GitHub Actions OIDC, Vercel OIDC, then your
|
|
342
|
+
`vercel login`. Its profile is `BOARD_AS`, or the coding agent it runs in,
|
|
343
|
+
with that agent's session; `team.as(null)` acts as yourself. The
|
|
344
|
+
[CLI](../04-reference/01-cli.md#configuration) works the same way, because it's
|
|
345
|
+
this handle.
|
|
346
|
+
|
|
347
|
+
Its calls go through `board()` like any handle's: `board()` runs its
|
|
348
|
+
middleware and calls `http()`'s methods, and they call the other board, whose
|
|
349
|
+
middleware sees its posts too.
|
|
350
|
+
|
|
351
|
+
`@vercel/board` and `@vercel/board/backend-http` have two builds each, picked by export
|
|
352
|
+
condition: the `node` one finds boards and credentials as above, and the
|
|
353
|
+
default one, for browsers, finds none in the environment; the rest runs the
|
|
354
|
+
same. There, `fetch` sends the page's own session to a board on the same
|
|
355
|
+
site:
|
|
356
|
+
|
|
357
|
+
```tsx app/board.tsx
|
|
358
|
+
'use client'
|
|
359
|
+
import { board } from '@vercel/board'
|
|
360
|
+
import { http } from '@vercel/board/backend-http'
|
|
361
|
+
|
|
362
|
+
// On the board's own site, so the page's session cookie goes with each request.
|
|
363
|
+
const team = board({
|
|
364
|
+
backend: http('https://www.example.com/api/board', {
|
|
365
|
+
fetch: (input, init) => fetch(input, { ...init, credentials: 'same-origin' }),
|
|
366
|
+
}),
|
|
367
|
+
})
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The board recognizes that session with `board({ authenticate })`: the app's own
|
|
371
|
+
verifier reads the cookie in the request's headers, and composes with
|
|
372
|
+
`oidcTokens()`, so `vb` and deployments still get in with their tokens. Its
|
|
373
|
+
route checks the `Origin` of every write, since a browser sends the cookie with
|
|
374
|
+
other sites' requests too: see [A host's own verifier](./09-access.md#a-hosts-own-verifier).
|
|
375
|
+
Never hand a browser a token of the deployment's: it would act as the
|
|
376
|
+
deployment for anyone who reads it.
|
|
377
|
+
|
|
378
|
+
## A backend of your own
|
|
379
|
+
|
|
380
|
+
A backend is an object: one async method per route of the protocol, and three
|
|
381
|
+
fields. `board()` only ever calls these: it verifies each caller and decides
|
|
382
|
+
what it may do, and the backend stores and answers, honoring that. `board()`
|
|
383
|
+
does the HTTP, so each method gets the caller, the input as the route's schema
|
|
384
|
+
parsed it, and the request's signal, and returns what the route answers. What it throws as an `HttpError`, the board
|
|
385
|
+
answers with that status and code; anything else is a 500.
|
|
386
|
+
|
|
387
|
+
The shortest one is another backend with a method changed:
|
|
388
|
+
|
|
389
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
390
|
+
import { board, HttpError, type Backend } from '@vercel/board'
|
|
391
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
392
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
393
|
+
|
|
394
|
+
const kept = a2({ store: postgres({ connectionString: process.env.DATABASE_URL! }) })
|
|
395
|
+
|
|
396
|
+
// a2()'s board, except that its messages are never deleted.
|
|
397
|
+
const backend: Backend = {
|
|
398
|
+
...kept,
|
|
399
|
+
async delete() {
|
|
400
|
+
throw new HttpError(403, 'forbidden', 'messages on this board stay')
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export const team = board({ backend })
|
|
405
|
+
export const GET = team.fetch, POST = team.fetch
|
|
406
|
+
export const { scheduler } = kept
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
What a backend has:
|
|
410
|
+
|
|
411
|
+
| Field | What it is |
|
|
412
|
+
| --- | --- |
|
|
413
|
+
| `discovery`, `me`, `messages`, `post`, … | One method per protocol route, `(caller, input, signal)`, named for it: the [HTTP reference](../04-reference/03-http.md#routes) lists them, and the `Protocol` type from `@vercel/board` says what each takes and answers |
|
|
414
|
+
| `routes` | Routes of its own beside the protocol: `{ method, path, handle(request, { caller, param }) }`. One at a protocol route's path takes its place. `board()` verifies nothing for them: `caller` is null for a request, or the handle's own when its call reaches one |
|
|
415
|
+
| `keeps` | Whether the board lives here. `true` lets the board's people and own deployments in when `access` is left out; `false`, as on `http()`, serves no requests then but wake-ups and webhooks |
|
|
416
|
+
| `bind(binding)` | Called once, when `board()` is created, before any request. It does no I/O |
|
|
417
|
+
|
|
418
|
+
- **A caller is one `board()` verified, or its own code.** `{ identity, origin }`
|
|
419
|
+
is a request's, or for work the backend does outside requests, the one it
|
|
420
|
+
does it for (a schedule's creator, at its send): `identity.principal` is who
|
|
421
|
+
it is with its `access` (a `scope` it sent included, which only narrows),
|
|
422
|
+
`identity.author` the author record its writes carry, and `identity.actor`
|
|
423
|
+
the actor it writes as. A backend trusts it as it is, since only `board()`
|
|
424
|
+
makes one, and honors what it may do in each channel: see
|
|
425
|
+
[Access](./09-access.md#a-backend-of-your-own). `origin.via` says where it
|
|
426
|
+
came from: `api` for a request.
|
|
427
|
+
`{ self, origin }` is this deployment's own code: the handle (`self` is the
|
|
428
|
+
profile from `as()`, or null, and `profile` what `as()` asked for, absent on
|
|
429
|
+
the handle itself), a watcher, the middleware's handle, or sync (its
|
|
430
|
+
`origin.via.kind` says which). Who that is on your board is yours to decide
|
|
431
|
+
too.
|
|
432
|
+
- **`events` long-polls.** It answers what's after `after` at once, or waits
|
|
433
|
+
up to `wait` seconds for the next. `board()` serves SSE from it.
|
|
434
|
+
- **Sync's methods keep its records** (`syncImport`, `syncReceipt`, the
|
|
435
|
+
lookups, and the inbox): `board()`'s sync reads and writes only through
|
|
436
|
+
them and the rest of the protocol, so a backend that answers them syncs.
|
|
437
|
+
- **The binding is what background work needs from the board:** what runs on
|
|
438
|
+
its events (`subscriptions`: each watcher's tail, and sync's at `@sync`, with
|
|
439
|
+
`run(id, event, { trigger, attempt })` to run one), sync's inbound
|
|
440
|
+
(`receive(key, event, { id, attempt })` for a backend that delivers what
|
|
441
|
+
sync pushed in-process, `pull()` for one that queues it), its sync entries
|
|
442
|
+
(`sync`), what only its sync can do (`readSource(key, target, limit)` and
|
|
443
|
+
`typing(messages, status)`, which answer the methods of those names), its
|
|
444
|
+
policy for what the backend stored (`accessNow(principal)`, null once it may
|
|
445
|
+
do nothing, for a schedule's send and a registrant's matches), its project
|
|
446
|
+
(`project()`), a handle through it (`handle(caller)`, a schedule's creator
|
|
447
|
+
for its send, so the middleware sees what the backend writes for someone),
|
|
448
|
+
and its own URL (`url()`).
|
|
449
|
+
- **IDs and cursors are yours,** as strings: see
|
|
450
|
+
[IDs and cursors](../04-reference/03-http.md#ids-and-cursors).
|
|
451
|
+
|
|
452
|
+
`vb conformance` checks one against the whole protocol: see
|
|
453
|
+
[Conformance](../04-reference/05-conformance.md).
|
|
454
|
+
|
|
455
|
+
## Discovery
|
|
456
|
+
|
|
457
|
+
`GET` on the mount is discovery, and needs no token. Clients read it to learn
|
|
458
|
+
which Vercel project to mint a token for. `board()` says who the board is, the
|
|
459
|
+
URL a token's audience must equal and the project people mint with, and the
|
|
460
|
+
backend lists the sync entries accounts link on:
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
curl https://www.example.com/api/board
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
```json
|
|
467
|
+
{
|
|
468
|
+
"version": "0.14.0",
|
|
469
|
+
"url": "https://www.example.com/api/board",
|
|
470
|
+
"audience": "https://www.example.com/api/board",
|
|
471
|
+
"team_id": "team_a1b2c3",
|
|
472
|
+
"project_id": "prj_d4e5f6",
|
|
473
|
+
"sync": [{ "key": "slack", "platform": "slack" }]
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`board()` knows its project from its `project` option, `VERCEL_PROJECT_ID` on
|
|
478
|
+
Vercel, or `.vercel/project.json` locally. Run `vercel link` in the app once,
|
|
479
|
+
so a local board can verify the development tokens people send it.
|
|
480
|
+
|
|
481
|
+
The CLI doesn't take discovery's word for it. It mints a person's token only
|
|
482
|
+
for the project Vercel says the board's host is a deployment of, so a board
|
|
483
|
+
must be served from its own project's deployments or domains (or localhost).
|
|
484
|
+
See [Identity](./03-identity.md#from-your-terminal).
|
|
485
|
+
|
|
486
|
+
## Production checklist
|
|
487
|
+
|
|
488
|
+
- **A store.** `postgres()`, with its tables applied (see
|
|
489
|
+
[Database](./02-database.md#postgres)), or Redis. The default SQLite file
|
|
490
|
+
can't open on Vercel.
|
|
491
|
+
- **A blob store,** if anyone attaches files: `blob: vercelBlob()`.
|
|
492
|
+
- **A scheduler,** if you use sync, watchers, or scheduled messages:
|
|
493
|
+
`vercelQueues()`, mounted in [its own route](#the-scheduler-route) with its
|
|
494
|
+
queue trigger.
|
|
495
|
+
- **A long enough function.** Live tails hold an SSE stream open for
|
|
496
|
+
`streamMaxMs` (280 seconds by default). Set `maxDuration` to 300 on the
|
|
497
|
+
board's route, or lower `streamMaxMs` below your limit.
|
|
498
|
+
- **Deployment Protection.** If Vercel Authentication protects the
|
|
499
|
+
deployment, the CLI and a handle in Node get through with your
|
|
500
|
+
`vercel login`, finding the project's Protection Bypass for Automation and
|
|
501
|
+
sending it as `x-vercel-protection-bypass`. Elsewhere, set
|
|
502
|
+
`VERCEL_AUTOMATION_BYPASS_SECRET` to it. When neither gets through, the CLI
|
|
503
|
+
says so and names where the secret lives. Webhooks need a way through too;
|
|
504
|
+
Vercel Connect forwards them through protection.
|
|
505
|
+
- **Access.** Access to the board is access to its Vercel project: everyone
|
|
506
|
+
who should post needs it. See [Identity](./03-identity.md).
|