@kitr/kit-realtime 0.0.0-stage → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Platon Podkopov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,11 @@
1
- # Temporary Holding Version
1
+ # @kitr/kit-realtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Realtime kit.** Start a chat room: typed WebSocket channels that feed the query cache, rooms in the URL.
4
+
5
+ ```sh
6
+ pnpm dlx kitr new my-app --kit realtime
7
+ ```
8
+
9
+ Read the guide at https://kitr.dev/docs/cli.
10
+
11
+ Part of [kitr](https://kitr.dev), the TypeScript framework where everything is an add-on. MIT licensed.
package/dist/kitr.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { AddContext } from "@kitr/core/package";
2
+ /**
3
+ * `kitr new --kit realtime`: A chat room: typed WebSocket channels that feed the query cache, a messages controller, rooms in the URL.
4
+ */
5
+ declare const _default: {
6
+ readonly name: "realtime";
7
+ readonly kind: "kit";
8
+ readonly kit: {
9
+ readonly description: "A chat room: typed WebSocket channels that feed the query cache, a messages controller, rooms in the URL.";
10
+ readonly platforms: {
11
+ readonly site: {
12
+ readonly type: "web";
13
+ readonly options: {
14
+ readonly mode: "ssr";
15
+ };
16
+ };
17
+ readonly api: {
18
+ readonly type: "server";
19
+ };
20
+ };
21
+ readonly packages: ["query", "forms", "tailwind", "theme", "toast", "storage", "testing", "seo", "api", "realtime"];
22
+ readonly components: ["button", "input", "label", "card", "badge", "page-header"];
23
+ readonly files: (ctx: AddContext, name: string) => void;
24
+ };
25
+ };
26
+ export { _default as default };
package/dist/kitr.js ADDED
@@ -0,0 +1,407 @@
1
+ import { definePackage, displayName, removeFiles, startDocument, writeTemplate } from "@kitr/core/package";
2
+ // src/kitr.ts
3
+ const routeTable = (routes) => `// Generated by \`kitr api:sync\`. Do not edit.\nexport const routeTable = ${JSON.stringify(routes.map(([name, method, path]) => ({
4
+ name,
5
+ method,
6
+ path
7
+ })), null, 2)} as const\n`;
8
+ const apiClient = `import { createApiClient, registerRoutes } from '@kitr/api/client'
9
+ import type { ServerRoutes } from '#server/routes'
10
+ import { routeTable } from './routes.gen'
11
+
12
+ /** Typed from the server table: route names, params, bodies and outputs; routes.gen.ts (kitr api:sync) carries the paths at run time. */
13
+ export const api = createApiClient<ServerRoutes>({
14
+ baseUrl: typeof window === 'undefined' ? 'http://localhost:' + (process.env.PORT ?? 3000) : '',
15
+ })
16
+ registerRoutes(api, [...routeTable])
17
+ `;
18
+ const files = (ctx, name) => {
19
+ const brand = displayName(name);
20
+ removeFiles(ctx, "app/pages/about.tsx", "app/channels/example.ts");
21
+ ctx.scripts.add("api:sync", "kitr api:sync");
22
+ writeTemplate(ctx, "app/validators/chat.ts", `import { z } from 'zod'
23
+
24
+ export const sendMessageSchema = z.object({
25
+ room: z.string().min(1),
26
+ author: z.string().trim().min(1, 'Pick a name').max(32),
27
+ text: z.string().trim().min(1, 'Say something').max(500),
28
+ })
29
+ export type SendMessage = z.infer<typeof sendMessageSchema>
30
+
31
+ export const roomQuerySchema = z.object({ room: z.string().default('general') })
32
+
33
+ export interface Message {
34
+ id: string
35
+ room: string
36
+ author: string
37
+ text: string
38
+ at: string
39
+ }
40
+ `);
41
+ writeTemplate(ctx, "app/channels/chat.ts", `import { defineChannel } from '@kitr/realtime'
42
+ import type { Message } from '#app/validators/chat'
43
+
44
+ /**
45
+ * One definition for both ends: the server serves it from server/routes.ts and emits \`message\`
46
+ * from MessagesController; the client's \`effects\` refresh the room's query, so the list updates
47
+ * without any wiring in the component. The key is spelled out (\`api.messages.index.key(...)\`
48
+ * would pull the client into the server).
49
+ */
50
+ export const chatChannel = defineChannel<{ room: string }>({
51
+ name: 'chat',
52
+ events: {
53
+ message: {
54
+ effects: (_message: Message, { room }) => ({
55
+ invalidate: [['api', 'messages.index', {}, { room }]],
56
+ }),
57
+ },
58
+ },
59
+ authorize: () => true,
60
+ })
61
+ `);
62
+ writeTemplate(ctx, "server/controllers/messages-controller.ts", `import type { HttpContext } from '@kitr/api'
63
+ import { chatChannel } from '#app/channels/chat'
64
+ import {
65
+ type Message,
66
+ type SendMessage,
67
+ roomQuerySchema,
68
+ sendMessageSchema,
69
+ } from '#app/validators/chat'
70
+ import type { z } from 'zod'
71
+
72
+ type Ctx<Q = Record<string, string | string[]>, B = unknown> = HttpContext<Record<string, string>, Q, B>
73
+
74
+ /** Last 100 messages per room, in memory. */
75
+ const rooms = new Map<string, Message[]>()
76
+ const history = (room: string) => {
77
+ let list = rooms.get(room)
78
+ if (!list) {
79
+ list = [
80
+ {
81
+ id: 'welcome',
82
+ room,
83
+ author: 'kitr',
84
+ text: 'Welcome to #' + room + '. Messages here go over a typed WebSocket channel.',
85
+ at: new Date().toISOString(),
86
+ },
87
+ ]
88
+ rooms.set(room, list)
89
+ }
90
+ return list
91
+ }
92
+
93
+ export default class MessagesController {
94
+ static validation = {
95
+ index: { query: roomQuerySchema },
96
+ store: { body: sendMessageSchema },
97
+ }
98
+
99
+ async index({ query }: Ctx<z.infer<typeof roomQuerySchema>>) {
100
+ return history(query.room)
101
+ }
102
+
103
+ async store({ app, body, response }: Ctx<Record<string, string | string[]>, SendMessage>) {
104
+ const message: Message = {
105
+ id: crypto.randomUUID(),
106
+ room: body.room,
107
+ author: body.author,
108
+ text: body.text,
109
+ at: new Date().toISOString(),
110
+ }
111
+ const list = history(body.room)
112
+ list.push(message)
113
+ if (list.length > 100) list.shift()
114
+ // The socket server mounted by \`.ws()\` in server/routes.ts; everyone in the room gets the event.
115
+ app.container.use('realtime').use(chatChannel, { room: body.room }).emit('message', message)
116
+ response.status(201)
117
+ return message
118
+ }
119
+ }
120
+ `);
121
+ writeTemplate(ctx, "server/routes.ts", `import { defineRoutes } from '@kitr/kitr/app'
122
+ import api from '@kitr/api/routes'
123
+ import realtime from '@kitr/realtime/routes'
124
+ import { chatChannel } from '#app/channels/chat'
125
+ import HealthController from '#server/controllers/health-controller'
126
+ import MessagesController from '#server/controllers/messages-controller'
127
+
128
+ /** Endpoints and the chat socket, served by \`createServer({ routes })\` in server/main.ts. */
129
+ export const routes = defineRoutes([api, realtime], (root) =>
130
+ root.group('/api', (r) =>
131
+ r
132
+ .get('/health', [HealthController, 'show'], { name: 'health' })
133
+ .resource('messages', MessagesController, { only: ['index', 'store'] })
134
+ // WebSocket upgrades on /api/ws, next to the REST routes.
135
+ .ws('/ws', {
136
+ channels: [chatChannel],
137
+ // authorize: (request) => verify(request.headers.get('authorization')),
138
+ }),
139
+ ),
140
+ )
141
+
142
+ /** Pass to createApiClient<ServerRoutes>() on the client. */
143
+ export type ServerRoutes = typeof routes.$types
144
+ `);
145
+ writeTemplate(ctx, "app/api/routes.gen.ts", routeTable([
146
+ [
147
+ "health",
148
+ "GET",
149
+ "/api/health"
150
+ ],
151
+ [
152
+ "messages.index",
153
+ "GET",
154
+ "/api/messages"
155
+ ],
156
+ [
157
+ "messages.store",
158
+ "POST",
159
+ "/api/messages"
160
+ ]
161
+ ]));
162
+ writeTemplate(ctx, "app/api/client.ts", apiClient);
163
+ writeTemplate(ctx, "config/realtime.ts", `import { defineConfig } from '@kitr/realtime'
164
+
165
+ export default defineConfig({
166
+ path: '/api/ws',
167
+ transport: 'websocket',
168
+ })
169
+ `);
170
+ writeTemplate(ctx, "app/realtime.ts", `import { type SocketTransport, createSocketClient } from '@kitr/realtime'
171
+ import realtimeConfig from '#config/realtime'
172
+
173
+ let transport: SocketTransport | undefined
174
+
175
+ /** One connection per tab, opened on first use (so nothing connects during SSR). */
176
+ export function getSocket(): SocketTransport {
177
+ transport ??= createSocketClient({
178
+ url: () => window.location.origin.replace(/^http/, 'ws') + realtimeConfig.path,
179
+ })
180
+ return transport
181
+ }
182
+ `);
183
+ writeTemplate(ctx, "app/queries/chat.ts", `import { mutationFrom, queryFrom, subscriptionFrom } from '@kitr/query'
184
+ import { channel } from '@kitr/realtime'
185
+ import { api } from '#app/api/client'
186
+ import { chatChannel } from '#app/channels/chat'
187
+ import { getSocket } from '#app/realtime'
188
+
189
+ export const messagesQuery = queryFrom(api.messages.index.query())
190
+
191
+ export const sendMessage = mutationFrom(
192
+ api.messages.store.mutation({
193
+ // The socket event refreshes the list for everyone, including the sender; nothing to do here.
194
+ }),
195
+ )
196
+
197
+ /** Live events for a room; \`useSubscription(chatSubscription(), { room })\` in a client-only component. */
198
+ export const chatSubscription = () => subscriptionFrom(channel(getSocket(), chatChannel).subscription())
199
+ `);
200
+ writeTemplate(ctx, "app/storage.ts", `import { defineStorage } from '@kitr/storage'
201
+
202
+ /** The display name, remembered between visits. */
203
+ export const displayName = defineStorage('chat:name', { default: '' })
204
+ `);
205
+ writeTemplate(ctx, "app/document.tsx", startDocument({
206
+ imports: "",
207
+ head: ` meta: [
208
+ { charSet: 'utf-8' },
209
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
210
+ { title: '${brand}' },
211
+ ],`,
212
+ body: " <Outlet />"
213
+ }));
214
+ writeTemplate(ctx, "app/routes.ts", `import { defineRoutes } from '@kitr/kitr/app'
215
+ import router from '@kitr/router/routes'
216
+ import { ssrRoute } from '@kitr/seo'
217
+ import { head, RootDocument } from './document'
218
+
219
+ const rooms = ['general', 'random', 'help']
220
+
221
+ /** Every page of the app. \`kitr make:page <path>\` appends to the chain. */
222
+ export const routes = defineRoutes([router], (r) =>
223
+ r.root({ head, component: RootDocument }).page('/', {
224
+ validateSearch: (search: Record<string, unknown>) => ({
225
+ room: typeof search.room === 'string' && rooms.includes(search.room) ? search.room : 'general',
226
+ }),
227
+ // The socket only exists in the browser, so this page renders on the client.
228
+ ...ssrRoute(false),
229
+ lazy: () => import('./pages/chat'),
230
+ }),
231
+ )
232
+
233
+ export type AppRoutes = typeof routes.$types
234
+ `);
235
+ writeTemplate(ctx, "app/pages/chat.tsx", `import { useMutation, useSubscription, useSuspenseQuery } from '@kitr/query'
236
+ import { getRouteApi } from '@kitr/router'
237
+ import { useStorage } from '@kitr/storage/react'
238
+ import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
239
+ import { chatSubscription, messagesQuery, sendMessage } from '#app/queries/chat'
240
+ import { displayName } from '#app/storage'
241
+ import { Badge } from '#components/ui/badge'
242
+ import { Button } from '#components/ui/button'
243
+ import { Card, CardContent, CardHeader, CardTitle } from '#components/ui/card'
244
+ import { Input } from '#components/ui/input'
245
+
246
+ const rooms = ['general', 'random', 'help']
247
+ const route = getRouteApi('/')
248
+
249
+ export default function ChatPage() {
250
+ const { room } = route.useSearch()
251
+ const navigate = route.useNavigate()
252
+ return (
253
+ <main className="mx-auto flex min-h-screen max-w-3xl flex-col gap-4 p-4">
254
+ <header className="flex flex-wrap items-center gap-2">
255
+ <h1 className="mr-auto text-xl font-semibold">${brand}</h1>
256
+ {rooms.map((r) => (
257
+ <Button
258
+ key={r}
259
+ size="sm"
260
+ variant={r === room ? 'default' : 'outline'}
261
+ onClick={() => navigate({ search: { room: r } })}
262
+ >
263
+ #{r}
264
+ </Button>
265
+ ))}
266
+ </header>
267
+ <Room key={room} room={room} />
268
+ </main>
269
+ )
270
+ }
271
+
272
+ function Room({ room }: { room: string }) {
273
+ const { data: messages } = useSuspenseQuery(messagesQuery.options({ query: { room } }))
274
+ const subscription = useMemo(() => chatSubscription(), [])
275
+ const live = useSubscription(subscription, { room })
276
+ const send = useMutation(sendMessage)
277
+ const [author, setAuthor] = useStorage(displayName)
278
+ const [text, setText] = useState('')
279
+ const bottom = useRef<HTMLDivElement>(null)
280
+
281
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the list grows
282
+ useEffect(() => {
283
+ bottom.current?.scrollIntoView({ block: 'end' })
284
+ }, [messages.length])
285
+
286
+ const submit = async (event: FormEvent) => {
287
+ event.preventDefault()
288
+ if (!author.trim() || !text.trim()) return
289
+ await send.mutateAsync({ body: { room, author: author.trim(), text: text.trim() } })
290
+ setText('')
291
+ }
292
+
293
+ return (
294
+ <Card className="flex flex-1 flex-col">
295
+ <CardHeader className="flex flex-row items-center justify-between">
296
+ <CardTitle>#{room}</CardTitle>
297
+ <Badge variant={live.status === 'open' ? 'default' : 'secondary'}>
298
+ {live.status === 'open' ? 'live' : live.status}
299
+ </Badge>
300
+ </CardHeader>
301
+ <CardContent className="flex flex-1 flex-col gap-4">
302
+ <ol className="flex max-h-[60vh] flex-1 flex-col gap-2 overflow-y-auto text-sm">
303
+ {messages.map((message) => (
304
+ <li key={message.id} className="rounded-md bg-muted/50 px-3 py-2">
305
+ <span className="font-medium">{message.author}</span>
306
+ <span className="ml-2 text-xs text-muted-foreground">
307
+ {new Date(message.at).toLocaleTimeString()}
308
+ </span>
309
+ <p className="mt-0.5 whitespace-pre-wrap">{message.text}</p>
310
+ </li>
311
+ ))}
312
+ <div ref={bottom} />
313
+ </ol>
314
+ <form onSubmit={submit} className="grid gap-2 sm:grid-cols-[10rem_1fr_auto]">
315
+ <Input
316
+ aria-label="Your name"
317
+ placeholder="Your name"
318
+ value={author}
319
+ onChange={(e) => setAuthor(e.target.value)}
320
+ />
321
+ <Input
322
+ aria-label="Message"
323
+ placeholder={'Message #' + room}
324
+ value={text}
325
+ onChange={(e) => setText(e.target.value)}
326
+ />
327
+ <Button type="submit" disabled={send.isPending || !author.trim() || !text.trim()}>
328
+ Send
329
+ </Button>
330
+ </form>
331
+ {send.error ? <p className="text-sm text-destructive">{send.error.message}</p> : null}
332
+ </CardContent>
333
+ </Card>
334
+ )
335
+ }
336
+ `);
337
+ writeTemplate(ctx, "test/messages.test.ts", `// @vitest-environment node
338
+ import { describe, expect, it } from 'vitest'
339
+ import { server } from '#server/main'
340
+
341
+ describe('messages', () => {
342
+ it('stores a message and lists it for the room', async () => {
343
+ const post = await server.fetch(
344
+ new Request('http://app/api/messages', {
345
+ method: 'POST',
346
+ headers: { 'content-type': 'application/json' },
347
+ body: JSON.stringify({ room: 'tests', author: 'vitest', text: 'hello' }),
348
+ }),
349
+ )
350
+ expect(post.status).toBe(201)
351
+ const list = await server.fetch(new Request('http://app/api/messages?room=tests'))
352
+ const messages = (await list.json()) as Array<{ text: string }>
353
+ expect(messages.at(-1)?.text).toBe('hello')
354
+ })
355
+
356
+ it('rejects empty messages', async () => {
357
+ const res = await server.fetch(
358
+ new Request('http://app/api/messages', {
359
+ method: 'POST',
360
+ headers: { 'content-type': 'application/json' },
361
+ body: JSON.stringify({ room: 'tests', author: 'vitest', text: ' ' }),
362
+ }),
363
+ )
364
+ expect(res.status).toBe(422)
365
+ })
366
+ })
367
+ `);
368
+ };
369
+ /**
370
+ * `kitr new --kit realtime`: A chat room: typed WebSocket channels that feed the query cache, a messages controller, rooms in the URL.
371
+ */
372
+ var kitr_default = definePackage({
373
+ name: "realtime",
374
+ kind: "kit",
375
+ kit: {
376
+ description: "A chat room: typed WebSocket channels that feed the query cache, a messages controller, rooms in the URL.",
377
+ platforms: {
378
+ site: {
379
+ type: "web",
380
+ options: { mode: "ssr" }
381
+ },
382
+ api: { type: "server" }
383
+ },
384
+ packages: [
385
+ "query",
386
+ "forms",
387
+ "tailwind",
388
+ "theme",
389
+ "toast",
390
+ "storage",
391
+ "testing",
392
+ "seo",
393
+ "api",
394
+ "realtime"
395
+ ],
396
+ components: [
397
+ "button",
398
+ "input",
399
+ "label",
400
+ "card",
401
+ "badge",
402
+ "page-header"
403
+ ],
404
+ files
405
+ }
406
+ });
407
+ export { kitr_default as default };
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@kitr/kit-realtime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.0",
4
+ "description": "kitr new --kit realtime: A chat room: typed WebSocket channels that feed the query cache, a messages controller, rooms in the URL.",
5
+ "kitr": {
6
+ "name": "realtime",
7
+ "title": "Realtime kit",
8
+ "summary": "Start a chat room: typed WebSocket channels that feed the query cache, rooms in the URL.",
9
+ "category": "kits",
10
+ "runsOn": "both",
11
+ "kind": "kit",
12
+ "setup": "./kitr"
13
+ },
14
+ "type": "module",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/kitrjs/kitr.git",
19
+ "directory": "packages/kit-realtime"
20
+ },
21
+ "homepage": "https://kitr.dev/docs/cli",
22
+ "bugs": "https://github.com/kitrjs/kitr/issues",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "sideEffects": false,
27
+ "exports": {
28
+ "./kitr": {
29
+ "types": "./dist/kitr.d.ts",
30
+ "default": "./dist/kitr.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "keywords": [
38
+ "kitr",
39
+ "kitr-package",
40
+ "kitr-kit"
41
+ ],
42
+ "dependencies": {
43
+ "@kitr/core": "0.3.0"
44
+ },
45
+ "devDependencies": {
46
+ "tsdown": "^0.23.7",
47
+ "typescript": "^5.9.3",
48
+ "@types/node": "^22.19.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsdown",
52
+ "typecheck": "tsc -p tsconfig.json"
53
+ }
6
54
  }