create-stitchkit 0.5.1 → 0.6.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/CHANGELOG.md +41 -0
- package/UPGRADING.md +43 -0
- package/examples/repository/packages/backend/src/surface.snapshot.json +32 -0
- package/examples/repository/packages/backend/src/surface.ts +49 -2
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +7 -0
- package/examples/repository/packages/shared/src/index.ts +3 -0
- package/examples/repository/project.json +10 -0
- package/package.json +1 -1
- package/template/.data/board.sqlite +0 -0
- package/template/bun.lock +2 -5
- package/template/package.json +1 -1
- package/template/packages/backend/src/index.ts +10 -2
- package/template/packages/backend/src/lib/board.ts +99 -0
- package/template/packages/backend/src/lib/live.ts +73 -0
- package/template/packages/backend/src/surface.snapshot.json +32 -0
- package/template/packages/backend/src/surface.ts +53 -3
- package/template/packages/backend/src/transport/board-service.ts +18 -0
- package/template/packages/config/src/variables.ts +16 -0
- package/template/packages/frontend/src/app/[locale]/page.tsx +2 -0
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +9 -1
- package/template/packages/frontend/src/features/board/board-live.ts +91 -0
- package/template/packages/frontend/src/features/board/board-panel.tsx +112 -0
- package/template/packages/shared/src/contracts/board.ts +54 -0
- package/template/packages/shared/src/contracts/live.ts +20 -0
- package/template/packages/shared/src/index.ts +3 -0
- package/template/packages/shared/src/schemas/board.ts +41 -0
- package/template/project.json +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,47 @@ step is overwritten by the next release.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.6.0] — 2026-09-02
|
|
16
|
+
|
|
17
|
+
The generated project stops being an empty frame with a to-do at the bottom of
|
|
18
|
+
the page. It ships **one vertical feature**, from schema to transport to UI —
|
|
19
|
+
and it was chosen so that every live primitive Stitchkit gained in 0.75–0.76 is
|
|
20
|
+
there because the feature needs it, not to demonstrate anything.
|
|
21
|
+
|
|
22
|
+
The demonstration is the second browser tab: post from it, and the first one
|
|
23
|
+
updates without asking. Nothing on that page polls, and nothing refetches after
|
|
24
|
+
a write.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **A live board.** `board.list` is a watched read and `board.post` writes to it.
|
|
29
|
+
Together they use, and only where they are needed:
|
|
30
|
+
- `defineEvents` — one topic, `board.changed`, declared in `shared` because the
|
|
31
|
+
server announces it and the browser subscribes to it;
|
|
32
|
+
- `defineKeyspace` / `openKeyspace` over SQLite — notes are authoritative in
|
|
33
|
+
memory and durable behind it, so a read in a handler needs no `await` and a
|
|
34
|
+
restart loses nothing;
|
|
35
|
+
- `createWatchHub` / `createWatchClient` — every browser asking the same
|
|
36
|
+
question is **one read** on the server, re-run when the topic says the answer
|
|
37
|
+
may have changed;
|
|
38
|
+
- `createTrustFence` — installed on **both** lanes when `TRUSTED_HOSTS` is set,
|
|
39
|
+
because the Socket.IO lane never reaches a lifecycle hook.
|
|
40
|
+
|
|
41
|
+
The comments say which of these to reach for and, more usefully, when not to:
|
|
42
|
+
a keyspace is for a small bounded set the process wants synchronously, and the
|
|
43
|
+
moment a thing wants queries, relations or unbounded growth it is a database
|
|
44
|
+
row and Prisma is already there for it.
|
|
45
|
+
|
|
46
|
+
- **Two environment variables**, both declared in the one place the project
|
|
47
|
+
declares variables: `BOARD_STORE_PATH` (defaulted, and the directory is created
|
|
48
|
+
by the application rather than by whoever deploys it) and `TRUSTED_HOSTS`
|
|
49
|
+
(unset means no fence, which is honest for a laptop and wrong for anything a
|
|
50
|
+
network can reach — a fence cannot invent the names it should answer to).
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- The template now targets `stitchkit` `^0.76.1`, up from `^0.71.0`.
|
|
55
|
+
|
|
15
56
|
## [0.5.1] — 2026-09-01
|
|
16
57
|
|
|
17
58
|
Three findings from someone setting up a new application on the starter from
|
package/UPGRADING.md
CHANGED
|
@@ -60,6 +60,49 @@ the first scaffolder release with a migration channel of its own.
|
|
|
60
60
|
|
|
61
61
|
---
|
|
62
62
|
|
|
63
|
+
## Released migration: 0.6.0
|
|
64
|
+
|
|
65
|
+
The scaffolder gained a vertical feature. Adopting it in a project you already
|
|
66
|
+
own is optional — nothing breaks if you skip it — but two things are **operator
|
|
67
|
+
steps**, and skipping those with the feature adopted means the API will not
|
|
68
|
+
start.
|
|
69
|
+
|
|
70
|
+
### 1. The store directory has to be writable
|
|
71
|
+
|
|
72
|
+
`BOARD_STORE_PATH` defaults to `.data/board.sqlite`, relative to the API role's
|
|
73
|
+
working directory. The application creates the directory itself; what it cannot
|
|
74
|
+
do is make a read-only volume writable.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# on the machine, as the user the API runs as
|
|
78
|
+
test -w "$(dirname "${BOARD_STORE_PATH:-.data/board.sqlite}")" || echo "not writable"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If the role runs from a read-only image, point `BOARD_STORE_PATH` at a mounted
|
|
82
|
+
volume instead.
|
|
83
|
+
|
|
84
|
+
### 2. Decide about the trust fence, on purpose
|
|
85
|
+
|
|
86
|
+
`TRUSTED_HOSTS` is unset by default, and unset means **no fence**. That is
|
|
87
|
+
correct on a laptop and wrong on anything a network reaches — but a fence cannot
|
|
88
|
+
guess which names your deployment answers to, so it refuses to invent them.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# every authority this deployment answers on, comma separated
|
|
92
|
+
TRUSTED_HOSTS=app.internal,app.internal:5181
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Set it and the fence is installed on both lanes: HTTP before routing, and the
|
|
96
|
+
realtime handshake, which never reaches a lifecycle hook on either runtime. If
|
|
97
|
+
your browser lives on another origin you already declared it as `CORS_ORIGIN`,
|
|
98
|
+
and the fence reads that one rather than asking you a second time.
|
|
99
|
+
|
|
100
|
+
### 3. Nothing else
|
|
101
|
+
|
|
102
|
+
The rest of the feature is code you either copy or do not. The framework range
|
|
103
|
+
moved to `^0.76.1`; if you upgrade the dependency without taking the feature,
|
|
104
|
+
[the framework's own guide](../../docs/guide/upgrading.md) is the one to follow.
|
|
105
|
+
|
|
63
106
|
## Released migration: 0.5.0
|
|
64
107
|
|
|
65
108
|
### the approval policy names a tool that no longer exists
|
|
@@ -1,4 +1,36 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"service": "board",
|
|
4
|
+
"action": "list",
|
|
5
|
+
"scope": "public",
|
|
6
|
+
"hasInput": false,
|
|
7
|
+
"hasOutput": true,
|
|
8
|
+
"inputShape": null,
|
|
9
|
+
"outputShape": "ce49fe198eb3412f",
|
|
10
|
+
"http": [
|
|
11
|
+
{
|
|
12
|
+
"method": "GET",
|
|
13
|
+
"path": "/api/board"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"tools": {}
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"service": "board",
|
|
20
|
+
"action": "post",
|
|
21
|
+
"scope": "public",
|
|
22
|
+
"hasInput": true,
|
|
23
|
+
"hasOutput": true,
|
|
24
|
+
"inputShape": "b1d8eb61c62cafed",
|
|
25
|
+
"outputShape": "ce49fe198eb3412f",
|
|
26
|
+
"http": [
|
|
27
|
+
{
|
|
28
|
+
"method": "POST",
|
|
29
|
+
"path": "/api/board"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"tools": {}
|
|
33
|
+
},
|
|
2
34
|
{
|
|
3
35
|
"service": "repository",
|
|
4
36
|
"action": "read",
|
|
@@ -1,16 +1,63 @@
|
|
|
1
1
|
import { env } from '@app/config';
|
|
2
2
|
import { repositoryRealtimeContract } from '@app/shared';
|
|
3
|
-
import {
|
|
3
|
+
import type { TrustFence } from 'stitchkit/server';
|
|
4
|
+
import { bindRealtimeServer, createSocketIOServer, createTrustFence } from 'stitchkit/server';
|
|
5
|
+
import { type BoardRuntime, openBoard } from './lib/board';
|
|
6
|
+
import { bindLive } from './lib/live';
|
|
7
|
+
import { createBoardService } from './transport/board-service';
|
|
4
8
|
import { createRepositoryService } from './transport/repository-service';
|
|
5
9
|
import { createSystemService } from './transport/system-service';
|
|
6
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The fence this deployment answers behind, or nothing.
|
|
13
|
+
*
|
|
14
|
+
* `trustedHosts` has no default and cannot have one: a fence compares the
|
|
15
|
+
* authority a request addressed against a list, and it cannot invent the list.
|
|
16
|
+
* Unset means no fence — honest for a checkout on a laptop, wrong for anything
|
|
17
|
+
* a network can reach.
|
|
18
|
+
*/
|
|
19
|
+
function createFence(): TrustFence | undefined {
|
|
20
|
+
if (!env.TRUSTED_HOSTS) return undefined;
|
|
21
|
+
const browserOrigin = env.CORS_ORIGIN ? [new URL(env.CORS_ORIGIN).host] : [];
|
|
22
|
+
return createTrustFence({
|
|
23
|
+
trustedHosts: env.TRUSTED_HOSTS.split(',').map((entry) => entry.trim()),
|
|
24
|
+
...(browserOrigin.length > 0 && { trustedOrigins: browserOrigin }),
|
|
25
|
+
onRefused: (refusal) => {
|
|
26
|
+
console.warn(
|
|
27
|
+
`Refused a ${refusal.lane} request: ${refusal.reason} (host ${refusal.host ?? 'absent'})`,
|
|
28
|
+
);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
7
33
|
export async function createSurface() {
|
|
34
|
+
const board: BoardRuntime = await openBoard();
|
|
35
|
+
const fence = createFence();
|
|
36
|
+
|
|
8
37
|
const socket = await createSocketIOServer({
|
|
9
38
|
cors: { origin: env.CORS_ORIGIN ?? [] },
|
|
39
|
+
// The socket's own admission point. `/socket.io/*` never reaches a lifecycle
|
|
40
|
+
// hook on either runtime, so a fence installed only in `hooks` would leave
|
|
41
|
+
// open the lane this app pushes its live data over.
|
|
42
|
+
...(fence && { allowRequest: fence.allowRequest }),
|
|
10
43
|
});
|
|
44
|
+
|
|
45
|
+
// Two bindings over one socket, deliberately. This example's own contract and
|
|
46
|
+
// the live one are separate declarations with separate event names, and
|
|
47
|
+
// merging them into a single registry here would mean editing the merge every
|
|
48
|
+
// time either side gains a topic.
|
|
11
49
|
const realtime = bindRealtimeServer(repositoryRealtimeContract, socket);
|
|
50
|
+
const live = bindLive(board, socket);
|
|
51
|
+
|
|
12
52
|
const repositoryService = createRepositoryService((snapshot) =>
|
|
13
53
|
realtime.emit('repository:refreshed', snapshot),
|
|
14
54
|
);
|
|
15
|
-
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
socket,
|
|
58
|
+
fence,
|
|
59
|
+
board,
|
|
60
|
+
hub: live.hub,
|
|
61
|
+
services: [createSystemService(), createBoardService(board), repositoryService],
|
|
62
|
+
};
|
|
16
63
|
}
|
|
@@ -4,6 +4,7 @@ import { BrandMark } from '@/components/brand-mark';
|
|
|
4
4
|
import { RepositorySummary } from '@/components/repository-summary';
|
|
5
5
|
import { LanguageSwitcher, ThemeToggle } from '@/components/system-controls';
|
|
6
6
|
import { buttonVariants } from '@/components/ui';
|
|
7
|
+
import { BoardPanel } from '@/features/board/board-panel';
|
|
7
8
|
import type { AppLocale } from '@/i18n/locales';
|
|
8
9
|
import { Link } from '@/i18n/navigation';
|
|
9
10
|
import { absoluteSiteUrl } from '@/lib/seo/metadata';
|
|
@@ -15,6 +16,7 @@ interface StarterPageProps {
|
|
|
15
16
|
applicationDescription: string;
|
|
16
17
|
heroTitle: string;
|
|
17
18
|
catalogueLabel: string;
|
|
19
|
+
realtimeOrigin?: string;
|
|
18
20
|
locale: AppLocale;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -62,6 +64,7 @@ export async function StarterPage({
|
|
|
62
64
|
applicationDescription,
|
|
63
65
|
heroTitle,
|
|
64
66
|
catalogueLabel,
|
|
67
|
+
realtimeOrigin,
|
|
65
68
|
locale,
|
|
66
69
|
}: StarterPageProps) {
|
|
67
70
|
const homeSeo = getSeoPage('home', locale);
|
|
@@ -140,6 +143,10 @@ export async function StarterPage({
|
|
|
140
143
|
</div>
|
|
141
144
|
</div>
|
|
142
145
|
|
|
146
|
+
<div className='mt-6 w-full max-w-xl'>
|
|
147
|
+
<BoardPanel realtimeOrigin={realtimeOrigin} />
|
|
148
|
+
</div>
|
|
149
|
+
|
|
143
150
|
<div className='mt-5 w-full'>
|
|
144
151
|
<RepositorySummary />
|
|
145
152
|
</div>
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
export * from './contracts/board';
|
|
2
|
+
export * from './contracts/live';
|
|
1
3
|
export * from './contracts/repository';
|
|
2
4
|
export * from './contracts/system';
|
|
3
5
|
export * from './realtime/repository';
|
|
6
|
+
export * from './schemas/board';
|
|
4
7
|
export * from './schemas/repository';
|
|
5
8
|
export * from './schemas/system';
|
|
@@ -105,6 +105,11 @@
|
|
|
105
105
|
"shape": "string",
|
|
106
106
|
"required": false
|
|
107
107
|
},
|
|
108
|
+
{
|
|
109
|
+
"name": "BOARD_STORE_PATH",
|
|
110
|
+
"shape": "string",
|
|
111
|
+
"required": false
|
|
112
|
+
},
|
|
108
113
|
{
|
|
109
114
|
"name": "CORS_ORIGIN",
|
|
110
115
|
"shape": "url",
|
|
@@ -179,6 +184,11 @@
|
|
|
179
184
|
"shape": "url",
|
|
180
185
|
"required": false
|
|
181
186
|
},
|
|
187
|
+
{
|
|
188
|
+
"name": "TRUSTED_HOSTS",
|
|
189
|
+
"shape": "string",
|
|
190
|
+
"required": false
|
|
191
|
+
},
|
|
182
192
|
{
|
|
183
193
|
"name": "WEB_PORT",
|
|
184
194
|
"shape": "integer",
|
package/package.json
CHANGED
|
Binary file
|
package/template/bun.lock
CHANGED
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
},
|
|
140
140
|
},
|
|
141
141
|
"catalog": {
|
|
142
|
-
"stitchkit": "^0.
|
|
142
|
+
"stitchkit": "^0.76.1",
|
|
143
143
|
},
|
|
144
144
|
"packages": {
|
|
145
145
|
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.63", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.29", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-D7BogSRg61QfTdr7AEcYn9h0I/e4QHvFXwIV1RW+DZZGJu1wSiX2cH06szZSYyKi7Eat50V4s4J8vggZUEs7eg=="],
|
|
@@ -1118,7 +1118,7 @@
|
|
|
1118
1118
|
|
|
1119
1119
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
|
1120
1120
|
|
|
1121
|
-
"stitchkit": ["stitchkit@0.
|
|
1121
|
+
"stitchkit": ["stitchkit@0.76.1", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@openrouter/ai-sdk-provider": "^3.0.0", "@opentelemetry/api": "^1.9.0", "@socket.io/bun-engine": "^0.1.1", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.3.14", "ai": "^7.0.0", "grammy": "^1.45.1", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5", "zod": "^4.4.3" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@openrouter/ai-sdk-provider", "@opentelemetry/api", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "grammy", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-+Qmm4CdyWUJJP0MLEQX3M2Ltf0WX5Wi5rEHdZGfWmyIn4SW//x+llQgm516sCyMyJthr9HOaYzxIIP27mBnCmQ=="],
|
|
1122
1122
|
|
|
1123
1123
|
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
|
1124
1124
|
|
|
@@ -1190,9 +1190,6 @@
|
|
|
1190
1190
|
|
|
1191
1191
|
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
|
1192
1192
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
1193
|
"@prisma/adapter-pg/@types/pg": ["@types/pg@8.21.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q=="],
|
|
1197
1194
|
|
|
1198
1195
|
"@prisma/adapter-pg/pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="],
|
package/template/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import { apiRole, appDeclaration } from '@app/config/declaration';
|
|
|
3
3
|
import { wrapInRequestContext } from 'stitchkit/observability';
|
|
4
4
|
import {
|
|
5
5
|
bindProcessSignals,
|
|
6
|
+
composeLifecycleHooks,
|
|
6
7
|
createServer,
|
|
7
8
|
generateOpenApiDocument,
|
|
8
9
|
openApiRoute,
|
|
@@ -14,7 +15,7 @@ import { createSurface } from './surface';
|
|
|
14
15
|
import { onError } from './transport/errors';
|
|
15
16
|
|
|
16
17
|
async function main(): Promise<void> {
|
|
17
|
-
const { services, socket } = await createSurface();
|
|
18
|
+
const { services, socket, fence, board, hub } = await createSurface();
|
|
18
19
|
const mcp = createMcpHandler({
|
|
19
20
|
serverInfo: {
|
|
20
21
|
name: appDeclaration.identity.slug,
|
|
@@ -36,7 +37,10 @@ async function main(): Promise<void> {
|
|
|
36
37
|
port: env.API_PORT,
|
|
37
38
|
hostname: env.BIND_HOST,
|
|
38
39
|
cors: env.CORS_ORIGIN ? { origin: env.CORS_ORIGIN } : undefined,
|
|
39
|
-
|
|
40
|
+
// The fence FIRST: hook composition stops at the first hook that answers,
|
|
41
|
+
// so a fence behind anything that can respond is a fence that sometimes
|
|
42
|
+
// does not run.
|
|
43
|
+
hooks: fence ? composeLifecycleHooks(fence.hooks, { onError }) : { onError },
|
|
40
44
|
logging: { format: env.LOG_FORMAT },
|
|
41
45
|
socket,
|
|
42
46
|
rawRoutes: [
|
|
@@ -66,6 +70,10 @@ async function main(): Promise<void> {
|
|
|
66
70
|
// SIGKILL — the one ending that runs no cleanup at all.
|
|
67
71
|
const cleanup = await closeWithinBudget([
|
|
68
72
|
{ name: 'MCP', close: () => mcp.close() },
|
|
73
|
+
// Stops re-reading for browsers that are already gone, then lets the
|
|
74
|
+
// board finish the writes it accepted and say how many it could not.
|
|
75
|
+
{ name: 'watch hub', close: async () => hub.close() },
|
|
76
|
+
{ name: 'board', close: () => board.close() },
|
|
69
77
|
{ name: 'database', close: () => prisma.$disconnect() },
|
|
70
78
|
]);
|
|
71
79
|
// Say how the drain ended. Without this an operator sees a process that
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { env } from '@app/config';
|
|
5
|
+
import { type Board, boardEvents, type Note, NoteSchema, type PostNote } from '@app/shared';
|
|
6
|
+
import {
|
|
7
|
+
defineKeyspace,
|
|
8
|
+
type OpenedKeyspace,
|
|
9
|
+
openKeyspace,
|
|
10
|
+
sqliteKeyspaceBackend,
|
|
11
|
+
} from 'stitchkit/application';
|
|
12
|
+
import type { EventPayloads } from 'stitchkit/live';
|
|
13
|
+
import { createEventBus, type EventBus } from 'stitchkit/server';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The board's notes: authoritative in memory, durable behind it.
|
|
17
|
+
*
|
|
18
|
+
* A keyspace and not a Prisma model, deliberately, and the boundary is worth
|
|
19
|
+
* knowing rather than guessing. A keyspace is for a **small, bounded set the
|
|
20
|
+
* whole process wants synchronously** — read it in a handler without awaiting,
|
|
21
|
+
* write it and know the write survived. The moment a thing wants queries,
|
|
22
|
+
* relations, pagination or unbounded growth, it is a database row and this is
|
|
23
|
+
* the wrong home for it.
|
|
24
|
+
*/
|
|
25
|
+
const notes = defineKeyspace('notes', {
|
|
26
|
+
schema: NoteSchema,
|
|
27
|
+
key: (note: Note) => note.id,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** The board, and the lifecycle its owner drives. */
|
|
31
|
+
export interface BoardRuntime {
|
|
32
|
+
/** Every announcement this role makes. Subscribed by the watch hub. */
|
|
33
|
+
readonly events: EventBus<EventPayloads<typeof boardEvents>>;
|
|
34
|
+
/** Synchronous, from memory. This is what makes a watched read cheap. */
|
|
35
|
+
read(): Board;
|
|
36
|
+
post(input: PostNote): Promise<Board>;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MOST_RECENT = 50;
|
|
41
|
+
|
|
42
|
+
export async function openBoard(): Promise<BoardRuntime> {
|
|
43
|
+
// Closed by the declaration: an undeclared topic is refused rather than
|
|
44
|
+
// delivered to nobody, and a topic can only be announced by the verb its
|
|
45
|
+
// declaration chose.
|
|
46
|
+
const events = createEventBus<EventPayloads<typeof boardEvents>>({
|
|
47
|
+
topics: boardEvents.topics,
|
|
48
|
+
onListenerError: (error, event) => {
|
|
49
|
+
console.error(`Listener for ${event} failed`, error);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// The directory too, not only the file. A starter that requires an operator to
|
|
54
|
+
// create a folder before it will boot is a starter that fails on the first run
|
|
55
|
+
// with an error about SQLite rather than about what is missing.
|
|
56
|
+
mkdirSync(dirname(env.BOARD_STORE_PATH), { recursive: true });
|
|
57
|
+
const database = new Database(env.BOARD_STORE_PATH, { create: true });
|
|
58
|
+
|
|
59
|
+
// Opened directly rather than declared to a kernel, because this role owns
|
|
60
|
+
// its own lifecycle: it binds its signals and closes what it holds in the
|
|
61
|
+
// order `cleanup.ts` lists. An application built on `createApplication` would
|
|
62
|
+
// declare `keyspaceResource(notes, …)` instead and let the graph order it.
|
|
63
|
+
const opened: OpenedKeyspace<Note> = await openKeyspace(notes, {
|
|
64
|
+
backend: sqliteKeyspaceBackend(notes, { database }),
|
|
65
|
+
// After durability and after memory — never before either. A subscriber
|
|
66
|
+
// woken by this reads immediately, and an announcement that arrived first
|
|
67
|
+
// would be a wake-up to the previous value.
|
|
68
|
+
onChanged: (change) => events.emit('board.changed', { noteId: change.key }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function read(): Board {
|
|
72
|
+
const all = [...opened.keyspace.list()].sort((left, right) =>
|
|
73
|
+
right.postedAt.localeCompare(left.postedAt),
|
|
74
|
+
);
|
|
75
|
+
return { notes: all.slice(0, MOST_RECENT), total: all.length };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
events,
|
|
80
|
+
read,
|
|
81
|
+
async post(input) {
|
|
82
|
+
await opened.keyspace.put({
|
|
83
|
+
id: crypto.randomUUID(),
|
|
84
|
+
body: input.body,
|
|
85
|
+
postedAt: new Date().toISOString(),
|
|
86
|
+
});
|
|
87
|
+
// The write resolved, so it is durable and in memory; this read cannot
|
|
88
|
+
// miss it. The watchers hear about it through the announcement above.
|
|
89
|
+
return read();
|
|
90
|
+
},
|
|
91
|
+
async close() {
|
|
92
|
+
opened.stopAdmission();
|
|
93
|
+
await opened.drain();
|
|
94
|
+
await opened.close();
|
|
95
|
+
database.close();
|
|
96
|
+
events.clear();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { boardContract, liveContract } from '@app/shared';
|
|
2
|
+
import { createWatchHub, type WatchHub } from 'stitchkit/application';
|
|
3
|
+
import { bindRealtimeServer, type RealtimeServerHandle } from 'stitchkit/server';
|
|
4
|
+
import type { BoardRuntime } from './board';
|
|
5
|
+
|
|
6
|
+
/** What makes the board’s answer stale — the one topic, named once. */
|
|
7
|
+
const INVALIDATED_BY = 'board.changed';
|
|
8
|
+
|
|
9
|
+
/** A read the browser is allowed to watch. */
|
|
10
|
+
const WATCHABLE = new Set([`${boardContract.meta.prefix}/list`]);
|
|
11
|
+
|
|
12
|
+
export interface LiveHandle {
|
|
13
|
+
readonly hub: WatchHub;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function bindLive(board: BoardRuntime, handle: RealtimeServerHandle): LiveHandle {
|
|
17
|
+
const realtime = bindRealtimeServer(liveContract, handle, {
|
|
18
|
+
onRejected: (rejected) => {
|
|
19
|
+
// A frame that failed its schema is reported where it was refused. Silence
|
|
20
|
+
// here is how two versions of an application go on talking past each other.
|
|
21
|
+
console.warn(`Realtime frame refused: ${rejected.event} (${rejected.reason})`);
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const hub = createWatchHub({
|
|
26
|
+
// The hub does not dispatch; it asks the application. One reader for the
|
|
27
|
+
// watched answer and the plain `GET` alike, so the two cannot disagree.
|
|
28
|
+
read: async (operation) => {
|
|
29
|
+
if (operation.action !== 'list') {
|
|
30
|
+
throw new Error(`${operation.service}.${operation.action} is not readable here`);
|
|
31
|
+
}
|
|
32
|
+
return board.read();
|
|
33
|
+
},
|
|
34
|
+
watchable: (operation) => WATCHABLE.has(`${operation.service}/${operation.action}`),
|
|
35
|
+
// The board's list takes no arguments, so its topic is the whole board.
|
|
36
|
+
// When a read *does* depend on an argument — one conversation of many — the
|
|
37
|
+
// topic is narrowed with it (`board.changed:${args.id}`), or one change wakes
|
|
38
|
+
// every watcher of the operation and pays for a read per watcher.
|
|
39
|
+
invalidatedBy: () => [INVALIDATED_BY],
|
|
40
|
+
subscribe: (topic, listener) => {
|
|
41
|
+
// The hub hands back the topics `invalidatedBy` returned, so this is a
|
|
42
|
+
// narrowing rather than a check — and it refuses rather than quietly
|
|
43
|
+
// returning a no-op, because an invalidation that silently never fires
|
|
44
|
+
// is a board that stops updating with nothing to show for it.
|
|
45
|
+
if (topic !== INVALIDATED_BY) {
|
|
46
|
+
throw new Error(`The board declares no invalidation topic named ${topic}`);
|
|
47
|
+
}
|
|
48
|
+
return board.events.on(topic, listener);
|
|
49
|
+
},
|
|
50
|
+
// A browser may watch a handful of questions, not an unbounded number: the
|
|
51
|
+
// limit is what turns a bug in a page into a refusal instead of a leak.
|
|
52
|
+
maxWatchesPerSubscriber: 8,
|
|
53
|
+
// A page that navigates away and back inside the window finds the answer
|
|
54
|
+
// warm and costs no read.
|
|
55
|
+
holdMs: 30_000,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
realtime.onConnection(({ raw, events }) => {
|
|
59
|
+
const watcher = hub.attach({
|
|
60
|
+
value: (frame) => events.emit('stitchkit.watch.value', frame),
|
|
61
|
+
state: (frame) => events.emit('stitchkit.watch.state', frame),
|
|
62
|
+
});
|
|
63
|
+
events.on('stitchkit.watch.open', (payload, acknowledge) => {
|
|
64
|
+
acknowledge(watcher.open(payload.key, payload.args));
|
|
65
|
+
});
|
|
66
|
+
events.on('stitchkit.watch.close', (payload) => watcher.close(payload.key));
|
|
67
|
+
// Every key this connection held is released here. Without it the hub goes
|
|
68
|
+
// on re-reading for a browser that closed its tab.
|
|
69
|
+
raw.on('disconnect', () => watcher.detach());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return { hub };
|
|
73
|
+
}
|
|
@@ -1,4 +1,36 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"service": "board",
|
|
4
|
+
"action": "list",
|
|
5
|
+
"scope": "public",
|
|
6
|
+
"hasInput": false,
|
|
7
|
+
"hasOutput": true,
|
|
8
|
+
"inputShape": null,
|
|
9
|
+
"outputShape": "ce49fe198eb3412f",
|
|
10
|
+
"http": [
|
|
11
|
+
{
|
|
12
|
+
"method": "GET",
|
|
13
|
+
"path": "/api/board"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"tools": {}
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"service": "board",
|
|
20
|
+
"action": "post",
|
|
21
|
+
"scope": "public",
|
|
22
|
+
"hasInput": true,
|
|
23
|
+
"hasOutput": true,
|
|
24
|
+
"inputShape": "b1d8eb61c62cafed",
|
|
25
|
+
"outputShape": "ce49fe198eb3412f",
|
|
26
|
+
"http": [
|
|
27
|
+
{
|
|
28
|
+
"method": "POST",
|
|
29
|
+
"path": "/api/board"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"tools": {}
|
|
33
|
+
},
|
|
2
34
|
{
|
|
3
35
|
"service": "system",
|
|
4
36
|
"action": "status",
|
|
@@ -1,13 +1,63 @@
|
|
|
1
1
|
import { env } from '@app/config';
|
|
2
|
-
import { createSocketIOServer } from 'stitchkit/server';
|
|
2
|
+
import { createSocketIOServer, createTrustFence, type TrustFence } from 'stitchkit/server';
|
|
3
|
+
import { type BoardRuntime, openBoard } from './lib/board';
|
|
4
|
+
import { bindLive } from './lib/live';
|
|
5
|
+
import { createBoardService } from './transport/board-service';
|
|
3
6
|
import { createSystemService } from './transport/system-service';
|
|
4
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The fence this deployment answers behind, or nothing.
|
|
10
|
+
*
|
|
11
|
+
* `trustedHosts` has no default and cannot have one: a fence can compare the
|
|
12
|
+
* authority a request addressed against a list, and it cannot invent the list.
|
|
13
|
+
* Unset means no fence, which is honest for a checkout on a laptop and wrong for
|
|
14
|
+
* anything a network can reach.
|
|
15
|
+
*
|
|
16
|
+
* The browser origin is the one already declared for CORS. They answer different
|
|
17
|
+
* questions — CORS says what a page may *read*, the fence says which authority
|
|
18
|
+
* this server agreed to *answer on* — but a deployment that names a cross-origin
|
|
19
|
+
* browser has named it once, and asking twice is asking for two answers that
|
|
20
|
+
* disagree.
|
|
21
|
+
*/
|
|
22
|
+
function createFence(): TrustFence | undefined {
|
|
23
|
+
if (!env.TRUSTED_HOSTS) return undefined;
|
|
24
|
+
const browserOrigin = env.CORS_ORIGIN ? [new URL(env.CORS_ORIGIN).host] : [];
|
|
25
|
+
return createTrustFence({
|
|
26
|
+
trustedHosts: env.TRUSTED_HOSTS.split(',').map((entry) => entry.trim()),
|
|
27
|
+
...(browserOrigin.length > 0 && { trustedOrigins: browserOrigin }),
|
|
28
|
+
onRefused: (refusal) => {
|
|
29
|
+
console.warn(
|
|
30
|
+
`Refused a ${refusal.lane} request: ${refusal.reason} (host ${refusal.host ?? 'absent'})`,
|
|
31
|
+
);
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
5
36
|
export async function createSurface() {
|
|
37
|
+
const board: BoardRuntime = await openBoard();
|
|
38
|
+
const fence = createFence();
|
|
39
|
+
|
|
6
40
|
// An EMPTY allow-list is same-origin: no origin is permitted to open a
|
|
7
41
|
// cross-origin socket, and no browser on this app's own origin needs one.
|
|
8
42
|
// `CORS_ORIGIN` is set only when the browser genuinely lives elsewhere.
|
|
9
43
|
// (Once the workspace targets a Stitchkit release where `cors` itself is
|
|
10
44
|
// optional, this becomes `undefined` and the empty array goes away.)
|
|
11
|
-
const socket = await createSocketIOServer({
|
|
12
|
-
|
|
45
|
+
const socket = await createSocketIOServer({
|
|
46
|
+
cors: { origin: env.CORS_ORIGIN ?? [] },
|
|
47
|
+
// The socket's own admission point, and the reason the fence has two halves:
|
|
48
|
+
// `/socket.io/*` never reaches a lifecycle hook on either runtime, so a
|
|
49
|
+
// fence installed only in `hooks` would leave open the lane this app pushes
|
|
50
|
+
// its live data over.
|
|
51
|
+
...(fence && { allowRequest: fence.allowRequest }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const live = bindLive(board, socket);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
socket,
|
|
58
|
+
fence,
|
|
59
|
+
board,
|
|
60
|
+
hub: live.hub,
|
|
61
|
+
services: [createSystemService(), createBoardService(board)],
|
|
62
|
+
};
|
|
13
63
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Board, boardContract } from '@app/shared';
|
|
2
|
+
import { implement } from 'stitchkit/server';
|
|
3
|
+
import type { BoardRuntime } from '../lib/board';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The board's two operations.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here knows it is watched. `list` is an ordinary handler that reads
|
|
9
|
+
* memory and returns; the watch hub calls this same implementation when a topic
|
|
10
|
+
* says the answer may have changed, so a watching browser and a plain `GET` can
|
|
11
|
+
* never disagree — there is one reader, not two.
|
|
12
|
+
*/
|
|
13
|
+
export function createBoardService(board: BoardRuntime) {
|
|
14
|
+
return implement(boardContract, {
|
|
15
|
+
list: (): Board => board.read(),
|
|
16
|
+
post: ({ input }) => board.post(input),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -73,6 +73,22 @@ const baseVariables = {
|
|
|
73
73
|
* requiring an origin there would be requiring knowledge of the place.
|
|
74
74
|
*/
|
|
75
75
|
CORS_ORIGIN: z.url().optional(),
|
|
76
|
+
/**
|
|
77
|
+
* Where the board keeps its notes. A file, so they survive a restart — which
|
|
78
|
+
* is the only thing that makes the board worth watching rather than a toy.
|
|
79
|
+
*/
|
|
80
|
+
BOARD_STORE_PATH: z.string().min(1).default('.data/board.sqlite'),
|
|
81
|
+
/**
|
|
82
|
+
* The authorities this API answers on, comma-separated — `host` or
|
|
83
|
+
* `host:port`.
|
|
84
|
+
*
|
|
85
|
+
* Set it and the trust fence is installed on BOTH lanes: HTTP before routing,
|
|
86
|
+
* and the realtime handshake, which never reaches a lifecycle hook on either
|
|
87
|
+
* runtime. Unset and there is no fence, which is honest for a local checkout
|
|
88
|
+
* and wrong for anything reachable from a network — a fence cannot invent the
|
|
89
|
+
* list of names it should answer to.
|
|
90
|
+
*/
|
|
91
|
+
TRUSTED_HOSTS: z.string().min(1).optional(),
|
|
76
92
|
};
|
|
77
93
|
|
|
78
94
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appIdentity } from '@app/config/app-identity';
|
|
2
2
|
import type { Metadata } from 'next';
|
|
3
3
|
import { getTranslations } from 'next-intl/server';
|
|
4
|
+
import { env } from '@/env';
|
|
4
5
|
import { LocaleSchema } from '@/i18n/locales';
|
|
5
6
|
import { createPageMetadata } from '@/lib/seo/metadata';
|
|
6
7
|
import { StarterPage } from './starter-page';
|
|
@@ -28,6 +29,7 @@ export default async function Page({ params }: { params: Promise<{ locale: strin
|
|
|
28
29
|
heroTitle={t('heroTitle')}
|
|
29
30
|
catalogueLabel={t('ui')}
|
|
30
31
|
locale={appLocale}
|
|
32
|
+
realtimeOrigin={env.PUBLIC_REALTIME_ORIGIN}
|
|
31
33
|
/>
|
|
32
34
|
);
|
|
33
35
|
}
|
|
@@ -3,6 +3,7 @@ import Image from 'next/image';
|
|
|
3
3
|
import { BrandMark } from '@/components/brand-mark';
|
|
4
4
|
import { LanguageSwitcher, ThemeToggle } from '@/components/system-controls';
|
|
5
5
|
import { buttonVariants } from '@/components/ui';
|
|
6
|
+
import { BoardPanel } from '@/features/board/board-panel';
|
|
6
7
|
import type { AppLocale } from '@/i18n/locales';
|
|
7
8
|
import { Link } from '@/i18n/navigation';
|
|
8
9
|
import { absoluteSiteUrl } from '@/lib/seo/metadata';
|
|
@@ -14,6 +15,7 @@ interface StarterPageProps {
|
|
|
14
15
|
applicationDescription: string;
|
|
15
16
|
heroTitle: string;
|
|
16
17
|
catalogueLabel: string;
|
|
18
|
+
realtimeOrigin?: string;
|
|
17
19
|
locale: AppLocale;
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -61,6 +63,7 @@ export async function StarterPage({
|
|
|
61
63
|
applicationDescription,
|
|
62
64
|
heroTitle,
|
|
63
65
|
catalogueLabel,
|
|
66
|
+
realtimeOrigin,
|
|
64
67
|
locale,
|
|
65
68
|
}: StarterPageProps) {
|
|
66
69
|
const homeSeo = getSeoPage('home', locale);
|
|
@@ -139,8 +142,13 @@ export async function StarterPage({
|
|
|
139
142
|
</div>
|
|
140
143
|
</div>
|
|
141
144
|
|
|
145
|
+
<div className='mt-6 w-full max-w-xl'>
|
|
146
|
+
<BoardPanel realtimeOrigin={realtimeOrigin} />
|
|
147
|
+
</div>
|
|
148
|
+
|
|
142
149
|
<p className='mt-5 text-sm text-muted-foreground'>
|
|
143
|
-
|
|
150
|
+
One vertical feature, from schema to transport to UI. Open a second tab and post
|
|
151
|
+
from it — nothing on this page polls.
|
|
144
152
|
</p>
|
|
145
153
|
</section>
|
|
146
154
|
</div>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type Board, BoardSchema, boardContract, liveContract } from '@app/shared';
|
|
4
|
+
import { createClient, createRealtimeClient } from 'stitchkit';
|
|
5
|
+
import { createWatchClient, type WatchStateFrame, watchTransport } from 'stitchkit/live';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One socket and one watch client for the life of the tab.
|
|
9
|
+
*
|
|
10
|
+
* Kept in a module local rather than in React state, and that is the whole
|
|
11
|
+
* point: the server shares one read between everyone asking the same question,
|
|
12
|
+
* and a client per component would defeat that from the other side. A component
|
|
13
|
+
* that remounts joins the subscription that exists.
|
|
14
|
+
*/
|
|
15
|
+
let shared: ReturnType<typeof connect> | undefined;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where the socket dials.
|
|
19
|
+
*
|
|
20
|
+
* The page's own origin unless a deployment says otherwise, and "otherwise" is
|
|
21
|
+
* handed down per request rather than compiled in — a `NEXT_PUBLIC_` value is
|
|
22
|
+
* substituted at build time, which would freeze one deployment's address into
|
|
23
|
+
* the artifact. `PUBLIC_REALTIME_ORIGIN` exists because a WebSocket upgrade does
|
|
24
|
+
* not survive a proxying route handler, so the two roles can share an origin for
|
|
25
|
+
* HTTP and still need to name the socket's.
|
|
26
|
+
*/
|
|
27
|
+
function connect(realtimeOrigin?: string) {
|
|
28
|
+
const realtime = createRealtimeClient(liveContract, {
|
|
29
|
+
url: realtimeOrigin ?? window.location.origin,
|
|
30
|
+
});
|
|
31
|
+
realtime.connect();
|
|
32
|
+
return {
|
|
33
|
+
realtime,
|
|
34
|
+
// `watchTransport` is the conversion the framework owns: a bound realtime
|
|
35
|
+
// client's `on` is generic over its contract, and TypeScript will not relate
|
|
36
|
+
// that to a transport interface. One call, and no cast in this application.
|
|
37
|
+
watch: createWatchClient(boardContract, {
|
|
38
|
+
transport: watchTransport(realtime),
|
|
39
|
+
// A tab that navigates away and back inside the window paints from memory
|
|
40
|
+
// and costs no read.
|
|
41
|
+
holdMs: 30_000,
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The typed HTTP client, for the one operation that writes. */
|
|
47
|
+
export const boardApi = createClient(boardContract, { baseUrl: '/api' });
|
|
48
|
+
|
|
49
|
+
export interface BoardWatch {
|
|
50
|
+
close(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Watch the board.
|
|
55
|
+
*
|
|
56
|
+
* `onState` is the honest half and is not optional in practice: `opening` means
|
|
57
|
+
* subscribed and nothing read yet, which is neither healthy nor broken, and
|
|
58
|
+
* `unavailable` carries the read's own words rather than a flag. A socket that
|
|
59
|
+
* drops says so here and resumes on its own when it comes back.
|
|
60
|
+
*/
|
|
61
|
+
export function watchBoard(
|
|
62
|
+
onValue: (board: Board) => void,
|
|
63
|
+
onState: (state: WatchStateFrame) => void,
|
|
64
|
+
realtimeOrigin?: string,
|
|
65
|
+
): BoardWatch {
|
|
66
|
+
shared ??= connect(realtimeOrigin);
|
|
67
|
+
const handle = shared.watch.list({});
|
|
68
|
+
const stop = handle.subscribe({
|
|
69
|
+
value: (value) => {
|
|
70
|
+
// Parsed at the boundary rather than asserted. A watched read carries the
|
|
71
|
+
// operation's output, so a value that fails its own schema means this tab
|
|
72
|
+
// and the server disagree about the contract — a half-finished deploy, or
|
|
73
|
+
// a cached bundle from before one. That is worth showing; a cast would
|
|
74
|
+
// render it as though it were fine and fail somewhere unrelated.
|
|
75
|
+
const parsed = BoardSchema.safeParse(value);
|
|
76
|
+
if (!parsed.success) {
|
|
77
|
+
onState({
|
|
78
|
+
key: { service: boardContract.meta.prefix, action: 'list', digest: '' },
|
|
79
|
+
phase: 'unavailable',
|
|
80
|
+
reason: 'source-error',
|
|
81
|
+
message:
|
|
82
|
+
'The board arrived in a shape this page does not know — reload to update it.',
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
onValue(parsed.data);
|
|
87
|
+
},
|
|
88
|
+
state: onState,
|
|
89
|
+
});
|
|
90
|
+
return { close: stop };
|
|
91
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Board } from '@app/shared';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
import type { WatchStateFrame } from 'stitchkit/live';
|
|
6
|
+
import {
|
|
7
|
+
Button,
|
|
8
|
+
Card,
|
|
9
|
+
CardContent,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle,
|
|
12
|
+
Input,
|
|
13
|
+
Spinner,
|
|
14
|
+
} from '@/components/ui';
|
|
15
|
+
import { boardApi, watchBoard } from './board-live';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The board, live.
|
|
19
|
+
*
|
|
20
|
+
* The demonstration is the second tab: open one, post from the other, and the
|
|
21
|
+
* note appears without this component asking for it. Nothing here polls, and
|
|
22
|
+
* nothing here refetches after a write — the server re-reads once, for everyone
|
|
23
|
+
* watching, and pushes the answer.
|
|
24
|
+
*/
|
|
25
|
+
export function BoardPanel({ realtimeOrigin }: { realtimeOrigin?: string }) {
|
|
26
|
+
const [board, setBoard] = useState<Board>();
|
|
27
|
+
const [state, setState] = useState<WatchStateFrame>();
|
|
28
|
+
const [draft, setDraft] = useState('');
|
|
29
|
+
const [posting, setPosting] = useState(false);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const watch = watchBoard(setBoard, setState, realtimeOrigin);
|
|
33
|
+
return () => watch.close();
|
|
34
|
+
}, [realtimeOrigin]);
|
|
35
|
+
|
|
36
|
+
async function post(event: React.FormEvent) {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
const body = draft.trim();
|
|
39
|
+
if (!body || posting) return;
|
|
40
|
+
setPosting(true);
|
|
41
|
+
try {
|
|
42
|
+
// The write returns the board too, so this tab does not wait for its own
|
|
43
|
+
// announcement to come back around. Every other tab learns from the push.
|
|
44
|
+
setBoard(await boardApi.post({ body }));
|
|
45
|
+
setDraft('');
|
|
46
|
+
} finally {
|
|
47
|
+
setPosting(false);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Card>
|
|
53
|
+
<CardHeader>
|
|
54
|
+
<CardTitle>Board</CardTitle>
|
|
55
|
+
</CardHeader>
|
|
56
|
+
<CardContent className='space-y-4'>
|
|
57
|
+
<form className='flex gap-2' onSubmit={post}>
|
|
58
|
+
<Input
|
|
59
|
+
aria-label='Note'
|
|
60
|
+
maxLength={140}
|
|
61
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
62
|
+
placeholder='Say something, then open a second tab'
|
|
63
|
+
value={draft}
|
|
64
|
+
/>
|
|
65
|
+
<Button disabled={posting || draft.trim().length === 0} type='submit'>
|
|
66
|
+
Post
|
|
67
|
+
</Button>
|
|
68
|
+
</form>
|
|
69
|
+
|
|
70
|
+
<BoardStatus board={board} state={state} />
|
|
71
|
+
|
|
72
|
+
<ul className='space-y-2'>
|
|
73
|
+
{board?.notes.map((note) => (
|
|
74
|
+
<li className='rounded-md border px-3 py-2 text-sm' key={note.id}>
|
|
75
|
+
{note.body}
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
</CardContent>
|
|
80
|
+
</Card>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Three states, not two.
|
|
86
|
+
*
|
|
87
|
+
* `opening` is subscribed and nothing read yet — early, not broken — and showing
|
|
88
|
+
* it as a failure tells a reader something is wrong when the truth is that it
|
|
89
|
+
* has not arrived. `unavailable` shows the read's own words, because "something
|
|
90
|
+
* went wrong" is the message that helps nobody.
|
|
91
|
+
*/
|
|
92
|
+
function BoardStatus({ board, state }: { board?: Board; state?: WatchStateFrame }) {
|
|
93
|
+
if (state?.phase === 'unavailable') {
|
|
94
|
+
return (
|
|
95
|
+
<p className='text-destructive text-sm'>
|
|
96
|
+
Not live: {state.message ?? state.reason ?? 'the server stopped answering'}
|
|
97
|
+
</p>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!board) {
|
|
101
|
+
return (
|
|
102
|
+
<p className='flex items-center gap-2 text-muted-foreground text-sm'>
|
|
103
|
+
<Spinner /> Waiting for the first read…
|
|
104
|
+
</p>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return (
|
|
108
|
+
<p className='text-muted-foreground text-sm'>
|
|
109
|
+
{board.total} {board.total === 1 ? 'note' : 'notes'}, live
|
|
110
|
+
</p>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createContractFactory } from 'stitchkit';
|
|
2
|
+
import { defineEvents } from 'stitchkit/live';
|
|
3
|
+
import { BoardChangedSchema, BoardSchema, PostNoteSchema } from '../schemas/board';
|
|
4
|
+
|
|
5
|
+
const { defineContract } = createContractFactory<'public'>({
|
|
6
|
+
toolExposure: 'explicit',
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The board: read it, add to it.
|
|
11
|
+
*
|
|
12
|
+
* Two operations, and the interesting one is `list` — it is a **watched read**.
|
|
13
|
+
* Nothing about that shows up here, which is the point: a watched read is an
|
|
14
|
+
* ordinary `GET` that the server happens to re-run when something it depends on
|
|
15
|
+
* changes. The contract stays the contract, and one caller can fetch it once
|
|
16
|
+
* while another watches it.
|
|
17
|
+
*/
|
|
18
|
+
export const boardContract = defineContract(
|
|
19
|
+
{ prefix: 'board', scope: 'public' },
|
|
20
|
+
{
|
|
21
|
+
list: {
|
|
22
|
+
method: 'GET',
|
|
23
|
+
path: '/',
|
|
24
|
+
desc: 'Read the board',
|
|
25
|
+
output: BoardSchema,
|
|
26
|
+
expose: ['HTTP'],
|
|
27
|
+
},
|
|
28
|
+
post: {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
path: '/',
|
|
31
|
+
desc: 'Add a note to the board',
|
|
32
|
+
input: PostNoteSchema,
|
|
33
|
+
output: BoardSchema,
|
|
34
|
+
expose: ['HTTP'],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What the server announces, declared beside what it can be asked.
|
|
41
|
+
*
|
|
42
|
+
* One topic, one payload schema, and a delivery mode — `emit`, because an
|
|
43
|
+
* announcement that the board changed is an observation and nothing waits on it.
|
|
44
|
+
* The wire name is the prefixed one, `board.changed`, and it is the only name
|
|
45
|
+
* this topic has: the short key below is where the full one is built.
|
|
46
|
+
*
|
|
47
|
+
* Declared in `shared` for the same reason the contract is: the server publishes
|
|
48
|
+
* it and the browser subscribes to it, and a topic described twice is a topic
|
|
49
|
+
* that will be published in one shape and parsed in another.
|
|
50
|
+
*/
|
|
51
|
+
export const boardEvents = defineEvents(
|
|
52
|
+
{ prefix: 'board' },
|
|
53
|
+
{ changed: { schema: BoardChangedSchema, mode: 'emit' } },
|
|
54
|
+
);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { toRealtimeContract, watchContract } from 'stitchkit/live';
|
|
2
|
+
import { boardEvents } from './board';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Everything this application says over its one socket.
|
|
6
|
+
*
|
|
7
|
+
* Its own announcements, projected from the same declaration both ends read,
|
|
8
|
+
* plus the protocol a watched read travels on. Declared in `shared` because the
|
|
9
|
+
* server binds it and the browser binds it, and a contract described twice is a
|
|
10
|
+
* contract that will be published in one shape and parsed in another — which is
|
|
11
|
+
* the failure `defineContract` exists to make impossible for requests and this
|
|
12
|
+
* makes impossible for announcements.
|
|
13
|
+
*/
|
|
14
|
+
export const liveContract = {
|
|
15
|
+
serverToClient: {
|
|
16
|
+
...toRealtimeContract(boardEvents).serverToClient,
|
|
17
|
+
...watchContract.serverToClient,
|
|
18
|
+
},
|
|
19
|
+
clientToServer: watchContract.clientToServer,
|
|
20
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One note on the board.
|
|
5
|
+
*
|
|
6
|
+
* Small on purpose: the board exists to show a value that several browsers
|
|
7
|
+
* watch at once, and everything about it that is not that gets in the way.
|
|
8
|
+
*/
|
|
9
|
+
export const NoteSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
id: z.uuid(),
|
|
12
|
+
/** Trimmed and bounded here, so the same limit holds for every transport. */
|
|
13
|
+
body: z.string().trim().min(1).max(140),
|
|
14
|
+
postedAt: z.iso.datetime({ offset: true }),
|
|
15
|
+
})
|
|
16
|
+
.strict();
|
|
17
|
+
export type Note = z.infer<typeof NoteSchema>;
|
|
18
|
+
|
|
19
|
+
/** What a reader of the board receives — newest first, and how many there are. */
|
|
20
|
+
export const BoardSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
notes: z.array(NoteSchema),
|
|
23
|
+
total: z.number().int().nonnegative(),
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
26
|
+
export type Board = z.infer<typeof BoardSchema>;
|
|
27
|
+
|
|
28
|
+
/** What a writer sends. The server owns `id` and `postedAt`; a client cannot set either. */
|
|
29
|
+
export const PostNoteSchema = z.object({ body: NoteSchema.shape.body }).strict();
|
|
30
|
+
export type PostNote = z.infer<typeof PostNoteSchema>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The payload of the announcement that the board changed.
|
|
34
|
+
*
|
|
35
|
+
* It carries the note's id rather than the note. An announcement says *that*
|
|
36
|
+
* something changed; the value comes from the read, which is the one place that
|
|
37
|
+
* decides what a reader is allowed to see. A payload that carried the row would
|
|
38
|
+
* be a second, unauthorised copy of the answer.
|
|
39
|
+
*/
|
|
40
|
+
export const BoardChangedSchema = z.object({ noteId: NoteSchema.shape.id }).strict();
|
|
41
|
+
export type BoardChanged = z.infer<typeof BoardChangedSchema>;
|
package/template/project.json
CHANGED
|
@@ -105,6 +105,11 @@
|
|
|
105
105
|
"shape": "string",
|
|
106
106
|
"required": false
|
|
107
107
|
},
|
|
108
|
+
{
|
|
109
|
+
"name": "BOARD_STORE_PATH",
|
|
110
|
+
"shape": "string",
|
|
111
|
+
"required": false
|
|
112
|
+
},
|
|
108
113
|
{
|
|
109
114
|
"name": "CORS_ORIGIN",
|
|
110
115
|
"shape": "url",
|
|
@@ -159,6 +164,11 @@
|
|
|
159
164
|
"shape": "url",
|
|
160
165
|
"required": false
|
|
161
166
|
},
|
|
167
|
+
{
|
|
168
|
+
"name": "TRUSTED_HOSTS",
|
|
169
|
+
"shape": "string",
|
|
170
|
+
"required": false
|
|
171
|
+
},
|
|
162
172
|
{
|
|
163
173
|
"name": "WEB_PORT",
|
|
164
174
|
"shape": "integer",
|