create-stitchkit 0.5.1 → 0.6.1

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 (38) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/UPGRADING.md +66 -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 +10 -10
  9. package/template/.data/board.sqlite +0 -0
  10. package/template/biome.json +1 -1
  11. package/template/bun.lock +194 -179
  12. package/template/e2e/starter.spec.ts +7 -1
  13. package/template/package.json +14 -8
  14. package/template/packages/backend/package.json +5 -5
  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/package.json +3 -3
  22. package/template/packages/config/src/variables.ts +16 -0
  23. package/template/packages/db/package.json +3 -3
  24. package/template/packages/frontend/package.json +15 -15
  25. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -0
  26. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +9 -1
  27. package/template/packages/frontend/src/features/board/board-live.ts +91 -0
  28. package/template/packages/frontend/src/features/board/board-panel.tsx +112 -0
  29. package/template/packages/frontend/src/lib/query-client.test.ts +6 -0
  30. package/template/packages/frontend/src/lib/query-client.ts +25 -17
  31. package/template/packages/shared/package.json +2 -2
  32. package/template/packages/shared/src/contracts/board.ts +54 -0
  33. package/template/packages/shared/src/contracts/live.ts +20 -0
  34. package/template/packages/shared/src/index.ts +3 -0
  35. package/template/packages/shared/src/schemas/board.ts +41 -0
  36. package/template/project.json +10 -0
  37. package/templates/agent/biome.json +1 -1
  38. package/templates/agent/package.json +5 -5
@@ -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
+ }
@@ -19,4 +19,10 @@ describe('query dehydration policy', () => {
19
19
  expect(shouldDehydrate(successful)).toBe(true);
20
20
  expect(shouldDehydrate(pending)).toBe(true);
21
21
  });
22
+
23
+ test('retries a query once and never retries a mutation', () => {
24
+ const defaults = getQueryClient().getDefaultOptions();
25
+ expect(defaults.queries?.retry).toBe(1);
26
+ expect(defaults.mutations?.retry).toBe(false);
27
+ });
22
28
  });
@@ -1,28 +1,36 @@
1
- import { defaultShouldDehydrateQuery, isServer, QueryClient } from '@tanstack/react-query';
1
+ import {
2
+ defaultShouldDehydrateQuery,
3
+ environmentManager,
4
+ QueryClient,
5
+ type QueryClientConfig,
6
+ } from '@tanstack/react-query';
2
7
  import { cache } from 'react';
3
8
 
4
- function createQueryClient(): QueryClient {
5
- return new QueryClient({
6
- defaultOptions: {
7
- queries: { staleTime: 30_000, retry: 1 },
8
- dehydrate: {
9
- // `pending` queries dehydrate too: a server component may kick off a
10
- // prefetch without awaiting it, and streaming SSR hands the in-flight
11
- // promise to the client, which resumes it instead of refetching. The
12
- // default predicate would drop exactly those queries and reintroduce
13
- // the client-side loading flash.
14
- shouldDehydrateQuery: (query) =>
15
- defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
16
- },
9
+ // The template targets the published catalog release, so it cannot import
10
+ // `createQueryClientFactory` yet; the retry policy below is the part of that
11
+ // factory a released core already lets it state. UPGRADING names the cutover.
12
+ const config = {
13
+ defaultOptions: {
14
+ // One retry for queries, none for mutations — a mutation retried on a
15
+ // timeout may run twice; a query only reads.
16
+ queries: { staleTime: 30_000, retry: 1 },
17
+ mutations: { retry: false },
18
+ dehydrate: {
19
+ shouldDehydrateQuery: (query) =>
20
+ defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
17
21
  },
18
- });
22
+ },
23
+ } satisfies QueryClientConfig;
24
+
25
+ function createQueryClient(): QueryClient {
26
+ return new QueryClient(config);
19
27
  }
20
28
 
21
29
  const getServerQueryClient = cache(createQueryClient);
22
30
  let browserQueryClient: QueryClient | undefined;
23
31
 
24
32
  export function getQueryClient(): QueryClient {
25
- if (isServer) return getServerQueryClient();
26
- if (!browserQueryClient) browserQueryClient = createQueryClient();
33
+ if (environmentManager.isServer()) return getServerQueryClient();
34
+ browserQueryClient ??= createQueryClient();
27
35
  return browserQueryClient;
28
36
  }
@@ -13,10 +13,10 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "stitchkit": "catalog:",
16
- "zod": "^4.4.3"
16
+ "zod": "^4.6.5"
17
17
  },
18
18
  "devDependencies": {
19
- "@types/bun": "^1.4.0",
19
+ "@types/bun": "^1.4.2",
20
20
  "typescript": "^7.0.2"
21
21
  }
22
22
  }
@@ -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",
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
3
3
  "files": {
4
4
  "includes": ["**", "!!node_modules", "!!dist", "!!project.json", "!!**/.stitchkit"]
5
5
  },
@@ -16,16 +16,16 @@
16
16
  "build": "bun build src/index.ts --outdir dist --target bun --packages external"
17
17
  },
18
18
  "dependencies": {
19
- "@openrouter/ai-sdk-provider": "^3.0.0",
20
- "ai": "^7.0.87",
19
+ "@openrouter/ai-sdk-provider": "^3.1.0",
20
+ "ai": "^7.0.107",
21
21
  "stitchkit": "file:../../../core",
22
22
  "stitchkit-tui": "file:../../../tui",
23
- "zod": "4.5.4"
23
+ "zod": "4.6.5"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
27
- "@types/bun": "^1.4.0",
28
- "@types/react": "^19.2.14",
27
+ "@types/bun": "^1.4.2",
28
+ "@types/react": "^19.3.0",
29
29
  "typescript": "^7.0.2"
30
30
  },
31
31
  "engines": {