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.
- package/CHANGELOG.md +56 -0
- package/UPGRADING.md +66 -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 +10 -10
- package/template/.data/board.sqlite +0 -0
- package/template/biome.json +1 -1
- package/template/bun.lock +194 -179
- package/template/e2e/starter.spec.ts +7 -1
- package/template/package.json +14 -8
- package/template/packages/backend/package.json +5 -5
- 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/package.json +3 -3
- package/template/packages/config/src/variables.ts +16 -0
- package/template/packages/db/package.json +3 -3
- package/template/packages/frontend/package.json +15 -15
- 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/frontend/src/lib/query-client.test.ts +6 -0
- package/template/packages/frontend/src/lib/query-client.ts +25 -17
- package/template/packages/shared/package.json +2 -2
- 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/templates/agent/biome.json +1 -1
- 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 {
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
33
|
+
if (environmentManager.isServer()) return getServerQueryClient();
|
|
34
|
+
browserQueryClient ??= createQueryClient();
|
|
27
35
|
return browserQueryClient;
|
|
28
36
|
}
|
|
@@ -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",
|
|
@@ -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.
|
|
20
|
-
"ai": "^7.0.
|
|
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
|
|
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.
|
|
28
|
-
"@types/react": "^19.
|
|
27
|
+
"@types/bun": "^1.4.2",
|
|
28
|
+
"@types/react": "^19.3.0",
|
|
29
29
|
"typescript": "^7.0.2"
|
|
30
30
|
},
|
|
31
31
|
"engines": {
|