@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.
- package/LICENSE +16 -0
- package/README.md +41 -2
- package/package.json +107 -4
- package/src/app.mjs +93 -0
- package/src/auth/better-auth.ts +58 -0
- package/src/auth/drizzle.ts +17 -0
- package/src/auth.ts +47 -0
- package/src/boot.ts +44 -0
- package/src/builtin.tsx +19 -0
- package/src/bus/postgres.ts +91 -0
- package/src/bus.ts +48 -0
- package/src/capture.d.mts +1 -0
- package/src/capture.mjs +15 -0
- package/src/changes-sql.d.mts +14 -0
- package/src/changes-sql.mjs +99 -0
- package/src/changes.ts +253 -0
- package/src/channel-fake.ts +55 -0
- package/src/channel.ts +622 -0
- package/src/client.tsx +20 -0
- package/src/contract.mjs +312 -0
- package/src/data.ts +929 -0
- package/src/db/bun-sqlite.d.ts +10 -0
- package/src/db/changes-mysql.ts +86 -0
- package/src/db/changes-postgres.ts +159 -0
- package/src/db/changes-sqlite.ts +116 -0
- package/src/db/core.ts +63 -0
- package/src/db/index.ts +27 -0
- package/src/db/mysql.ts +67 -0
- package/src/db/postgres.ts +65 -0
- package/src/db/sqlite.ts +139 -0
- package/src/derive.d.mts +16 -0
- package/src/derive.mjs +140 -0
- package/src/dev-runtime.mjs +42 -0
- package/src/exec.ts +519 -0
- package/src/extension.ts +38 -0
- package/src/first-paint.ts +57 -0
- package/src/form.tsx +134 -0
- package/src/gate.css +6 -0
- package/src/hub.ts +822 -0
- package/src/index.ts +106 -0
- package/src/inline.mjs +2 -0
- package/src/inspect.mjs +280 -0
- package/src/link.ts +461 -0
- package/src/list-drizzle.ts +50 -0
- package/src/list.ts +224 -0
- package/src/listen.ts +28 -0
- package/src/live-fake.ts +42 -0
- package/src/live-target.mjs +147 -0
- package/src/live.ts +499 -0
- package/src/media.ts +43 -0
- package/src/migrations.mjs +19 -0
- package/src/mux-session.ts +6 -0
- package/src/mux.ts +135 -0
- package/src/og/DefaultCard.tsx +60 -0
- package/src/og/fonts.mjs +64 -0
- package/src/og/images.mjs +54 -0
- package/src/og/index.mjs +146 -0
- package/src/og/native.mjs +29 -0
- package/src/range.mjs +19 -0
- package/src/router.ts +149 -0
- package/src/routes.mjs +142 -0
- package/src/run.tsx +202 -0
- package/src/rust.ts +333 -0
- package/src/server.tsx +1021 -0
- package/src/shape.d.mts +3 -0
- package/src/shape.mjs +73 -0
- package/src/share-server.ts +208 -0
- package/src/share.ts +503 -0
- package/src/sheets.mjs +75 -0
- package/src/sidecar.mjs +503 -0
- package/src/socket.ts +389 -0
- package/src/stream.ts +13 -0
- package/src/verify.ts +251 -0
- package/src/view.tsx +47 -0
- 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
|
+
}
|