create-stitchkit 0.5.0 → 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 +83 -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/README.md +11 -0
- package/template/_env.example +3 -0
- package/template/bun.lock +2 -5
- package/template/docs/ADDING_A_FEATURE.md +35 -11
- package/template/package.json +3 -2
- 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/template/scripts/check-authored.test.ts +43 -0
- package/template/scripts/check-authored.ts +51 -20
- package/template/scripts/dev.ts +6 -1
- package/template/scripts/guide-paths.test.ts +37 -0
- package/template/scripts/guide-paths.ts +66 -0
- package/template/scripts/local-env.test.ts +50 -1
- package/template/scripts/local-env.ts +53 -9
|
@@ -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",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { inspect } from './check-authored';
|
|
3
|
+
|
|
4
|
+
describe('check:authored — assertions', () => {
|
|
5
|
+
test('lets a const assertion through: it narrows and cannot launder a type', () => {
|
|
6
|
+
const source = [
|
|
7
|
+
"export const benchKeys = { jobs: ['bench', 'jobs'] as const };",
|
|
8
|
+
"export const modes = ['fast', 'full'] as const;",
|
|
9
|
+
'',
|
|
10
|
+
].join('\n');
|
|
11
|
+
expect(inspect('packages/shared/src/bench.ts', source)).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('still refuses a real assertion, and the finding names what to do instead', () => {
|
|
15
|
+
const findings = inspect(
|
|
16
|
+
'packages/shared/src/parse.ts',
|
|
17
|
+
'const parsed = JSON.parse(raw) as Payload;\n',
|
|
18
|
+
);
|
|
19
|
+
expect(findings).toHaveLength(1);
|
|
20
|
+
const [finding] = findings;
|
|
21
|
+
// Line and file, so it is navigable…
|
|
22
|
+
expect(finding).toContain('packages/shared/src/parse.ts:1');
|
|
23
|
+
// …and the remedy, so the reader does not have to search for the sanctioned way.
|
|
24
|
+
expect(finding).toContain('satisfies');
|
|
25
|
+
expect(finding).toContain('schema at the boundary');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('the angle-bracket form is refused too, and explicit any separately', () => {
|
|
29
|
+
expect(inspect('scripts/x.ts', 'const a = <Foo>bar;\n')).toHaveLength(1);
|
|
30
|
+
const anyFindings = inspect('scripts/y.ts', 'function f(a: any) { return a; }\n');
|
|
31
|
+
expect(anyFindings).toHaveLength(1);
|
|
32
|
+
expect(anyFindings[0]).toContain('explicit any');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a const assertion beside a real one reports only the real one', () => {
|
|
36
|
+
const findings = inspect(
|
|
37
|
+
'scripts/mixed.ts',
|
|
38
|
+
"const keys = ['a'] as const;\nconst value = raw as Payload;\n",
|
|
39
|
+
);
|
|
40
|
+
expect(findings).toHaveLength(1);
|
|
41
|
+
expect(findings[0]).toContain('scripts/mixed.ts:2');
|
|
42
|
+
});
|
|
43
|
+
});
|