@weft-js/start 0.0.0-stage → 0.1.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 +16 -0
- package/README.md +41 -2
- package/package.json +107 -4
- package/src/app.mjs +82 -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 +616 -0
- package/src/client.tsx +20 -0
- package/src/contract.mjs +308 -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 +456 -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 +144 -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 +198 -0
- package/src/rust.ts +333 -0
- package/src/server.tsx +1014 -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/hub.ts
ADDED
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
// The server's realtime hub (research 30 §5). No runtime imports, so one hub runs behind `/_weft/rt` and behind
|
|
2
|
+
// @weft-js/core/test's fake transport: both hand it a sink, and only the HTTP sink knows about bytes.
|
|
3
|
+
//
|
|
4
|
+
// rings one for pokes, always listening, so a rendered page's cursor can replay the gap before it connects;
|
|
5
|
+
// one per topic channel, alive while someone listens and one window longer. Cursors are
|
|
6
|
+
// `<epoch>:<seq>`: another epoch, or one older than a ring's floor, is out of the window.
|
|
7
|
+
// connections one per tab, changed in place (`k`), so adding a subscription never restarts another's `run`.
|
|
8
|
+
// backpressure past the high-water mark a subscription holds its last message, or up to `buffer`; pokes merge by tag.
|
|
9
|
+
// A buffer that overflows resets that stream; a connection over `queue` bytes is closed, and resumes.
|
|
10
|
+
|
|
11
|
+
import type { Bus } from './bus'
|
|
12
|
+
import { memoryBus } from './bus'
|
|
13
|
+
import { createFeed, functionHost, type Feed, type Poke, type Probe, type Source } from './changes'
|
|
14
|
+
import { currentScope, enter, fns, StartError, type Context, type Fn, type LiveMode, type StandardSchemaV1 } from './data'
|
|
15
|
+
import { sessionOf } from './mux-session'
|
|
16
|
+
import type { ShareRule } from './share-server'
|
|
17
|
+
import { createScope, defaultStore, guard, liveQueries, missedFeed, tagsOf, validate, type Scope, type ScopeOptions, type Store } from './exec'
|
|
18
|
+
|
|
19
|
+
// An app's tsc may have no Node types, so `process` is named here.
|
|
20
|
+
const proc = globalThis as unknown as { process?: { env: Record<string, string | undefined>; exit(code: number): void } }
|
|
21
|
+
|
|
22
|
+
/** What one connection may cost; every number has a default in `LIMITS`. */
|
|
23
|
+
export interface Limits {
|
|
24
|
+
subscriptions: number
|
|
25
|
+
/** Per signed-in user. */
|
|
26
|
+
connections: number
|
|
27
|
+
/** Bytes of one request (client→server). */
|
|
28
|
+
body: number
|
|
29
|
+
/** Bytes of one message (server→client). */
|
|
30
|
+
message: number
|
|
31
|
+
/** Bytes one connection may hold before it's closed. */
|
|
32
|
+
queue: number
|
|
33
|
+
/** Events a ring keeps. */
|
|
34
|
+
ring: number
|
|
35
|
+
/** Ms a ring keeps them. */
|
|
36
|
+
window: number
|
|
37
|
+
/** Ms between `: ping`s. */
|
|
38
|
+
heartbeat: number
|
|
39
|
+
/** Ms between session re-checks of `auth` subscriptions and channel joins. */
|
|
40
|
+
session: number
|
|
41
|
+
/** Channel messages a client may send per second, per socket. */
|
|
42
|
+
perSecond: number
|
|
43
|
+
/** The burst `perSecond` allows. */
|
|
44
|
+
burst: number
|
|
45
|
+
/** Keys one socket may `share`. They cost no joins: a 200-row list is one. */
|
|
46
|
+
shareKeys: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const LIMITS: Limits = { subscriptions: 100, connections: 10, body: 64 * 1024, message: 1 << 20, queue: 1 << 20, ring: 256, window: 60_000, heartbeat: 20_000, session: 300_000, perSecond: 20, burst: 40, shareKeys: 1000 }
|
|
50
|
+
const HIGH = 64 * 1024
|
|
51
|
+
const VOLATILE = '{"v":1'
|
|
52
|
+
|
|
53
|
+
/** `realtime()`: the options of an app's `src/realtime.server.ts`. */
|
|
54
|
+
export interface RealtimeOptions {
|
|
55
|
+
/** Default: `memoryBus()`, one instance only (R002). */
|
|
56
|
+
bus?: Bus
|
|
57
|
+
/** Origins allowed besides the app's own, exact: scheme, host and port. */
|
|
58
|
+
origins?: string[]
|
|
59
|
+
limits?: Partial<Limits>
|
|
60
|
+
/** Seconds the platform lets a response live: streams end 10 s earlier, and the client resumes. */
|
|
61
|
+
maxDuration?: number
|
|
62
|
+
/** Writes from outside the app: `changes: dbChanges(db, [todos])`, or a source of your own. */
|
|
63
|
+
changes?: Source
|
|
64
|
+
/**
|
|
65
|
+
* Who may share or see a key of `interaction({ share })` with other clients. Left out, nobody: tabs still work. One
|
|
66
|
+
* key at a time, `({ ctx, key }) => boolean`, or `{ keys: ({ ctx, keys }) => allowed }` to answer a whole join at once.
|
|
67
|
+
*/
|
|
68
|
+
share?: ShareRule | undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What the app gets from `configure`, beside `src/realtime.server.ts`. */
|
|
72
|
+
export interface HubOptions extends RealtimeOptions {
|
|
73
|
+
build?: string | undefined
|
|
74
|
+
dev?: boolean | undefined
|
|
75
|
+
/** Queries marked `live: { required: true }`: without a real feed the server refuses to run (R015). */
|
|
76
|
+
required?: string[] | undefined
|
|
77
|
+
store?: Store | undefined
|
|
78
|
+
onDiagnostic?: ScopeOptions['onDiagnostic'] | undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What `.live` reports, and a health route reads. */
|
|
82
|
+
export interface LiveState {
|
|
83
|
+
mode: LiveMode
|
|
84
|
+
via: string
|
|
85
|
+
why?: string | undefined
|
|
86
|
+
fix?: string | undefined
|
|
87
|
+
/** The feed works in general and is out right now. */
|
|
88
|
+
down?: string | undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `src/realtime.server.ts`'s default export. */
|
|
92
|
+
export function realtime(options: RealtimeOptions): RealtimeOptions {
|
|
93
|
+
checkOrigins(options.origins)
|
|
94
|
+
return options
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** R004. `realtime()` is a wrapper an app can leave out, so the upgrade calls this too. */
|
|
98
|
+
export function checkOrigins(origins: string[] = []) {
|
|
99
|
+
if (origins.some(o => o.includes('*'))) throw new StartError('R004', '`realtime({ origins })` has a wildcard: any site could read this app\'s streams and join its channels with a visitor\'s cookies', 'list the origins, scheme and port included')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Where a connection's frames go. `data` is JSON. */
|
|
103
|
+
export interface Sink {
|
|
104
|
+
write(event: string, data: string): void
|
|
105
|
+
ping?(): void
|
|
106
|
+
/** Bytes written and not yet taken by the client. */
|
|
107
|
+
backlog(): number
|
|
108
|
+
close(): void
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What the client sends (research 30 §4): `[n, kind, id, input, cursor, buffer]`. */
|
|
112
|
+
export type SubEntry = [n: number, kind: 'q' | 's', id: string, input?: unknown, cursor?: string, buffer?: number]
|
|
113
|
+
/** The body of `/_weft/rt`: the cursor, subscriptions to add, and subscriptions to drop. */
|
|
114
|
+
export interface Body { k?: string; c?: string | undefined; s?: SubEntry[]; d?: number[] }
|
|
115
|
+
|
|
116
|
+
/** A message with its cursor: `yield tracked(row.id, row)`, and a resumed `run` gets it as `lastEventId`. */
|
|
117
|
+
export class Tracked<M> {
|
|
118
|
+
constructor(public id: string, public value: M) {}
|
|
119
|
+
}
|
|
120
|
+
/** A message with an id, so a stream can resume after it. */
|
|
121
|
+
export const tracked = <M>(id: string, value: M) => new Tracked(id, value)
|
|
122
|
+
|
|
123
|
+
/** Thrown out of `run` when its cursor can't resume: the stream restarts without one. */
|
|
124
|
+
const RESET = new Error('weft.reset')
|
|
125
|
+
|
|
126
|
+
/** One message in a channel's ring. */
|
|
127
|
+
export interface Ev { seq: number; at: number; raw: string; /** Parsed once, for every socket of a room. */ p?: unknown }
|
|
128
|
+
/** One hub channel: its replay ring and who's listening on this instance. */
|
|
129
|
+
export interface Channel {
|
|
130
|
+
name: string
|
|
131
|
+
ring: Ev[]
|
|
132
|
+
floor: number
|
|
133
|
+
listeners: Set<(ev: Ev | null) => void>
|
|
134
|
+
/** Take a message as if the bus had brought it: a change feed reaches this instance's connections only. */
|
|
135
|
+
got: (raw: string) => void
|
|
136
|
+
ready: Promise<void>
|
|
137
|
+
stop?: () => void
|
|
138
|
+
idle?: ReturnType<typeof setTimeout> | undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface Sub {
|
|
142
|
+
n: number
|
|
143
|
+
kind: 'q' | 's'
|
|
144
|
+
fn: Fn
|
|
145
|
+
input: unknown
|
|
146
|
+
tags: string[]
|
|
147
|
+
buffer: number
|
|
148
|
+
q: { raw: string; size: number }[]
|
|
149
|
+
ac: AbortController
|
|
150
|
+
request: Request
|
|
151
|
+
options: ScopeOptions
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** One client's realtime connection. */
|
|
155
|
+
export interface Conn {
|
|
156
|
+
k: string
|
|
157
|
+
sink: Sink
|
|
158
|
+
user?: string | undefined
|
|
159
|
+
subs: Map<number, Sub>
|
|
160
|
+
tags: Map<string, number>
|
|
161
|
+
held: Set<Sub>
|
|
162
|
+
pokes?: { t: Set<string>; c: string } | undefined
|
|
163
|
+
bytes: number
|
|
164
|
+
timers: ReturnType<typeof setTimeout>[]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The realtime server of this process: channels, connections and the change feed. */
|
|
168
|
+
export interface Hub {
|
|
169
|
+
readonly bus: Bus
|
|
170
|
+
readonly limits: Limits
|
|
171
|
+
/** `realtime({ share })`. */
|
|
172
|
+
readonly share?: ShareRule | undefined
|
|
173
|
+
poke(tags: string[], origin?: string | null): Promise<void>
|
|
174
|
+
cursor(): string | undefined
|
|
175
|
+
/** Listening for pokes, or 250 ms: a page waits this long for a cursor, and goes without one. */
|
|
176
|
+
ready(): Promise<unknown>
|
|
177
|
+
cursorOf(seq: number): string
|
|
178
|
+
channel(name: string): Channel
|
|
179
|
+
replay(ch: Channel, after: string): Ev[] | undefined
|
|
180
|
+
unlisten(ch: Channel, on: (ev: Ev | null) => void): void
|
|
181
|
+
/** `line`: one tab on a shared socket (mux.ts), which `limits.connections` counted already. */
|
|
182
|
+
connect(request: Request, scope: Scope, body: Body, sink: Sink, line?: boolean): Promise<Conn>
|
|
183
|
+
change(c: Conn, request: Request, options: ScopeOptions, body: Body): Promise<void>
|
|
184
|
+
drain(c: Conn): void
|
|
185
|
+
close(c: Conn): void
|
|
186
|
+
find(k: string): Conn | undefined
|
|
187
|
+
connections(): Conn[]
|
|
188
|
+
/** @weft-js/core/test: a message straight to every reader of a stream. */
|
|
189
|
+
push(id: string, msg: unknown): number
|
|
190
|
+
/** Server-side counts for tests and `weft browser`. */
|
|
191
|
+
stats(): { connections: number; subscriptions: number; channels: number }
|
|
192
|
+
/** The live mode this server settled on: `.liveMode` on the client, and health routes. */
|
|
193
|
+
live(): LiveState
|
|
194
|
+
required: boolean
|
|
195
|
+
/** Settled once the source has been probed. */
|
|
196
|
+
probed: Promise<unknown>
|
|
197
|
+
/** R015: required live queries, and no feed. Requests get this instead of stale data. */
|
|
198
|
+
refused?: StartError | undefined
|
|
199
|
+
shutdown(): Promise<void>
|
|
200
|
+
/** The position a new listener resumes from. */
|
|
201
|
+
position(): string
|
|
202
|
+
/** Every poked tag, on this instance: socket.ts's `session:<user>` re-check. */
|
|
203
|
+
watch(fn: (tags: string[]) => void): void
|
|
204
|
+
/** Run at shutdown: socket.ts closes its sockets. */
|
|
205
|
+
closing: Set<() => void>
|
|
206
|
+
/** `room.publish` checks its messages (C002). */
|
|
207
|
+
dev?: boolean | undefined
|
|
208
|
+
/** The instance's name in a shared value's stamp. */
|
|
209
|
+
epoch: string
|
|
210
|
+
/** Seconds a connection may live (`RealtimeOptions.maxDuration`). */
|
|
211
|
+
maxDuration?: number | undefined
|
|
212
|
+
build?: string | undefined
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const unref = <T,>(t: T) => ((t as { unref?(): void } | null | undefined)?.unref?.(), t)
|
|
216
|
+
|
|
217
|
+
/** A hub on these options; `hub()` is the usual way to reach the one a server made. */
|
|
218
|
+
export function createHub(options: HubOptions): Hub {
|
|
219
|
+
const bus = options.bus ?? memoryBus()
|
|
220
|
+
const limits = { ...LIMITS, ...options.limits }
|
|
221
|
+
const epoch = crypto.randomUUID().slice(0, 8)
|
|
222
|
+
const channels = new Map<string, Channel>()
|
|
223
|
+
const conns = new Map<string, Conn>()
|
|
224
|
+
const watchers = new Set<(tags: string[]) => void>()
|
|
225
|
+
const closing = new Set<() => void>()
|
|
226
|
+
let seq = 0
|
|
227
|
+
const cursorOf = (n: number) => `${epoch}:${n}`
|
|
228
|
+
const source = options.changes
|
|
229
|
+
let live: LiveState = source ? { mode: 'off', via: 'none', why: 'not probed yet' } : { mode: 'app', via: 'mutations' }
|
|
230
|
+
let refused: StartError | undefined
|
|
231
|
+
let waking: Promise<unknown> | undefined, resting: ReturnType<typeof setTimeout> | undefined, beats: ReturnType<typeof setInterval> | undefined, beat = 0
|
|
232
|
+
const report = (code: string, severity: string, message: string) => options.onDiagnostic?.({ code, severity, message })
|
|
233
|
+
const status = (down: string | undefined) => {
|
|
234
|
+
if (live.down === down) return
|
|
235
|
+
live = { ...live, down }
|
|
236
|
+
if (down) report('R016', 'warning', `live: ${down}`)
|
|
237
|
+
for (const c of conns.values()) c.sink.write('L', JSON.stringify(wire()))
|
|
238
|
+
}
|
|
239
|
+
const wire = () => ({ m: live.mode, ...(live.down && { d: 1 }) })
|
|
240
|
+
const feed: Feed | undefined = source && createFeed(source, {
|
|
241
|
+
emit: p => pokes().got(JSON.stringify(p)),
|
|
242
|
+
bump: tags => void (options.store ?? defaultStore).bump(tags),
|
|
243
|
+
status,
|
|
244
|
+
})
|
|
245
|
+
const probed: Promise<unknown> = source && !proc.process?.env.WEFT_BUILD ? settle(source) : Promise.resolve()
|
|
246
|
+
probed.catch(() => {})
|
|
247
|
+
|
|
248
|
+
/** Probes rather than assumes (`what works here`), says what it found once, and refuses to serve what was required. */
|
|
249
|
+
async function settle(src: Source) {
|
|
250
|
+
const host = functionHost()
|
|
251
|
+
const how: Probe = src.external
|
|
252
|
+
? { mode: 'feed', via: 'listener' }
|
|
253
|
+
: host
|
|
254
|
+
? { mode: 'off', via: 'none', why: `${host} ends a process with its request, so nothing can hold a database connection`, fix: 'run `weft db listen` on an always-on host and set `listener: \'external\'`, or deploy to a server preset' }
|
|
255
|
+
: await src.probe().catch(e => ({ mode: 'off' as const, via: 'none', why: e instanceof Error ? e.message : String(e), fix: 'check the database' }))
|
|
256
|
+
live = { mode: src.external ? 'external' : how.mode, via: how.via, why: how.why, fix: how.fix }
|
|
257
|
+
const line = `live: ${live.mode} (${live.via})${how.why ? `: ${how.why}` : ''}`
|
|
258
|
+
if (how.mode === 'feed') console.info(`weft ${line}`)
|
|
259
|
+
else {
|
|
260
|
+
console.warn(`weft ${line}${how.fix ? `\n fix: ${how.fix}` : ''}`)
|
|
261
|
+
report('R016', 'warning', `${line}. \`.live()\` shows \`degraded\``)
|
|
262
|
+
}
|
|
263
|
+
if (how.mode === 'feed' || !options.required?.length) return how
|
|
264
|
+
refused = new StartError('R015', `${options.required.map(n => `\`${n}\``).join(', ')} require${options.required.length > 1 ? '' : 's'} a live feed, and this server has ${how.mode === 'poll' ? 'only polling' : 'none'}${how.why ? `: ${how.why}` : ''}`, how.fix ?? 'see `weft doctor`', { status: 503 })
|
|
265
|
+
console.error(`weft ${refused.message}`)
|
|
266
|
+
if (!options.dev) proc.process?.exit(1)
|
|
267
|
+
return how
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The feed runs while someone can use it: a server with no clients holds no listener and runs no watcher. */
|
|
271
|
+
function want() {
|
|
272
|
+
clearTimeout(resting)
|
|
273
|
+
resting = undefined
|
|
274
|
+
if (!source) return
|
|
275
|
+
if (source.external) {
|
|
276
|
+
if (beats) return
|
|
277
|
+
beat = Date.now()
|
|
278
|
+
void pokes()
|
|
279
|
+
beats = unref(setInterval(() => { if (Date.now() - beat > limits.window / 2) status('the external listener has gone quiet') }, limits.heartbeat / 2))
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
return (waking ??= probed.then(how => feed!.start(how as Probe)).catch(e => status(e instanceof Error ? e.message : String(e))).finally(() => { waking = undefined }))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function rest() {
|
|
286
|
+
if (!source || conns.size || resting || !feed?.running) return
|
|
287
|
+
resting = unref(setTimeout(() => {
|
|
288
|
+
resting = undefined
|
|
289
|
+
if (!conns.size) void feed.stop()
|
|
290
|
+
}, limits.window))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const trim = (ch: Channel) => {
|
|
294
|
+
const old = Date.now() - limits.window
|
|
295
|
+
let drop = 0
|
|
296
|
+
while (drop < ch.ring.length && (ch.ring[drop]!.at < old || ch.ring.length - drop > limits.ring)) drop++
|
|
297
|
+
if (!drop) return
|
|
298
|
+
ch.floor = ch.ring[drop - 1]!.seq
|
|
299
|
+
ch.ring.splice(0, drop)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function channel(name: string): Channel {
|
|
303
|
+
const ch = channels.get(name)
|
|
304
|
+
if (ch) {
|
|
305
|
+
clearTimeout(ch.idle)
|
|
306
|
+
ch.idle = undefined
|
|
307
|
+
return ch
|
|
308
|
+
}
|
|
309
|
+
const made: Channel = { name, ring: [], floor: seq, listeners: new Set(), got: undefined as never, ready: undefined as never }
|
|
310
|
+
channels.set(name, made)
|
|
311
|
+
made.got = raw => {
|
|
312
|
+
const ev = { seq: ++seq, at: Date.now(), raw }
|
|
313
|
+
// Cursors and presence beats are only worth having now: in the ring they'd push out what a resume needs.
|
|
314
|
+
if (!raw.startsWith(VOLATILE)) {
|
|
315
|
+
made.ring.push(ev)
|
|
316
|
+
trim(made)
|
|
317
|
+
}
|
|
318
|
+
for (const on of made.listeners) on(ev)
|
|
319
|
+
}
|
|
320
|
+
made.ready = bus.subscribe(name, made.got, () => {
|
|
321
|
+
made.ring.length = 0
|
|
322
|
+
made.floor = seq
|
|
323
|
+
for (const on of made.listeners) on(null)
|
|
324
|
+
}).then(stop => {
|
|
325
|
+
made.stop = stop
|
|
326
|
+
made.floor = seq
|
|
327
|
+
}, e => {
|
|
328
|
+
channels.delete(name) // the next use tries again; whoever awaits `ready` sees the failure
|
|
329
|
+
throw e
|
|
330
|
+
})
|
|
331
|
+
made.ready.catch(() => {})
|
|
332
|
+
return made
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function unlisten(ch: Channel, on: (ev: Ev | null) => void) {
|
|
336
|
+
ch.listeners.delete(on)
|
|
337
|
+
if (ch.listeners.size || ch.idle) return
|
|
338
|
+
ch.idle = unref(setTimeout(() => {
|
|
339
|
+
ch.stop?.()
|
|
340
|
+
channels.delete(ch.name)
|
|
341
|
+
}, limits.window))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function replay(ch: Channel, after: string): Ev[] | undefined {
|
|
345
|
+
const [e, n] = after.split(':')
|
|
346
|
+
const at = Number(n)
|
|
347
|
+
trim(ch)
|
|
348
|
+
if (e !== epoch || !(at >= ch.floor)) return undefined
|
|
349
|
+
return ch.ring.filter(ev => ev.seq > at)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Listens from the first realtime use on, not from boot: `weft build` loads this with no bus to reach. */
|
|
353
|
+
function pokes(): Channel {
|
|
354
|
+
const had = channels.get('weft:poke')
|
|
355
|
+
if (had) return had
|
|
356
|
+
const ch = channel('weft:poke')
|
|
357
|
+
ch.listeners.add(ev => {
|
|
358
|
+
if (!ev) {
|
|
359
|
+
for (const c of conns.values()) c.sink.write('R', JSON.stringify({ c: cursorOf(seq) }))
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
const m = JSON.parse(ev.raw) as Poke & { o?: string; b?: 1 }
|
|
363
|
+
if (m.b) { // an external listener's heartbeat
|
|
364
|
+
beat = Date.now()
|
|
365
|
+
return status(undefined)
|
|
366
|
+
}
|
|
367
|
+
for (const c of conns.values()) if (c.k !== m.o) poke(c, picked(c, m), ev.seq)
|
|
368
|
+
for (const w of watchers) w(m.t)
|
|
369
|
+
})
|
|
370
|
+
return ch
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** What this connection subscribed to. `k`: a mutation's poke already reaches a subscription with one of these tags. */
|
|
374
|
+
function picked(c: Conn, { t, p, k }: Poke) {
|
|
375
|
+
if (!p && !k) return t.filter(x => c.tags.has(x))
|
|
376
|
+
const out = new Set<string>()
|
|
377
|
+
for (const s of c.subs.values()) {
|
|
378
|
+
if (k && s.tags.some(x => k.includes(x))) continue
|
|
379
|
+
for (const x of s.tags) if (t.includes(x) || p?.some(pre => x.startsWith(pre))) out.add(x)
|
|
380
|
+
}
|
|
381
|
+
return [...out]
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function poke(c: Conn, tags: string[], at: number) {
|
|
385
|
+
if (!tags.length) return
|
|
386
|
+
if (!c.held.size && c.sink.backlog() < HIGH) return c.sink.write('p', JSON.stringify({ t: tags, c: cursorOf(at) }))
|
|
387
|
+
const p = (c.pokes ??= { t: new Set(), c: '' })
|
|
388
|
+
for (const t of tags) p.t.add(t)
|
|
389
|
+
p.c = cursorOf(at)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Over `queue`: the client gets a bye, reconnects and resumes, rather than this process growing. */
|
|
393
|
+
function hold(c: Conn, s: Sub, raw: string): boolean {
|
|
394
|
+
const size = raw.length
|
|
395
|
+
if (!s.buffer) {
|
|
396
|
+
for (const x of s.q) c.bytes -= x.size
|
|
397
|
+
s.q.length = 0
|
|
398
|
+
} else if (s.q.length >= s.buffer) {
|
|
399
|
+
for (const x of s.q) c.bytes -= x.size
|
|
400
|
+
s.q.length = 0
|
|
401
|
+
return false
|
|
402
|
+
}
|
|
403
|
+
s.q.push({ raw, size })
|
|
404
|
+
c.bytes += size
|
|
405
|
+
c.held.add(s)
|
|
406
|
+
if (c.bytes > limits.queue) bye(c, 0)
|
|
407
|
+
return true
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Written to the client, or held. False: the buffer overflowed, so this stream resets. */
|
|
411
|
+
function deliver(c: Conn, s: Sub, raw: string): boolean {
|
|
412
|
+
if (!c.held.size && c.sink.backlog() < HIGH) return c.sink.write('m', raw), true
|
|
413
|
+
return hold(c, s, raw)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function drain(c: Conn) {
|
|
417
|
+
if (c.pokes && c.sink.backlog() < HIGH) {
|
|
418
|
+
c.sink.write('p', JSON.stringify({ t: [...c.pokes.t], c: c.pokes.c }))
|
|
419
|
+
c.pokes = undefined
|
|
420
|
+
}
|
|
421
|
+
for (const s of c.held) {
|
|
422
|
+
while (s.q.length && c.sink.backlog() < HIGH) {
|
|
423
|
+
const x = s.q.shift()!
|
|
424
|
+
c.bytes -= x.size
|
|
425
|
+
c.sink.write('m', x.raw)
|
|
426
|
+
}
|
|
427
|
+
if (s.q.length) return
|
|
428
|
+
c.held.delete(s)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function bye(c: Conn, retry: number) {
|
|
433
|
+
c.sink.write('b', JSON.stringify({ retry }))
|
|
434
|
+
close(c)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function close(c: Conn) {
|
|
438
|
+
if (!conns.delete(c.k)) return
|
|
439
|
+
for (const t of c.timers) clearTimeout(t)
|
|
440
|
+
for (const s of c.subs.values()) s.ac.abort()
|
|
441
|
+
c.subs.clear()
|
|
442
|
+
c.sink.close()
|
|
443
|
+
rest()
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A subscription refused before it existed. */
|
|
447
|
+
const refuse = (c: Conn, n: number, e: StartError) => c.sink.write('e', JSON.stringify([n, { code: e.code, message: e.message }]))
|
|
448
|
+
|
|
449
|
+
function fail(c: Conn, s: Sub, e: unknown) {
|
|
450
|
+
remove(c, s)
|
|
451
|
+
if (e instanceof StartError) return c.sink.write('e', JSON.stringify([s.n, { code: e.code, message: e.message, ...(e.extra.issues ? { issues: e.extra.issues } : {}) }]))
|
|
452
|
+
s.options.onDiagnostic?.({ code: 'E_RT', severity: 'error', message: `${s.fn.$id}: ${e instanceof Error ? e.stack ?? e.message : String(e)}` })
|
|
453
|
+
c.sink.write('e', JSON.stringify([s.n, { message: s.options.dev && e instanceof Error ? e.message : 'Subscription failed.' }]))
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function remove(c: Conn, s: Sub) {
|
|
457
|
+
s.ac.abort()
|
|
458
|
+
c.subs.delete(s.n)
|
|
459
|
+
c.held.delete(s)
|
|
460
|
+
for (const x of s.q) c.bytes -= x.size
|
|
461
|
+
for (const t of s.tags) {
|
|
462
|
+
const left = c.tags.get(t)! - 1
|
|
463
|
+
left ? c.tags.set(t, left) : c.tags.delete(t)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function add(c: Conn, entry: SubEntry, request: Request, scoped: ScopeOptions) {
|
|
468
|
+
const [n, kind, id, input, cursor, buffer] = entry
|
|
469
|
+
const fn = fns.get(id)
|
|
470
|
+
if (c.subs.size >= limits.subscriptions) return refuse(c, n, new StartError('R009', `this connection already has ${limits.subscriptions} subscriptions`, 'read fewer keys, or raise `limits.subscriptions`'))
|
|
471
|
+
if (!fn || fn.kind !== (kind === 'q' ? 'query' : 'stream')) return refuse(c, n, new StartError('Q009', `no ${kind === 'q' ? 'query' : 'stream'} \`${id}\` (the page is older than the server): reload`))
|
|
472
|
+
if (c.subs.has(n)) return
|
|
473
|
+
const s: Sub = { n, kind, fn, input, tags: [], buffer: buffer ?? 0, q: [], ac: new AbortController(), request, options: scoped }
|
|
474
|
+
c.subs.set(n, s) // before any await: a `d` that follows finds it
|
|
475
|
+
try {
|
|
476
|
+
const valid = await validate(fn, input)
|
|
477
|
+
if (s.ac.signal.aborted) return
|
|
478
|
+
if (kind === 's') return void stream(c, s, valid, cursor)
|
|
479
|
+
guard(fn, fn.def.auth ? await createScope(request, scoped).ctx() : ({} as Context))
|
|
480
|
+
if (s.ac.signal.aborted) return
|
|
481
|
+
s.tags = await tagsOf(fn, valid, createScope(request, scoped))
|
|
482
|
+
for (const t of s.tags) c.tags.set(t, (c.tags.get(t) ?? 0) + 1)
|
|
483
|
+
if (scoped.dev) {
|
|
484
|
+
liveQueries.set(fn.$id, s.tags)
|
|
485
|
+
if (source) missedFeed(fn, s.tags, source.tables, scoped.onDiagnostic)
|
|
486
|
+
if (!s.tags.length) scoped.onDiagnostic?.({ code: 'W053', severity: 'error', message: `\`${fn.$id}\` is read with \`.live()\` but declares no \`tags\`: no mutation can ever poke it. Add \`tags\` to the query` })
|
|
487
|
+
}
|
|
488
|
+
c.sink.write('a', JSON.stringify([n, s.tags]))
|
|
489
|
+
} catch (e) {
|
|
490
|
+
fail(c, s, e)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** One stream's `run`: resumed with its cursor, restarted without one on a reset. */
|
|
495
|
+
async function stream(c: Conn, s: Sub, input: unknown, cursor?: string) {
|
|
496
|
+
const { signal } = s.ac
|
|
497
|
+
for (;;) {
|
|
498
|
+
try {
|
|
499
|
+
const scope = createScope(s.request, s.options)
|
|
500
|
+
const frame = s.options.dev ? Object.create(scope, { touched: { value: { read: new Set(), write: new Set() } } }) : scope
|
|
501
|
+
await enter(frame, async () => {
|
|
502
|
+
const ctx = await scope.ctx()
|
|
503
|
+
guard(s.fn, ctx)
|
|
504
|
+
const replaced = s.options.handlers?.get(s.fn.$id) as ((input: unknown, ctx: Context, o: { signal: AbortSignal; lastEventId: unknown }) => unknown) | undefined
|
|
505
|
+
const out = replaced ? replaced(input, ctx, { signal, lastEventId: cursor }) : s.fn.def.run!({ input, ctx, signal, lastEventId: cursor })
|
|
506
|
+
for await (const y of out as AsyncIterable<unknown>) {
|
|
507
|
+
if (signal.aborted) return
|
|
508
|
+
const [id, msg] = y instanceof Tracked ? [y.id, y.value] : [null, y]
|
|
509
|
+
if (s.options.dev) await checked(s.fn, msg)
|
|
510
|
+
const raw = JSON.stringify([s.n, id, msg])
|
|
511
|
+
if (raw.length > limits.message) throw new StartError('R008', `\`${s.fn.$id}\` yielded ${raw.length} bytes; a message may be ${limits.message}`, 'send less, or an id the client fetches')
|
|
512
|
+
if (id !== null) cursor = id
|
|
513
|
+
if (!deliver(c, s, raw)) throw RESET
|
|
514
|
+
}
|
|
515
|
+
})
|
|
516
|
+
if (signal.aborted) return
|
|
517
|
+
remove(c, s)
|
|
518
|
+
c.sink.write('f', JSON.stringify([s.n]))
|
|
519
|
+
return
|
|
520
|
+
} catch (e) {
|
|
521
|
+
if (signal.aborted) return
|
|
522
|
+
if (e !== RESET) return fail(c, s, e)
|
|
523
|
+
cursor = undefined
|
|
524
|
+
c.sink.write('r', JSON.stringify([s.n]))
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function change(c: Conn, request: Request, opts: ScopeOptions, body: Body) {
|
|
530
|
+
for (const n of body.d ?? []) {
|
|
531
|
+
const s = c.subs.get(n)
|
|
532
|
+
if (s) remove(c, s)
|
|
533
|
+
}
|
|
534
|
+
await Promise.all((body.s ?? []).map(e => add(c, e, request, opts)))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function replayPokes(c: Conn, after: string) {
|
|
538
|
+
const list = replay(pokes(), after)
|
|
539
|
+
if (!list) return c.sink.write('R', JSON.stringify({ c: cursorOf(seq) }))
|
|
540
|
+
for (const ev of list) {
|
|
541
|
+
const m = JSON.parse(ev.raw) as Poke & { o?: string }
|
|
542
|
+
if (m.o !== c.k) poke(c, picked(c, m), ev.seq)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function recheck(c: Conn) {
|
|
547
|
+
const authed = [...c.subs.values()].filter(s => s.fn.def.auth)
|
|
548
|
+
if (!authed.length) return
|
|
549
|
+
const ctx = await createScope(authed[0]!.request, authed[0]!.options).ctx().catch(() => ({}) as Context)
|
|
550
|
+
if (!conns.has(c.k)) return
|
|
551
|
+
for (const s of authed) {
|
|
552
|
+
try { guard(s.fn, ctx) } catch (e) { fail(c, s, e) }
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function connect(request: Request, scope: Scope, body: Body, sink: Sink, line = false) {
|
|
557
|
+
const opts = scope.options
|
|
558
|
+
const ctx = await scope.ctx()
|
|
559
|
+
const user = (ctx as { user?: { id?: string } }).user?.id
|
|
560
|
+
if (!line && user !== undefined && [...conns.values()].filter(c => c.user === user).length >= limits.connections) {
|
|
561
|
+
throw new StartError('R009', `user \`${user}\` already has ${limits.connections} realtime connections`, 'close tabs, or raise `limits.connections`', { status: 429 })
|
|
562
|
+
}
|
|
563
|
+
const c: Conn = { k: crypto.randomUUID(), sink, user, subs: new Map(), tags: new Map(), held: new Set(), bytes: 0, timers: [] }
|
|
564
|
+
conns.set(c.k, c)
|
|
565
|
+
await pokes().ready.catch(e => {
|
|
566
|
+
conns.delete(c.k)
|
|
567
|
+
throw e
|
|
568
|
+
})
|
|
569
|
+
await want() // before `h`: a client that reads next must not miss a change
|
|
570
|
+
sink.write('h', JSON.stringify({ k: c.k, e: epoch, b: options.build ?? '', c: cursorOf(seq), ...(source && { l: wire() }) }))
|
|
571
|
+
await change(c, request, opts, body)
|
|
572
|
+
if (body.c) replayPokes(c, body.c)
|
|
573
|
+
let ticks = 0
|
|
574
|
+
const every = Math.max(1, Math.round(limits.session / limits.heartbeat))
|
|
575
|
+
c.timers.push(unref(setInterval(() => {
|
|
576
|
+
sink.ping?.()
|
|
577
|
+
if (++ticks % every === 0) void recheck(c)
|
|
578
|
+
}, limits.heartbeat)))
|
|
579
|
+
if (options.maxDuration) c.timers.push(unref(setTimeout(() => bye(c, 0), Math.max(1, options.maxDuration - 10) * 1000)))
|
|
580
|
+
return c
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
bus, limits, cursorOf, channel, replay, unlisten, connect, change, drain, close, closing, epoch, dev: options.dev, maxDuration: options.maxDuration, build: options.build, share: options.share,
|
|
585
|
+
position: () => cursorOf(seq),
|
|
586
|
+
watch(fn) {
|
|
587
|
+
watchers.add(fn)
|
|
588
|
+
void pokes()
|
|
589
|
+
},
|
|
590
|
+
find: k => conns.get(k),
|
|
591
|
+
connections: () => [...conns.values()],
|
|
592
|
+
cursor: () => (pokes().stop ? cursorOf(seq) : undefined),
|
|
593
|
+
ready: () => {
|
|
594
|
+
const on = Promise.resolve(want()).then(rest)
|
|
595
|
+
return Promise.race([Promise.all([pokes().ready.catch(() => {}), on]), new Promise(ok => { unref(setTimeout(ok, 250)) })])
|
|
596
|
+
},
|
|
597
|
+
async poke(tags, origin) {
|
|
598
|
+
if (!tags.length) return
|
|
599
|
+
// In this order: a client's refetch must start after what the barrier claims is covered.
|
|
600
|
+
if (live.mode === 'feed' || live.mode === 'external') await feed?.settle(tags)
|
|
601
|
+
await bus.publish('weft:poke', JSON.stringify({ t: tags, ...(origin && { o: origin }) }))
|
|
602
|
+
},
|
|
603
|
+
push(id, msg) {
|
|
604
|
+
let hit = 0
|
|
605
|
+
for (const c of conns.values()) {
|
|
606
|
+
for (const s of c.subs.values()) {
|
|
607
|
+
if (s.fn.$id !== id) continue
|
|
608
|
+
hit++
|
|
609
|
+
deliver(c, s, JSON.stringify([s.n, null, msg]))
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return hit
|
|
613
|
+
},
|
|
614
|
+
stats: () => ({ connections: conns.size, subscriptions: [...conns.values()].reduce((n, c) => n + c.subs.size, 0), channels: channels.size }),
|
|
615
|
+
live: () => live,
|
|
616
|
+
probed,
|
|
617
|
+
required: !!options.required?.length,
|
|
618
|
+
get refused() { return refused },
|
|
619
|
+
async shutdown() {
|
|
620
|
+
clearTimeout(resting)
|
|
621
|
+
clearInterval(beats)
|
|
622
|
+
await feed?.stop()
|
|
623
|
+
for (const fn of closing) fn()
|
|
624
|
+
for (const c of conns.values()) close(c)
|
|
625
|
+
for (const ch of channels.values()) {
|
|
626
|
+
clearTimeout(ch.idle)
|
|
627
|
+
ch.stop?.()
|
|
628
|
+
}
|
|
629
|
+
channels.clear()
|
|
630
|
+
await bus.close?.()
|
|
631
|
+
},
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function checked(fn: Fn, msg: unknown) {
|
|
636
|
+
const r = await fn.def.message?.['~standard'].validate(msg)
|
|
637
|
+
if (r?.issues) throw new StartError('C002', `\`${fn.$id}\` yielded a message its \`message\` schema rejects: ${r.issues.map(i => `${i.path?.map(p => (typeof p === 'object' ? p.key : p)).join('.') ?? ''}: ${i.message}`).join('; ')}`, 'fix `run`, or the schema', { status: 500 })
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
let current: Hub | undefined
|
|
641
|
+
/** The process's hub: the first caller makes the default one (memory bus). */
|
|
642
|
+
export const hub = (): Hub => (current ??= createHub({}))
|
|
643
|
+
/** Set by `createHandler`, from `src/realtime.server.ts`. */
|
|
644
|
+
export const configure = (options: HubOptions) => {
|
|
645
|
+
void current?.shutdown() // a dev server's reload makes a new handler
|
|
646
|
+
return (current = createHub(options))
|
|
647
|
+
}
|
|
648
|
+
/** This instance's connections, subscriptions and listened channels: for a health route. */
|
|
649
|
+
export const realtimeStats = () => hub().stats()
|
|
650
|
+
/**
|
|
651
|
+
* For a health route: `ok` is false when required live queries have no working feed, now or at startup. A server that
|
|
652
|
+
* started fine and lost its database mid-flight keeps running, and says so here: restarting it wouldn't bring the
|
|
653
|
+
* database back, and it recovers by itself.
|
|
654
|
+
*/
|
|
655
|
+
export function liveHealth(): LiveState & { ok: boolean; required: boolean } {
|
|
656
|
+
const h = hub(), s = h.live(), required = h.required
|
|
657
|
+
return { ...s, required, ok: !required || ((s.mode === 'feed' || s.mode === 'external') && !s.down) }
|
|
658
|
+
}
|
|
659
|
+
/** @weft-js/core/test, after each test. */
|
|
660
|
+
export const resetHub = () => {
|
|
661
|
+
const old = current
|
|
662
|
+
current = undefined
|
|
663
|
+
return old?.shutdown()
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ---- topics ---------------------------------------------------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
/** What `topic()` returns: publish on the server, follow with `.live`. */
|
|
669
|
+
export interface Topic<K, M> {
|
|
670
|
+
/** Fan a message out to every subscriber, on every instance. */
|
|
671
|
+
publish(key: K, message: M): Promise<void>
|
|
672
|
+
/**
|
|
673
|
+
* The messages of `keys`. Listens from this call, so a read that follows can't miss one (R006's gap): call it before
|
|
674
|
+
* reading a backlog. With `after`, replays what the ring still has; outside the window the stream resets.
|
|
675
|
+
*/
|
|
676
|
+
subscribe(keys: K | K[], options?: { signal?: AbortSignal | undefined; after?: string | undefined }): AsyncGenerator<Tracked<M>, void, void>
|
|
677
|
+
readonly name: string
|
|
678
|
+
readonly message?: StandardSchemaV1<unknown, M>
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
type Out<S> = S extends StandardSchemaV1<unknown, infer O> ? O : never
|
|
682
|
+
|
|
683
|
+
/** A typed server channel: mutations, jobs and webhooks publish, streams read. */
|
|
684
|
+
export function topic<K extends StandardSchemaV1<unknown, string | number>, M extends StandardSchemaV1>(name: string, def: { key?: K; message: M }): Topic<Out<K>, Out<M>> {
|
|
685
|
+
const path = (key: unknown) => `weft:t:${name}:${String(key)}`
|
|
686
|
+
return {
|
|
687
|
+
name,
|
|
688
|
+
message: def.message as StandardSchemaV1<unknown, Out<M>>,
|
|
689
|
+
async publish(key, message) {
|
|
690
|
+
const raw = JSON.stringify(message)
|
|
691
|
+
const h = hub()
|
|
692
|
+
if (raw.length > h.limits.message) throw new StartError('R008', `a \`${name}\` message is ${raw.length} bytes; a message may be ${h.limits.message}`, 'publish less, or an id subscribers fetch')
|
|
693
|
+
await h.bus.publish(path(key), raw)
|
|
694
|
+
},
|
|
695
|
+
subscribe(keys, { signal, after } = {}) {
|
|
696
|
+
const h = hub()
|
|
697
|
+
if (currentScope()?.touched?.read.size) currentScope()!.options.onDiagnostic?.({ code: 'R006', severity: 'warning', message: `a stream read ${[...currentScope()!.touched!.read].join(', ')} before \`${name}.subscribe()\`: anything published in between is lost. Call \`subscribe\` first, or pass \`after\`` })
|
|
698
|
+
const chans = (Array.isArray(keys) ? keys : [keys]).map(k => h.channel(path(k)))
|
|
699
|
+
const q: Ev[] = []
|
|
700
|
+
let wake: (() => void) | undefined
|
|
701
|
+
let lost = false
|
|
702
|
+
const on = (ev: Ev | null) => {
|
|
703
|
+
if (ev) q.push(ev)
|
|
704
|
+
else lost = true
|
|
705
|
+
if (q.length > h.limits.ring) lost = true
|
|
706
|
+
wake?.()
|
|
707
|
+
}
|
|
708
|
+
for (const ch of chans) ch.listeners.add(on) // before anything is read: that's the rule
|
|
709
|
+
const stop = () => { for (const ch of chans) h.unlisten(ch, on) }
|
|
710
|
+
const abort = () => wake?.()
|
|
711
|
+
signal?.addEventListener('abort', abort)
|
|
712
|
+
return (async function* () {
|
|
713
|
+
try {
|
|
714
|
+
await Promise.all(chans.map(c => c.ready))
|
|
715
|
+
if (after) {
|
|
716
|
+
const seen = new Set(q.map(e => e.seq))
|
|
717
|
+
for (const ch of chans) {
|
|
718
|
+
const back = h.replay(ch, after)
|
|
719
|
+
if (!back) throw RESET
|
|
720
|
+
for (const ev of back) if (!seen.has(ev.seq)) q.push(ev)
|
|
721
|
+
}
|
|
722
|
+
q.sort((a, b) => a.seq - b.seq)
|
|
723
|
+
}
|
|
724
|
+
for (;;) {
|
|
725
|
+
if (signal?.aborted) return
|
|
726
|
+
if (lost) throw RESET
|
|
727
|
+
const ev = q.shift()
|
|
728
|
+
if (ev) yield tracked(h.cursorOf(ev.seq), JSON.parse(ev.raw))
|
|
729
|
+
else await new Promise<void>(ok => { wake = ok })
|
|
730
|
+
}
|
|
731
|
+
} finally {
|
|
732
|
+
signal?.removeEventListener('abort', abort)
|
|
733
|
+
stop()
|
|
734
|
+
}
|
|
735
|
+
})()
|
|
736
|
+
},
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ---- the HTTP route -------------------------------------------------------------------------------------------------
|
|
741
|
+
|
|
742
|
+
const enc = new TextEncoder()
|
|
743
|
+
/** Whether a request body has the shape of `Body`. */
|
|
744
|
+
export const validBody = (input: unknown): input is Body => {
|
|
745
|
+
const b = input as Partial<Record<keyof Body, unknown>> | null | undefined
|
|
746
|
+
return !!b && typeof b === 'object' && !Array.isArray(b) &&
|
|
747
|
+
(b.k === undefined || typeof b.k === 'string') && (b.c === undefined || typeof b.c === 'string') &&
|
|
748
|
+
(b.d === undefined || (Array.isArray(b.d) && b.d.every((n: unknown) => typeof n === 'number'))) &&
|
|
749
|
+
(b.s === undefined || (Array.isArray(b.s) && b.s.every((e: unknown) => Array.isArray(e) && typeof e[0] === 'number' && (e[1] === 'q' || e[1] === 's') && typeof e[2] === 'string')))
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** `/_weft/rt` (research 30 §4): opens a connection, or changes one. Same proof as a mutation, plus `origins`. */
|
|
753
|
+
export async function rtRoute(request: Request, scope: Scope, origins: string[] = []): Promise<Response> {
|
|
754
|
+
const refuse = (code: string, message: string, status: number) => {
|
|
755
|
+
scope.options.onDiagnostic?.({ code, severity: 'error', message })
|
|
756
|
+
return Response.json({ e: { code, message } }, { status, headers: { 'cache-control': 'no-store' } })
|
|
757
|
+
}
|
|
758
|
+
if (request.method !== 'POST') return refuse('Q010', 'Q010: `/_weft/rt` needs POST', 405)
|
|
759
|
+
const url = new URL(request.url)
|
|
760
|
+
const origin = request.headers.get('origin')
|
|
761
|
+
const site = request.headers.get('sec-fetch-site')
|
|
762
|
+
const allowed = origin && origins.includes(origin)
|
|
763
|
+
if (!allowed && ((origin && origin !== url.origin) || (site && site !== 'same-origin' && site !== 'none'))) return refuse('R010', `R010: origin \`${origin ?? site}\` may not open a realtime connection: list it in \`realtime({ origins })\``, 403)
|
|
764
|
+
if (request.headers.get('x-weft') !== '1' || !request.headers.get('content-type')?.startsWith('application/json')) return refuse('Q010', 'Q010: `/_weft/rt` without the CSRF proof (POST + JSON + `x-weft: 1`)', 403)
|
|
765
|
+
const h = hub()
|
|
766
|
+
const raw = await request.text()
|
|
767
|
+
if (raw.length > h.limits.body) return refuse('R008', `R008: a realtime request of ${raw.length} bytes; the limit is ${h.limits.body}`, 413)
|
|
768
|
+
let body: Body
|
|
769
|
+
try {
|
|
770
|
+
body = JSON.parse(raw)
|
|
771
|
+
} catch {
|
|
772
|
+
return refuse('Q002', 'Q002: `/_weft/rt` got a body that isn\'t JSON', 400)
|
|
773
|
+
}
|
|
774
|
+
// A tab that didn't dial the shared socket asks who it is now (link.ts).
|
|
775
|
+
if ((body as { w?: 1 })?.w === 1) return Response.json({ u: await sessionOf(await scope.ctx()) }, { headers: { 'cache-control': 'no-store' } })
|
|
776
|
+
if (!validBody(body)) return refuse('Q002', 'Q002: `/_weft/rt` got a body of the wrong shape', 400)
|
|
777
|
+
if (body.k) {
|
|
778
|
+
const c = h.find(body.k)
|
|
779
|
+
const user = c && ((await scope.ctx()) as { user?: { id?: string } }).user?.id
|
|
780
|
+
if (!c || c.user !== user) return refuse('Q009', 'Q009: no such realtime connection (reconnect with the whole set)', 404)
|
|
781
|
+
await h.change(c, request, scope.options, body)
|
|
782
|
+
return new Response(null, { status: 204 })
|
|
783
|
+
}
|
|
784
|
+
let conn: Conn | undefined
|
|
785
|
+
let queued = 0
|
|
786
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>
|
|
787
|
+
const push = (text: string) => {
|
|
788
|
+
const bytes = enc.encode(text)
|
|
789
|
+
queued += bytes.length
|
|
790
|
+
controller.enqueue(bytes)
|
|
791
|
+
}
|
|
792
|
+
const sink: Sink = {
|
|
793
|
+
write: (event, data) => push(`event: ${event}\ndata: ${data}\n\n`),
|
|
794
|
+
ping: () => push(': ping\n\n'),
|
|
795
|
+
backlog: () => queued,
|
|
796
|
+
close: () => { try { controller.close() } catch {} },
|
|
797
|
+
}
|
|
798
|
+
const out = new ReadableStream<Uint8Array>(
|
|
799
|
+
{
|
|
800
|
+
start: c => void (controller = c),
|
|
801
|
+
// The reader took what was queued: the strategy's hwm of 0 means every pull is an empty queue.
|
|
802
|
+
pull() {
|
|
803
|
+
queued = 0
|
|
804
|
+
if (conn) h.drain(conn)
|
|
805
|
+
},
|
|
806
|
+
cancel() {
|
|
807
|
+
if (conn) h.close(conn)
|
|
808
|
+
else closed = true
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
{ highWaterMark: 0 },
|
|
812
|
+
)
|
|
813
|
+
let closed = false
|
|
814
|
+
try {
|
|
815
|
+
conn = await h.connect(request, scope, body, sink)
|
|
816
|
+
} catch (e) {
|
|
817
|
+
if (e instanceof StartError) return refuse(e.code, e.message, (e.extra.status as number) ?? 500)
|
|
818
|
+
throw e
|
|
819
|
+
}
|
|
820
|
+
if (closed) h.close(conn)
|
|
821
|
+
return new Response(out, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store', 'x-accel-buffering': 'no', connection: 'keep-alive' } })
|
|
822
|
+
}
|