@weft-js/start 0.0.0-stage → 0.1.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 (75) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +41 -2
  3. package/package.json +107 -4
  4. package/src/app.mjs +93 -0
  5. package/src/auth/better-auth.ts +58 -0
  6. package/src/auth/drizzle.ts +17 -0
  7. package/src/auth.ts +47 -0
  8. package/src/boot.ts +44 -0
  9. package/src/builtin.tsx +19 -0
  10. package/src/bus/postgres.ts +91 -0
  11. package/src/bus.ts +48 -0
  12. package/src/capture.d.mts +1 -0
  13. package/src/capture.mjs +15 -0
  14. package/src/changes-sql.d.mts +14 -0
  15. package/src/changes-sql.mjs +99 -0
  16. package/src/changes.ts +253 -0
  17. package/src/channel-fake.ts +55 -0
  18. package/src/channel.ts +622 -0
  19. package/src/client.tsx +20 -0
  20. package/src/contract.mjs +312 -0
  21. package/src/data.ts +929 -0
  22. package/src/db/bun-sqlite.d.ts +10 -0
  23. package/src/db/changes-mysql.ts +86 -0
  24. package/src/db/changes-postgres.ts +159 -0
  25. package/src/db/changes-sqlite.ts +116 -0
  26. package/src/db/core.ts +63 -0
  27. package/src/db/index.ts +27 -0
  28. package/src/db/mysql.ts +67 -0
  29. package/src/db/postgres.ts +65 -0
  30. package/src/db/sqlite.ts +139 -0
  31. package/src/derive.d.mts +16 -0
  32. package/src/derive.mjs +140 -0
  33. package/src/dev-runtime.mjs +42 -0
  34. package/src/exec.ts +519 -0
  35. package/src/extension.ts +38 -0
  36. package/src/first-paint.ts +57 -0
  37. package/src/form.tsx +134 -0
  38. package/src/gate.css +6 -0
  39. package/src/hub.ts +822 -0
  40. package/src/index.ts +106 -0
  41. package/src/inline.mjs +2 -0
  42. package/src/inspect.mjs +280 -0
  43. package/src/link.ts +461 -0
  44. package/src/list-drizzle.ts +50 -0
  45. package/src/list.ts +224 -0
  46. package/src/listen.ts +28 -0
  47. package/src/live-fake.ts +42 -0
  48. package/src/live-target.mjs +147 -0
  49. package/src/live.ts +499 -0
  50. package/src/media.ts +43 -0
  51. package/src/migrations.mjs +19 -0
  52. package/src/mux-session.ts +6 -0
  53. package/src/mux.ts +135 -0
  54. package/src/og/DefaultCard.tsx +60 -0
  55. package/src/og/fonts.mjs +64 -0
  56. package/src/og/images.mjs +54 -0
  57. package/src/og/index.mjs +146 -0
  58. package/src/og/native.mjs +29 -0
  59. package/src/range.mjs +19 -0
  60. package/src/router.ts +149 -0
  61. package/src/routes.mjs +142 -0
  62. package/src/run.tsx +202 -0
  63. package/src/rust.ts +333 -0
  64. package/src/server.tsx +1021 -0
  65. package/src/shape.d.mts +3 -0
  66. package/src/shape.mjs +73 -0
  67. package/src/share-server.ts +208 -0
  68. package/src/share.ts +503 -0
  69. package/src/sheets.mjs +75 -0
  70. package/src/sidecar.mjs +503 -0
  71. package/src/socket.ts +389 -0
  72. package/src/stream.ts +13 -0
  73. package/src/verify.ts +251 -0
  74. package/src/view.tsx +47 -0
  75. package/src/vite.mjs +899 -0
package/src/socket.ts ADDED
@@ -0,0 +1,389 @@
1
+ // The server's half of `channel` and `sharedState` (research 32 §4): one socket per tab, many joins. `attach` knows
2
+ // nothing about crossws, so the same code runs behind `/_weft/ws` and behind @weft-js/core/test's fake. A room is a hub channel
3
+ // (`weft:c:<id>:<params>`), so rings, cursors, gaps and fan-out are the hub's, and a join listens before it replays.
4
+ //
5
+ // checks the upgrade: exact Origin (a missing one is refused), a wildcard in `origins` (R004).
6
+ // authorize every join, and again every `limits.session` and on a poke of `session:<user>`. A gone user is 4401.
7
+ // limits frame size (1009), a token bucket per socket and per channel (1008), joins, sockets, send queue (1013).
8
+ // shared state last writer wins by `(at, instance)`. Instances hold the value; a new listener asks on the bus.
9
+
10
+ import type { PublishOptions } from './channel'
11
+ import { enter, fns, FnError, isRedirect, runtime, stable, StartError, type Context, type Fn, type StandardSchemaV1 } from './data'
12
+ import { createScope, type Scope, type ScopeOptions } from './exec'
13
+ import { checkOrigins, hub, type Channel, type Ev, type Hub } from './hub'
14
+ import { SHARE, shareChannel } from './share-server'
15
+
16
+ /** A socket as this file needs it. `send` takes a whole frame. */
17
+ export interface Peer {
18
+ send(text: string): void
19
+ close(code: number, reason?: string): void
20
+ backlog(): number
21
+ }
22
+
23
+ export interface Attached {
24
+ message(text: string): void
25
+ close(): void
26
+ /** The session re-check, now. */
27
+ recheck(): Promise<void>
28
+ }
29
+
30
+ interface Join { n: number; fn: Fn; params: unknown; room?: Room; on?: (ev: Ev | null) => void; bucket: Bucket; gone?: boolean; chain: Promise<unknown> }
31
+ interface Bucket { tokens: number; at: number }
32
+ interface Sock {
33
+ k: string
34
+ peer: Peer
35
+ request: Request
36
+ options: ScopeOptions
37
+ ctx: Context
38
+ user?: string | undefined
39
+ joins: Map<number, Join>
40
+ bucket: Bucket
41
+ last: number
42
+ timers: ReturnType<typeof setTimeout>[]
43
+ }
44
+ interface Room { name: string; ch: Channel; refs: number; shared: boolean; on: (ev: Ev | null) => void }
45
+ interface Value { v: unknown; at: number; o: string }
46
+ interface Hubbed { socks: Set<Sock>; rooms: Map<string, Room>; values: Map<string, Value> }
47
+
48
+ /** A hub's sockets: a new hub (a dev reload, a test) starts empty. */
49
+ const states = new WeakMap<Hub, Hubbed>()
50
+ const VALUES = 10_000
51
+
52
+ function stateOf(h: Hub): Hubbed {
53
+ const s = states.get(h)
54
+ if (s) return s
55
+ const made: Hubbed = { socks: new Set(), rooms: new Map(), values: new Map() }
56
+ states.set(h, made)
57
+ h.closing.add(() => { for (const c of Array.from(made.socks)) gone(h, c, 1001) })
58
+ h.watch(tags => {
59
+ for (const t of tags) {
60
+ if (!t.startsWith('session:')) continue
61
+ for (const c of made.socks) if (c.user === t.slice(8)) void recheck(h, c)
62
+ }
63
+ })
64
+ return made
65
+ }
66
+
67
+ const unref = <T,>(t: T) => ((t as { unref?(): void } | null | undefined)?.unref?.(), t)
68
+ const roomName = (id: string, params: unknown) => `weft:c:${id}:${stable(params)}`
69
+ const SET = '$set', VALUE = '$v', ASK = '$?'
70
+
71
+ function take(b: Bucket, perSecond: number, burst: number) {
72
+ const now = Date.now()
73
+ b.tokens = Math.min(burst, b.tokens + ((now - b.at) * perSecond) / 1000)
74
+ b.at = now
75
+ if (b.tokens < 1) return false
76
+ b.tokens--
77
+ return true
78
+ }
79
+
80
+ async function check(schema: StandardSchemaV1 | undefined, value: unknown, what: string) {
81
+ if (!schema) return value
82
+ const r = await schema['~standard'].validate(value)
83
+ if (!r.issues) return r.value
84
+ throw new StartError('Q002', `${what} was refused: ${r.issues.map(i => i.message).join('; ')}`, 'send what the schema accepts', { status: 400, issues: r.issues.map(i => ({ message: i.message, path: i.path?.map(p => (typeof p === 'object' ? p.key : p)) })) })
85
+ }
86
+
87
+ const send = (c: Sock, out: unknown[]) => {
88
+ c.peer.send(JSON.stringify(out))
89
+ if (c.peer.backlog() > hub().limits.queue) gone(hub(), c, 1013)
90
+ }
91
+
92
+ const refuse = (c: Sock, n: number, e: { code?: string | undefined; message: string }) => send(c, ['e', n, { code: e.code, message: e.message }])
93
+
94
+ type Authorize = ((args: { ctx: Context; params: unknown }) => unknown) & Record<'read' | 'write', (args: { ctx: Context; key: unknown }) => unknown>
95
+ const allowed = async (fn: Fn, ctx: Context, params: unknown, write?: boolean): Promise<boolean> => {
96
+ const a = fn.def.authorize as Authorize
97
+ return !!(await (fn.kind === 'shared' ? a[write ? 'write' : 'read']({ ctx, key: params }) : a({ ctx, params })))
98
+ }
99
+
100
+ const denied = (fn: Fn) => new StartError('R011', `\`${fn.$id}\`: \`authorize\` refused this client`, 'sign in, or check `authorize`')
101
+
102
+ /** Fan-out of a room's messages to one join. */
103
+ function deliver(c: Sock, j: Join, h: Hub, ev: Ev) {
104
+ const m = (ev.p ??= JSON.parse(ev.raw)) as { f?: string; x?: 1; t?: string; n: string; m: unknown }
105
+ if (m.n === ASK || (m.x && m.f === c.k) || (m.t && m.t !== c.k)) return
106
+ send(c, ['m', j.n, h.cursorOf(ev.seq), m.n, m.m])
107
+ }
108
+
109
+ function room(h: Hub, st: Hubbed, name: string, shared: boolean): Room {
110
+ let r = st.rooms.get(name)
111
+ if (r) return r.refs++, r
112
+ const ch = h.channel(name)
113
+ const on = (ev: Ev | null) => {
114
+ if (!ev || !shared) return
115
+ const m = (ev.p ??= JSON.parse(ev.raw)) as { n: string; m: never }
116
+ if (m.n === VALUE) store(st, name, m.m)
117
+ else if (m.n === ASK) {
118
+ const v = st.values.get(name)
119
+ if (v) void h.bus.publish(name, JSON.stringify({ n: VALUE, m: v }))
120
+ }
121
+ }
122
+ ch.listeners.add(on)
123
+ r = { name, ch, refs: 1, shared, on }
124
+ st.rooms.set(name, r)
125
+ if (shared && !st.values.has(name)) void ch.ready.then(() => h.bus.publish(name, JSON.stringify({ n: ASK })))
126
+ return r
127
+ }
128
+
129
+ function leaveRoom(h: Hub, st: Hubbed, r: Room) {
130
+ if (--r.refs) return
131
+ st.rooms.delete(r.name)
132
+ h.unlisten(r.ch, r.on)
133
+ }
134
+
135
+ function store(st: Hubbed, name: string, v: Value) {
136
+ const had = st.values.get(name)
137
+ if (had && (had.at > v.at || (had.at === v.at && had.o >= v.o))) return
138
+ st.values.delete(name) // last in the map: the oldest key goes first
139
+ st.values.set(name, v)
140
+ if (st.values.size > VALUES) st.values.delete(st.values.keys().next().value!)
141
+ }
142
+
143
+ async function join(h: Hub, c: Sock, n: number, id: string, params: unknown, cursor?: string) {
144
+ const st = stateOf(h)
145
+ if (c.joins.has(n) || typeof n !== 'number') return
146
+ const fn = id === SHARE ? shareChannel(h) : fns.get(id)
147
+ if (fn?.kind !== 'channel' && fn?.kind !== 'shared') return refuse(c, n, new StartError('Q009', `no channel \`${id}\` (the page is older than the server): reload`))
148
+ if (c.joins.size >= h.limits.subscriptions) return refuse(c, n, new StartError('R009', `this socket already has ${h.limits.subscriptions} joins`, 'join fewer rooms, or raise `limits.subscriptions`'))
149
+ const j: Join = { n, fn, params, bucket: { tokens: h.limits.burst, at: Date.now() }, chain: Promise.resolve() }
150
+ c.joins.set(n, j)
151
+ try {
152
+ j.params = await check(fn.kind === 'shared' ? fn.def.key : fn.def.params, params, `\`${id}\`'s params`)
153
+ if (!(await allowed(fn, c.ctx, j.params))) throw denied(fn)
154
+ } catch (e) {
155
+ c.joins.delete(n)
156
+ return refuse(c, n, known(c, id, e))
157
+ }
158
+ if (j.gone) return
159
+ const name = roomName(id, j.params)
160
+ const r = (j.room = room(h, st, name, fn.kind === 'shared'))
161
+ const pending: Ev[] = []
162
+ let live = false, lost = false
163
+ // Listen, then replay: a message published in between can't be lost (R006's rule).
164
+ j.on = ev => {
165
+ if (!ev) return live ? send(c, ['r', n, h.position()]) : void (lost = true)
166
+ if (live) deliver(c, j, h, ev)
167
+ else pending.push(ev)
168
+ }
169
+ r.ch.listeners.add(j.on)
170
+ try {
171
+ await r.ch.ready
172
+ } catch (e) {
173
+ leave(h, c, j)
174
+ return refuse(c, n, new StartError('R011', `\`${id}\`: the bus is down`, undefined))
175
+ }
176
+ if (j.gone) return
177
+ const back = cursor ? h.replay(r.ch, cursor) : []
178
+ send(c, ['j', n, h.position()])
179
+ if (!back || lost) {
180
+ send(c, ['r', n, h.position()])
181
+ pending.length = 0
182
+ } else {
183
+ const seen = new Set(back.map(e => e.seq))
184
+ for (const ev of [...back, ...pending.filter(e => !seen.has(e.seq))].sort((a, b) => a.seq - b.seq)) deliver(c, j, h, ev)
185
+ }
186
+ live = true
187
+ const v = st.values.get(name)
188
+ if (fn.kind === 'shared' && v) send(c, ['m', n, h.position(), VALUE, v])
189
+ }
190
+
191
+ function leave(h: Hub, c: Sock, j: Join) {
192
+ j.gone = true
193
+ c.joins.delete(j.n)
194
+ if (j.room && j.on) {
195
+ j.room.ch.listeners.delete(j.on)
196
+ leaveRoom(h, stateOf(h), j.room)
197
+ if (j.fn.def.left) void left(c, j)
198
+ }
199
+ }
200
+
201
+ const bound = (c: Sock, j: Join) => ({ params: j.params, publish: (n: string, m: unknown, o?: PublishOptions) => publish(j.fn, j.params, n, m, { ...o, from: c.k }) })
202
+
203
+ /** To this connection, past the bus: only `share` uses it, and it has already picked who hears what. */
204
+ const reply = (h: Hub, c: Sock, j: Join) => (name: string, msg: unknown) => { if (!j.gone) send(c, ['m', j.n, h.position(), name, msg]) }
205
+
206
+ /** `def.left`, once per join that got in, however it ended. Nothing is listening for a failure, so it's reported. */
207
+ async function left(c: Sock, j: Join) {
208
+ const scope: Scope = { ...createScope(c.request, c.options), ctx: () => Promise.resolve(c.ctx) }
209
+ try {
210
+ await enter(scope, () => j.fn.def.left!({ id: c.k, ctx: c.ctx, params: j.params, room: bound(c, j) }))
211
+ } catch (e) {
212
+ known(c, j.fn.$id, e)
213
+ }
214
+ }
215
+
216
+ const known = (c: Sock, id: string, e: unknown): { code?: string | undefined; message: string; issues?: unknown } => {
217
+ if (e instanceof StartError || e instanceof FnError) return { code: e.code, message: e.message, ...(e.issues && { issues: e.issues }) }
218
+ c.options.onDiagnostic?.({ code: 'E_RT', severity: 'error', message: `${id}: ${e instanceof Error ? e.stack ?? e.message : String(e)}` })
219
+ return { message: c.options.dev && e instanceof Error ? e.message : 'Message failed.' }
220
+ }
221
+
222
+ /** A message from the client: validated, its handler run in a fresh scope, the ack sent. In order within a join. */
223
+ async function run(h: Hub, c: Sock, j: Join, ack: number, name: string, msg: unknown) {
224
+ const fn = j.fn
225
+ try {
226
+ if (fn.kind === 'shared') {
227
+ if (name !== SET) throw new StartError('Q002', `\`${fn.$id}\` takes no message \`${name}\``)
228
+ const v = await check(fn.def.value, msg, `\`${fn.$id}\`'s value`)
229
+ if (!(await allowed(fn, c.ctx, j.params, true))) throw denied(fn)
230
+ const st = stateOf(h), key = j.room!.name
231
+ const stamp: Value = { v, at: Math.max(Date.now(), (st.values.get(key)?.at ?? 0) + 1), o: h.epoch }
232
+ store(st, key, stamp)
233
+ await h.bus.publish(key, JSON.stringify({ f: c.k, n: VALUE, m: stamp }))
234
+ } else {
235
+ const schema = Object.hasOwn(fn.def.client ?? {}, name) ? fn.def.client![name] : undefined
236
+ const handler = Object.hasOwn(fn.def.on ?? {}, name) ? fn.def.on![name] : undefined
237
+ if (!schema || !handler) throw new StartError('Q002', `\`${fn.$id}\` takes no message \`${name}\``, 'name one of `client`, with a handler in `on`')
238
+ const valid = await check(schema, msg, `\`${fn.$id}.${name}\``)
239
+ const scope: Scope = { ...createScope(c.request, c.options), ctx: () => Promise.resolve(c.ctx) }
240
+ await enter(scope, () => handler({ msg: valid, id: c.k, ctx: c.ctx, params: j.params, room: bound(c, j), reply: reply(h, c, j) } as never))
241
+ }
242
+ if (!j.gone) send(c, ['a', j.n, ack])
243
+ } catch (e) {
244
+ if (isRedirect(e)) return void (!j.gone && send(c, ['a', j.n, ack]))
245
+ if (!j.gone) send(c, ['a', j.n, ack, known(c, fn.$id, e)])
246
+ }
247
+ }
248
+
249
+ function frame(h: Hub, c: Sock, text: string) {
250
+ if (!stateOf(h).socks.has(c)) return // closing: what was already on the wire
251
+ c.last = Date.now()
252
+ if (text.length > h.limits.body) {
253
+ c.options.onDiagnostic?.({ code: 'R008', severity: 'error', message: `R008: a channel frame of ${text.length} bytes; the limit is ${h.limits.body}` })
254
+ return gone(h, c, 1009, 'too big')
255
+ }
256
+ let f: unknown
257
+ try { f = JSON.parse(text) } catch { return gone(h, c, 1007, 'not JSON') }
258
+ if (!Array.isArray(f)) return gone(h, c, 1007, 'not a frame')
259
+ if (f[0] === 'p') return send(c, ['p'])
260
+ if (!take(c.bucket, h.limits.perSecond, h.limits.burst)) {
261
+ c.options.onDiagnostic?.({ code: 'R008', severity: 'error', message: `R008: a client sent over ${h.limits.perSecond} messages a second` })
262
+ return gone(h, c, 1008, 'rate limit')
263
+ }
264
+ if (f[0] === 'j' && typeof f[2] === 'string') return void join(h, c, f[1], f[2], f[3], typeof f[4] === 'string' ? f[4] : undefined)
265
+ const j = c.joins.get(f[1])
266
+ if (f[0] === 'l') return (j && leave(h, c, j))
267
+ if (f[0] !== 'm' || typeof f[2] !== 'number' || typeof f[3] !== 'string') return
268
+ if (!j) return send(c, ['a', f[1], f[2], { code: 'Q009', message: 'not joined' }])
269
+ const per = j.fn.def.limits?.perSecond
270
+ if (per && !take(j.bucket, per, per * 2)) {
271
+ c.options.onDiagnostic?.({ code: 'R008', severity: 'error', message: `R008: a client sent over ${per} messages a second to \`${j.fn.$id}\`` })
272
+ return gone(h, c, 1008, 'rate limit')
273
+ }
274
+ j.chain = j.chain.then(() => run(h, c, j, f[2], f[3], f[4]))
275
+ }
276
+
277
+ function gone(h: Hub, c: Sock, code: number, reason?: string) {
278
+ const st = stateOf(h)
279
+ if (!st.socks.delete(c)) return
280
+ for (const t of c.timers) clearTimeout(t)
281
+ for (const j of Array.from(c.joins.values())) leave(h, c, j)
282
+ c.peer.close(code, reason)
283
+ }
284
+
285
+ /** The session again: `ctx`, then `authorize` for every join. A user who's gone closes the socket; 4401 tells the client. */
286
+ async function recheck(h: Hub, c: Sock) {
287
+ const ctx = await createScope(c.request, c.options).ctx().catch(() => ({}) as Context)
288
+ if (!stateOf(h).socks.has(c)) return
289
+ if (c.user !== undefined && (ctx as { user?: { id?: string } }).user?.id !== c.user) return gone(h, c, 4401, 'session')
290
+ c.ctx = ctx
291
+ for (const j of Array.from(c.joins.values())) {
292
+ if (await allowed(j.fn, ctx, j.params).catch(() => false)) {
293
+ await j.fn.def.recheck?.({ id: c.k, ctx, reply: reply(h, c, j) })
294
+ continue
295
+ }
296
+ leave(h, c, j)
297
+ refuse(c, j.n, denied(j.fn))
298
+ }
299
+ }
300
+
301
+ /** Open: the hello, the heartbeat watch and the session timer. */
302
+ /** `line`: one tab on a shared socket (mux.ts), which `limits.connections` counted already. */
303
+ export async function attach(peer: Peer, request: Request, scope: Scope, h = hub(), line = false): Promise<Attached> {
304
+ const st = stateOf(h)
305
+ const ctx = await scope.ctx()
306
+ const user = (ctx as { user?: { id?: string } }).user?.id
307
+ const c: Sock = { k: crypto.randomUUID(), peer, request, options: scope.options, ctx, user, joins: new Map(), bucket: { tokens: h.limits.burst, at: Date.now() }, last: Date.now(), timers: [] }
308
+ if (!line && user !== undefined && [...st.socks].filter(s => s.user === user).length >= h.limits.connections) {
309
+ peer.close(1008, 'too many sockets')
310
+ scope.options.onDiagnostic?.({ code: 'R009', severity: 'error', message: `R009: user \`${user}\` already has ${h.limits.connections} sockets` })
311
+ return { message() {}, close() {}, recheck: async () => {} }
312
+ }
313
+ st.socks.add(c)
314
+ send(c, ['h', { k: c.k, e: h.epoch, b: h.build ?? '' }])
315
+ let ticks = 0
316
+ const every = Math.max(1, Math.round(h.limits.session / h.limits.heartbeat))
317
+ c.timers.push(unref(setInterval(() => {
318
+ if (Date.now() - c.last > h.limits.heartbeat * 3) return gone(h, c, 1001, 'idle')
319
+ if (++ticks % every === 0) void recheck(h, c)
320
+ }, h.limits.heartbeat)))
321
+ if (h.maxDuration) c.timers.push(unref(setTimeout(() => gone(h, c, 1012, 'max duration'), Math.max(1, h.maxDuration - 10) * 1000)))
322
+ return { message: text => frame(h, c, text), close: () => gone(h, c, 1000), recheck: () => recheck(h, c) }
323
+ }
324
+
325
+ /** `room.publish` and `room.in(params).publish`. */
326
+ export async function publish(fn: Fn, params: unknown, name: string, message: unknown, o: PublishOptions & { from?: string } = {}) {
327
+ const h = hub()
328
+ const valid = await check(fn.kind === 'shared' ? fn.def.key : fn.def.params, params, `\`${fn.$id}\`'s params`)
329
+ if (h.dev) {
330
+ const schema = Object.hasOwn(fn.def.server ?? {}, name) ? fn.def.server![name] : undefined
331
+ if (!schema) throw new StartError('C002', `\`${fn.$id}\` has no server message \`${name}\``, 'add it to `server`', { status: 500 })
332
+ const r = await schema['~standard'].validate(message)
333
+ if (r.issues) throw new StartError('C002', `\`${fn.$id}.${name}\` was published with a message its schema rejects: ${r.issues.map(i => i.message).join('; ')}`, 'fix the message, or the schema', { status: 500 })
334
+ }
335
+ // `v` first: the hub reads it off the front of the string, to leave the ring alone.
336
+ const raw = JSON.stringify({ ...(o.volatile && { v: 1 }), ...(o.from && { f: o.from }), ...(o.exceptSelf && { x: 1 }), ...(o.to && { t: o.to }), n: name, m: message })
337
+ if (raw.length > h.limits.message) throw new StartError('R008', `a \`${fn.$id}\` message is ${raw.length} bytes; a message may be ${h.limits.message}`, 'publish less, or an id subscribers fetch')
338
+ await h.bus.publish(roomName(fn.$id, valid), raw)
339
+ }
340
+
341
+ /** A shared value written by the server: `deckSlide.in(key).set(3)`. */
342
+ export async function setShared(fn: Fn, key: unknown, value: unknown) {
343
+ const h = hub()
344
+ const valid = await check(fn.def.key, key, `\`${fn.$id}\`'s key`)
345
+ const v = await check(fn.def.value, value, `\`${fn.$id}\`'s value`)
346
+ const st = stateOf(h), name = roomName(fn.$id, valid)
347
+ const stamp: Value = { v, at: Math.max(Date.now(), (st.values.get(name)?.at ?? 0) + 1), o: h.epoch }
348
+ store(st, name, stamp)
349
+ await h.bus.publish(name, JSON.stringify({ n: VALUE, m: stamp }))
350
+ }
351
+
352
+ runtime.channels = { publish, set: setShared }
353
+
354
+ interface WsPeer { id: string; send(text: string): unknown; close(code?: number, reason?: string): unknown; websocket?: { bufferedAmount?: number } }
355
+
356
+ /** Nitro reads `crossws` off the response (research 32 §1). A refusal is a hook that throws, so no socket opens. */
357
+ export function socketRoute(request: Request, scope: Scope, origins: string[] = []): Promise<Response> {
358
+ const hooks = (crossws: object) => Promise.resolve(Object.assign(new Response('Upgrade required', { status: 426 }), { crossws }))
359
+ const turnAway = (code: string, message: string, status: number) => {
360
+ scope.options.onDiagnostic?.({ code, severity: 'error', message })
361
+ // oxlint-disable-next-line only-throw-error -- crossws answers an upgrade with the Response its hook throws
362
+ return hooks({ upgrade() { throw new Response(message, { status }) } })
363
+ }
364
+ if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') return Promise.resolve(new Response('Upgrade required', { status: 426, headers: { 'cache-control': 'no-store' } }))
365
+ try {
366
+ checkOrigins(origins)
367
+ } catch (e) {
368
+ return turnAway('R004', (e as Error).message, 500)
369
+ }
370
+ const origin = request.headers.get('origin')
371
+ if (!origin || (origin !== new URL(request.url).origin && !origins.includes(origin))) return turnAway('R010', `R010: origin \`${origin ?? 'none'}\` may not open a socket: list it in \`realtime({ origins })\``, 403)
372
+ const peers = new Map<string, Promise<Pick<Attached, 'message' | 'close'>>>()
373
+ const mux = new URL(request.url).searchParams.has('mux')
374
+ return hooks({
375
+ open(peer: WsPeer) {
376
+ const p: Peer = { send: t => void peer.send(t), close: (code, reason) => peer.close(code, reason), backlog: () => peer.websocket?.bufferedAmount ?? 0 }
377
+ peers.set(peer.id, (mux ? import('./mux').then(m => m.muxed(p, request, scope)) : attach(p, request, scope)).catch(e => {
378
+ scope.options.onDiagnostic?.({ code: 'E_RT', severity: 'error', message: `/_weft/ws: ${e instanceof Error ? e.stack ?? e.message : String(e)}` })
379
+ peer.close(1011, 'context failed')
380
+ return { message() {}, close() {} }
381
+ }))
382
+ },
383
+ message(peer: WsPeer, m: { text(): string }) { void peers.get(peer.id)?.then(a => a.message(m.text())) },
384
+ close(peer: WsPeer) {
385
+ void peers.get(peer.id)?.then(a => a.close())
386
+ peers.delete(peer.id)
387
+ },
388
+ })
389
+ }
package/src/stream.ts ADDED
@@ -0,0 +1,13 @@
1
+ // `stream`, connected to `data.ts`. `q.live()` does the same `use` on its first call (see `live.ts`). A call, not a
2
+ // top-level statement: `sideEffects: false` lets a bundler skip this file's own statements for a re-export.
3
+
4
+ import { liveApi } from './data'
5
+ import { stream as define, use } from './live'
6
+
7
+ /** `stream()`, with the realtime half connected on first use. */
8
+ export const stream = ((def: never) => {
9
+ use(liveApi)
10
+ return define(def)
11
+ }) as typeof define
12
+
13
+ export { backoff, hold, type Link, type Stream, type StreamDef, type StreamFn, type StreamOptions, type StreamStatus } from './live'
package/src/verify.ts ADDED
@@ -0,0 +1,251 @@
1
+ // `live: { required: true, verify: … }`: a real round trip against a target that isn't production, as part of `weft
2
+ // check` and `weft build`. A marked row goes in through a connection of its own, as any outside app's would; each link
3
+ // from there to a refetch is timed, and the row comes out again.
4
+ //
5
+ // trigger → write → notify → app → poke → refetch → denied → cleanup
6
+ //
7
+ // A database target tests the first half and a URL target the app's half; with both, the whole chain. Whatever wasn't
8
+ // tested is reported, by name. `live-target.mjs` decides whether a target may be touched at all.
9
+
10
+ import postgres from 'postgres'
11
+ import { CHANNEL } from './changes-sql.mjs'
12
+ import type { Context, Fn, VerifyOptions } from './data'
13
+ import type { Source } from './changes'
14
+
15
+ export type { VerifyOptions }
16
+
17
+ /** Each step of the round trip, in order. */
18
+ export const LINKS = ['trigger', 'write', 'notify', 'app', 'poke', 'refetch', 'denied', 'cleanup'] as const
19
+ /** One step of the round trip. */
20
+ export type Link = (typeof LINKS)[number]
21
+
22
+ /** How a round trip went: the link it broke at, and what wasn't tested. */
23
+ export interface VerifyResult {
24
+ ok: boolean
25
+ /** The link that broke, and what it said. */
26
+ broke?: { link: Link; why: string }
27
+ ms: Partial<Record<Link, number>>
28
+ untested: { link: Link; why: string }[]
29
+ }
30
+
31
+ class Broke extends Error {
32
+ constructor(public link: Link, why: string) { super(why) }
33
+ }
34
+
35
+ const clean = (s: unknown) => String((s as Error | null | undefined)?.message ?? s).replace(/\/\/[^@/\s]*@/g, '//…@')
36
+
37
+ /** A value for a required column nobody named. Foreign keys can't be guessed: `row` is for those. */
38
+ function made(type: string, marker: string): unknown {
39
+ if (/uuid/.test(type)) return crypto.randomUUID()
40
+ if (/int|serial/.test(type)) return Math.floor(Math.random() * 2_000_000_000)
41
+ if (/numeric|double|real|money/.test(type)) return 0
42
+ if (/bool/.test(type)) return false
43
+ if (/timestamp|date/.test(type)) return new Date().toISOString()
44
+ if (/time/.test(type)) return '00:00:00'
45
+ if (/json/.test(type)) return JSON.stringify({ marker })
46
+ return marker
47
+ }
48
+
49
+ async function insert(sql: postgres.Sql, table: string, marker: string, row: Record<string, unknown>, pk: string[]) {
50
+ const cols = await sql`select column_name, data_type, is_nullable, column_default, is_identity, is_generated from information_schema.columns where table_schema = current_schema() and table_name = ${table} order by ordinal_position`
51
+ if (!cols.length) throw new Broke('write', `no table \`${table}\` in this database`)
52
+ const values: Record<string, unknown> = { ...row }
53
+ let marked = false
54
+ for (const c of cols) {
55
+ const name = c.column_name as string
56
+ if (name in values) continue
57
+ if (/char|text/.test(c.data_type) && !marked) {
58
+ values[name] = marker
59
+ marked = true
60
+ } else if (c.is_nullable === 'NO' && c.column_default === null && c.is_identity === 'NO' && c.is_generated === 'NEVER') values[name] = made(c.data_type, marker)
61
+ }
62
+ if (!marked) throw new Broke('write', `\`${table}\` has no free text column to mark a row with: name one less in \`row\``)
63
+ const names = Object.keys(values)
64
+ const quoted = (n: string) => `"${n.replaceAll('"', '""')}"`
65
+ const key = pk.length ? pk : names.slice(0, 1)
66
+ const [got] = await sql.unsafe(`insert into ${quoted(table)} (${names.map(quoted).join(', ')}) values (${names.map((_, i) => `$${i + 1}`).join(', ')}) returning ${key.map(quoted).join(', ')}`, names.map(n => values[n]) as never[])
67
+ if (!got) throw new Broke('write', `\`${table}\` took the row and returned nothing`)
68
+ const where = key.map((k, i) => `${quoted(k)} = $${i + 1}`).join(' and ')
69
+ return { id: key.map(k => String(got[k])).join(':'), remove: () => sql.unsafe(`delete from ${quoted(table)} where ${where}`, key.map(k => got[k])) }
70
+ }
71
+
72
+ async function primaryKey(sql: postgres.Sql, table: string): Promise<string[]> {
73
+ const rows = await sql`select a.attname from pg_index i join pg_class c on c.oid = i.indrelid join pg_attribute a on a.attrelid = c.oid and a.attnum = any(i.indkey) where c.relname = ${table} and i.indisprimary`
74
+ return rows.map(r => r.attname as string)
75
+ }
76
+
77
+ /** One SSE connection to the app, by the protocol a browser's tab speaks. */
78
+ type Frame = [event: string, data: unknown]
79
+ type Hello = { l?: { m?: string; d?: unknown } }
80
+
81
+ async function subscribe(url: string, id: string, input: unknown, cookie: string | undefined, signal: AbortSignal) {
82
+ const res = await fetch(`${url}/_weft/rt`, { method: 'POST', signal, headers: { 'content-type': 'application/json', 'x-weft': '1', accept: 'text/event-stream', origin: url, ...(cookie && { cookie }) }, body: JSON.stringify({ s: [[1, 'q', id, input]] }) })
83
+ if (!res.ok) throw new Error(`\`/_weft/rt\` answered ${res.status}`)
84
+ const frames: Frame[] = []
85
+ const waiting = new Set<() => void>()
86
+ void (async () => {
87
+ const dec = new TextDecoder()
88
+ let buf = ''
89
+ try {
90
+ for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
91
+ buf += dec.decode(chunk, { stream: true })
92
+ for (let i; (i = buf.indexOf('\n\n')) >= 0; buf = buf.slice(i + 2)) {
93
+ if (buf[0] === ':') continue
94
+ const line = buf.indexOf('\n')
95
+ frames.push([buf.slice(7, line), JSON.parse(buf.slice(line + 7, i))])
96
+ for (const w of waiting) w()
97
+ }
98
+ }
99
+ } catch {}
100
+ })()
101
+ const until = async (test: (f: Frame) => boolean, ms: number) => {
102
+ const end = Date.now() + ms
103
+ for (;;) {
104
+ const hit = frames.find(test)
105
+ if (hit) return hit
106
+ const left = end - Date.now()
107
+ if (left <= 0) return undefined
108
+ await new Promise<void>(ok => {
109
+ const t = setTimeout(ok, left)
110
+ waiting.add(() => { clearTimeout(t); ok() })
111
+ })
112
+ waiting.clear()
113
+ }
114
+ }
115
+ return { frames, until }
116
+ }
117
+
118
+ const read = (url: string, id: string, input: unknown, cookie?: string) =>
119
+ fetch(`${url}/_weft/fn/${id}${input == null ? '' : `?i=${encodeURIComponent(JSON.stringify(input))}`}`, { headers: { accept: 'application/json', ...(cookie && { cookie }) } })
120
+
121
+ /** What `roundTrip` needs: the query, the feed, and where the app is. */
122
+ export interface RoundTrip {
123
+ fn: Fn
124
+ source?: Source
125
+ url?: string
126
+ db?: string
127
+ options: VerifyOptions
128
+ env: Record<string, string | undefined>
129
+ }
130
+
131
+ /** The query's table: the first of its tags `changes` reports. */
132
+ function tableOf(fn: Fn, source: Source | undefined, input: unknown): string | undefined {
133
+ const any = new Proxy({}, { get: (_, k) => (k === Symbol.toPrimitive ? () => '*' : '*') })
134
+ let tags: readonly string[] = []
135
+ try { tags = typeof fn.def.tags === 'function' ? fn.def.tags(input ?? any, any as Context) : fn.def.tags ?? [] } catch {}
136
+ const names = tags.map(t => t.split(':')[0])
137
+ return names.find(t => t !== undefined && source?.tables.includes(t)) ?? names[0]
138
+ }
139
+
140
+ /** Writes a real row and follows it to a live client, naming the first link that breaks. */
141
+ export async function roundTrip({ fn, source, url, db, options, env }: RoundTrip): Promise<VerifyResult> {
142
+ const timeout = options.timeout ?? 5000
143
+ const marker = `weft-verify-${crypto.randomUUID()}` // concurrent builds never touch each other's row
144
+ const ms: VerifyResult['ms'] = {}
145
+ const untested: VerifyResult['untested'] = []
146
+ const input = options.input
147
+ const cookie = env[options.cookie ?? 'WEFT_STAGING_COOKIE']
148
+ const other = env[options.otherCookie ?? 'WEFT_STAGING_OTHER_COOKIE']
149
+ const timed = async <T,>(link: Link, run: () => Promise<T>): Promise<T> => {
150
+ const t = performance.now()
151
+ try {
152
+ return await run()
153
+ } catch (e) {
154
+ throw e instanceof Broke ? e : new Broke(link, clean(e))
155
+ } finally {
156
+ ms[link] = Math.round(performance.now() - t)
157
+ }
158
+ }
159
+ const skip = (links: Link[], why: string) => { for (const link of links) untested.push({ link, why }) }
160
+ const sql = db ? postgres(db, { max: 3, idle_timeout: 2, connect_timeout: 5, onnotice: () => {} }) : undefined
161
+ const listener = db ? postgres(db, { max: 1, idle_timeout: 2, connect_timeout: 5, onnotice: () => {} }) : undefined
162
+ const ac = new AbortController()
163
+ let row: Awaited<ReturnType<typeof insert>> | undefined
164
+ let cleaning: unknown
165
+ try {
166
+ const table = tableOf(fn, source, input)
167
+ if (!table) throw new Broke('trigger', `\`${fn.$id}\` has no tag naming a table`)
168
+ const heard: string[] = []
169
+ let hearing: (() => void) | undefined
170
+ if (sql) {
171
+ await timed('trigger', async () => {
172
+ const [t] = await sql`select t.tgenabled from pg_trigger t join pg_class c on c.oid = t.tgrelid where t.tgname = 'weft_changes_ins' and c.relname = ${table}`
173
+ if (!t) throw new Broke('trigger', `\`${table}\` has no change trigger: run \`weft db live\`, and apply the migration there`)
174
+ if (t.tgenabled === 'D') throw new Broke('trigger', `the change trigger on \`${table}\` is disabled`)
175
+ })
176
+ await listener!.listen(CHANNEL, p => { heard.push(p); hearing?.() })
177
+ } else skip(['trigger', 'write', 'notify', 'poke', 'refetch', 'denied', 'cleanup'], 'no database target: nothing wrote a row, so only the app and its feed were checked. Set `STAGING_DATABASE_URL` or `verify.db`')
178
+
179
+ const live = url ? await timed('app', async () => {
180
+ const a = await subscribe(url, fn.$id, input, cookie, ac.signal)
181
+ const hi = await a.until(f => f[0] === 'h', timeout)
182
+ if (!hi) throw new Broke('app', 'the app accepted the connection and said nothing')
183
+ const said = (hi[1] as Hello).l
184
+ if (said?.m !== 'feed' && said?.m !== 'external') throw new Broke('app', `the app's live mode is \`${said?.m ?? 'app'}\`, not a feed: its log says why`)
185
+ if (said?.d) throw new Broke('app', 'the app\'s change feed is down')
186
+ const ack = await a.until(f => (f[0] === 'a' || f[0] === 'e') && (f[1] as unknown[])[0] === 1, timeout)
187
+ if (!ack) throw new Broke('app', 'no answer to the subscription')
188
+ const answer = (ack[1] as [number, unknown])[1]
189
+ const failed = answer as { code?: string; message?: string }
190
+ if (ack[0] === 'e') throw new Broke('app', failed.code === 'Q009' ? `the app doesn't know \`${fn.$id}\`: it runs another build` : failed.code === 'A002' ? `\`${fn.$id}\` needs a signed-in user: set \`WEFT_STAGING_COOKIE\`` : `the subscription failed: ${failed.message}`)
191
+ return { ...a, tags: answer as string[] }
192
+ }) : undefined
193
+ if (!url) skip(sql ? ['app', 'poke', 'refetch', 'denied'] : ['app'], 'no URL target: the database half was tested, not the running app. Set `WEFT_STAGING_URL` or `verify.url`')
194
+
195
+ if (!sql) {
196
+ if (live) ac.abort()
197
+ return { ok: true, ms, untested }
198
+ }
199
+ const denied = url && !(cookie && !other && !fn.def.auth) ? await subscribe(url, fn.$id, input, other, ac.signal).catch(() => undefined) : undefined
200
+
201
+ const pk = await primaryKey(sql, table)
202
+ row = await timed('write', () => insert(sql, table, marker, options.row ?? {}, pk))
203
+ await timed('notify', async () => {
204
+ const seen = () => heard.some(h => { try { const m = JSON.parse(h); return m.t === table && (!m.i || m.i.includes(row!.id)) } catch { return false } })
205
+ if (!seen()) await new Promise<void>(ok => {
206
+ const t = setTimeout(ok, timeout)
207
+ hearing = () => { if (seen()) { clearTimeout(t); ok() } }
208
+ })
209
+ if (!seen()) throw new Broke('notify', `the trigger on \`${table}\` sent nothing on \`${CHANNEL}\` within ${timeout} ms`)
210
+ })
211
+ if (live) {
212
+ await timed('poke', async () => {
213
+ const p = await live.until(f => f[0] === 'p' && (f[1] as { t: string[] }).t.some(t => live.tags.includes(t)), timeout)
214
+ if (!p) throw new Broke('poke', `the database notified, and the app never poked this subscription within ${timeout} ms: its listener, or a tag the query doesn't declare`)
215
+ })
216
+ await timed('refetch', async () => {
217
+ const end = Date.now() + timeout
218
+ for (;;) {
219
+ const res = await read(url!, fn.$id, input, cookie)
220
+ if (res.ok && (await res.text()).includes(marker)) return
221
+ if (Date.now() > end) throw new Broke('refetch', `poked, and the query didn't return the row within ${timeout} ms${options.row ? '' : ': if it filters by user, say whose it is in `verify.row`'}`)
222
+ await new Promise(ok => { setTimeout(ok, 50) })
223
+ }
224
+ })
225
+ if (denied) await timed('denied', async () => {
226
+ const res = await read(url!, fn.$id, input, other)
227
+ const text = await res.text()
228
+ if (text.includes(marker) || denied.frames.some(f => JSON.stringify(f[1]).includes(marker))) throw new Broke('denied', `${other ? 'the other user' : 'a signed-out client'} got the row`)
229
+ })
230
+ else skip(['denied'], 'a query with no `auth` and no other user to compare: set `WEFT_STAGING_OTHER_COOKIE`')
231
+ }
232
+ return { ok: true, ms, untested }
233
+ } catch (e) {
234
+ const b = e instanceof Broke ? e : new Broke('trigger', clean(e))
235
+ return { ok: false, broke: { link: b.link, why: clean(b.message) }, ms, untested }
236
+ } finally {
237
+ ac.abort()
238
+ if (row) {
239
+ const t = performance.now()
240
+ try {
241
+ await row.remove()
242
+ ms.cleanup = Math.round(performance.now() - t)
243
+ } catch (e) {
244
+ cleaning = e
245
+ }
246
+ }
247
+ await Promise.all([sql?.end({ timeout: 1 }), listener?.end({ timeout: 1 })]).catch(() => {})
248
+ // oxlint-disable-next-line no-unsafe-finally -- a row left in a real database outranks whatever the run concluded
249
+ if (cleaning) throw new Broke('cleanup', `row \`${marker}\` is still there: ${clean(cleaning)}`)
250
+ }
251
+ }