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.
Files changed (37) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/UPGRADING.md +43 -0
  3. package/examples/repository/packages/backend/src/surface.snapshot.json +32 -0
  4. package/examples/repository/packages/backend/src/surface.ts +49 -2
  5. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +7 -0
  6. package/examples/repository/packages/shared/src/index.ts +3 -0
  7. package/examples/repository/project.json +10 -0
  8. package/package.json +1 -1
  9. package/template/.data/board.sqlite +0 -0
  10. package/template/README.md +11 -0
  11. package/template/_env.example +3 -0
  12. package/template/bun.lock +2 -5
  13. package/template/docs/ADDING_A_FEATURE.md +35 -11
  14. package/template/package.json +3 -2
  15. package/template/packages/backend/src/index.ts +10 -2
  16. package/template/packages/backend/src/lib/board.ts +99 -0
  17. package/template/packages/backend/src/lib/live.ts +73 -0
  18. package/template/packages/backend/src/surface.snapshot.json +32 -0
  19. package/template/packages/backend/src/surface.ts +53 -3
  20. package/template/packages/backend/src/transport/board-service.ts +18 -0
  21. package/template/packages/config/src/variables.ts +16 -0
  22. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -0
  23. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +9 -1
  24. package/template/packages/frontend/src/features/board/board-live.ts +91 -0
  25. package/template/packages/frontend/src/features/board/board-panel.tsx +112 -0
  26. package/template/packages/shared/src/contracts/board.ts +54 -0
  27. package/template/packages/shared/src/contracts/live.ts +20 -0
  28. package/template/packages/shared/src/index.ts +3 -0
  29. package/template/packages/shared/src/schemas/board.ts +41 -0
  30. package/template/project.json +10 -0
  31. package/template/scripts/check-authored.test.ts +43 -0
  32. package/template/scripts/check-authored.ts +51 -20
  33. package/template/scripts/dev.ts +6 -1
  34. package/template/scripts/guide-paths.test.ts +37 -0
  35. package/template/scripts/guide-paths.ts +66 -0
  36. package/template/scripts/local-env.test.ts +50 -1
  37. 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({ cors: { origin: env.CORS_ORIGIN ?? [] } });
12
- return { socket, services: [createSystemService()] };
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
- Add your first vertical feature from schema to transport and UI.
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
+ };
@@ -1,2 +1,5 @@
1
+ export * from './contracts/board';
2
+ export * from './contracts/live';
1
3
  export * from './contracts/system';
4
+ export * from './schemas/board';
2
5
  export * from './schemas/system';
@@ -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>;
@@ -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
+ });