@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.
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 +82 -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 +616 -0
  19. package/src/client.tsx +20 -0
  20. package/src/contract.mjs +308 -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 +456 -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 +144 -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 +198 -0
  63. package/src/rust.ts +333 -0
  64. package/src/server.tsx +1014 -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/channel.ts ADDED
@@ -0,0 +1,616 @@
1
+ // `channel` and `sharedState` (research 32): definitions on both sides, and the browser's half. One line per tab on the
2
+ // browser's one socket (link.ts), one join per id + params, shared by every reader and held by this module, so a remount within `keep`
3
+ // finds its messages. The socket server is socket.ts, and `room.in(params).publish` reaches it through
4
+ // `runtime.channels`, so nothing of the server ships here.
5
+
6
+ import { Box, DEV, batch, collect as boxes, createRoot, derived, effect, getOwner, onCleanup, state, untrack, type Derived, type State } from '@weft-js/core'
7
+ import { HANDOVER, openLine, socketDown } from './link'
8
+ import { currentScope, fns, FnError, keyOf, net, rt, runtime, skip, StartError, track, type Context, type Fn, type Skip, type StandardSchemaV1 } from './data'
9
+
10
+ // ---- definitions ----------------------------------------------------------------------------------------------------
11
+
12
+ type In<S> = S extends StandardSchemaV1<infer I, unknown> ? I : void
13
+ type Out<S> = S extends StandardSchemaV1<unknown, infer O> ? O : undefined
14
+ type Schemas = Record<string, StandardSchemaV1>
15
+ type Inputs<T extends Schemas> = { [K in keyof T]: In<T[K]> }
16
+ type Outputs<T extends Schemas> = { [K in keyof T]: Out<T[K]> }
17
+
18
+ /** Where a connection stands; `unauthorized` waits for `rejoin`. */
19
+ export type ChannelStatus = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'unauthorized'
20
+
21
+ /** `room.publish`: who a message goes to, and whether it can be replayed. */
22
+ export interface PublishOptions {
23
+ /** Not back to the sender. */
24
+ exceptSelf?: boolean
25
+ /** Only to this connection, wherever it is: the `id` a handler was given. */
26
+ to?: string
27
+ /** Not kept for a resume: a cursor, a heartbeat, anything the next one replaces. */
28
+ volatile?: boolean
29
+ }
30
+
31
+ /** A channel instance as a server handler has it. */
32
+ export interface Room<P, M> {
33
+ params: P
34
+ /** To everyone in the room, on every instance. */
35
+ publish<K extends keyof M & string>(name: K, message: M[K], options?: PublishOptions): Promise<void>
36
+ }
37
+
38
+ /** `channel()`: the params, the messages each side may send, and who may join. */
39
+ export interface ChannelDef<P extends StandardSchemaV1 | undefined, C extends Schemas, S extends Schemas> {
40
+ params?: P
41
+ /** Required (R001). Runs on every join and at each session re-check. `() => true` must be written out. */
42
+ authorize: (args: { ctx: Context; params: Out<P> }) => boolean | Promise<boolean>
43
+ /** Client → server, always validated. */
44
+ client?: C
45
+ /** Server → client, validated in dev and tests. */
46
+ server?: S
47
+ /** `id` is the sender's connection, the same one `left` gets. */
48
+ on?: { [K in keyof C]?: (args: { msg: Out<C[K]>; id: string; ctx: Context; params: Out<P>; room: Room<Out<P>, Inputs<S>> }) => unknown }
49
+ /** A connection stopped being in the room: it left, closed, or lost `authorize`. Not called if its instance dies. */
50
+ left?: (args: { id: string; ctx: Context; params: Out<P>; room: Room<Out<P>, Inputs<S>> }) => unknown
51
+ limits?: { perSecond?: number }
52
+ $id?: string
53
+ }
54
+
55
+ /** A live join of a channel, from `.connect()`. */
56
+ export interface Connection<C, S> {
57
+ readonly status: Derived<ChannelStatus>
58
+ /** Which room this is. Changes when a function argument moves to another, even one already open. */
59
+ readonly room: Derived<string | undefined>
60
+ /** The last refusal or failure. */
61
+ readonly error: unknown
62
+ /** Ticks when the server couldn't resume: messages may be missing, so refetch what the room's history is. */
63
+ readonly resets: Derived<number>
64
+ /** Keep a room's messages of one name, in the cache: a remount within `keep` finds them. `key` drops a repeat. */
65
+ collect<K extends keyof S & string>(name: K, options?: { max?: number; key?: (message: S[K]) => unknown }): Derived<S[K][]>
66
+ /** Each message of one name, until the owner is disposed. */
67
+ on<K extends keyof S & string>(name: K, fn: (message: S[K]) => void): () => void
68
+ onReset(fn: () => void): () => void
69
+ /** Resolves on the server's ack, rejects with what it refused. Waits up to 10 s for the socket; never retries. */
70
+ send<K extends keyof C & string>(name: K, message: C[K]): Promise<void>
71
+ /** Join again after `unauthorized`. */
72
+ retry(): void
73
+ }
74
+
75
+ type Arg<P> = P | (() => P | Skip)
76
+ /** How long a join outlives the last component using it. */
77
+ export interface ConnectOptions { keep?: string | number }
78
+
79
+ /** What `channel()` returns: connect from a page, publish from the server. */
80
+ export interface Channel<P, C, S> {
81
+ /** In a component or scope. A function argument re-joins when what it reads changes; `skip` leaves. */
82
+ connect(...args: [P] extends [void] ? [params?: undefined | (() => undefined | Skip), options?: ConnectOptions] : [params: Arg<P>, options?: ConnectOptions]): Connection<C, S>
83
+ /** On the server, from a mutation or a job. */
84
+ in(...args: [P] extends [void] ? [] : [params: P]): { publish<K extends keyof S & string>(name: K, message: S[K]): Promise<void> }
85
+ readonly kind: 'channel'
86
+ readonly $id: string
87
+ }
88
+
89
+ const FN = Symbol.for('weft.fn')
90
+ let auto = 0
91
+
92
+ /** A named room of schema-checked messages over the shared socket. */
93
+ export function channel<P extends StandardSchemaV1 | undefined = undefined, C extends Schemas = {}, S extends Schemas = {}>(def: ChannelDef<P, C, S>): Channel<In<P>, Inputs<C>, Outputs<S>> {
94
+ const id = def.$id ?? `c${++auto}`
95
+ if (!(def as { $stub?: boolean }).$stub && !def.authorize) throw new StartError('R001', `\`${id}\` has no \`authorize\`: anyone could join it`, 'add `authorize` (`() => true` must be written out)')
96
+ const fn: Fn = { kind: 'channel', $id: id, def: def as Fn['def'] }
97
+ fns.set(id, fn)
98
+ return {
99
+ kind: 'channel', $id: id, [FN]: fn,
100
+ connect: (params?: unknown, options?: ConnectOptions) => connect(fn, params, options),
101
+ in: (params?: unknown) => ({ publish: (name: string, message: unknown) => runtime.channels!.publish(fn, params, name, message) }),
102
+ } as never
103
+ }
104
+
105
+ /** `sharedState()`: the key and value schemas, the first value, and who reads and writes. */
106
+ export interface SharedDef<K extends StandardSchemaV1, V extends StandardSchemaV1> {
107
+ key: K
108
+ value: V
109
+ /** What a reader has until the server says otherwise. Kept in the client bundle, so plain data. */
110
+ initial: Out<V>
111
+ /** Both required (R001). */
112
+ authorize: { read: (args: { ctx: Context; key: Out<K> }) => boolean | Promise<boolean>; write: (args: { ctx: Context; key: Out<K> }) => boolean | Promise<boolean> }
113
+ $id?: string
114
+ }
115
+
116
+ /** A box in a scope: reads as a value, and a write is optimistic, sent, and last writer wins on the server. */
117
+ export type SharedBox<T> = State<T> & { readonly status: Derived<ChannelStatus>; readonly error: unknown }
118
+
119
+ /** What `sharedState()` returns: read a value on a page, set it from the server. */
120
+ export interface SharedState<K, V> {
121
+ connect(key: K | (() => K | Skip), options?: ConnectOptions): SharedBox<V>
122
+ /** On the server: write it from a mutation or a job. */
123
+ in(key: K): { set(value: V): Promise<void> }
124
+ readonly kind: 'shared'
125
+ readonly $id: string
126
+ }
127
+
128
+ /** One value per key that every client holding that key sees change. */
129
+ export function sharedState<K extends StandardSchemaV1, V extends StandardSchemaV1>(name: string, def: SharedDef<K, V>): SharedState<In<K>, Out<V>> {
130
+ const id = def.$id ?? `shared:${name}`
131
+ if (!(def as { $stub?: boolean }).$stub && (!def.authorize?.read || !def.authorize?.write)) throw new StartError('R001', `\`${id}\` needs \`authorize: { read, write }\`: anyone could read or write it`, '`() => true` must be written out')
132
+ const fn: Fn = { kind: 'shared', $id: id, def }
133
+ fns.set(id, fn)
134
+ return {
135
+ kind: 'shared', $id: id, [FN]: fn,
136
+ connect: (key: unknown, options?: ConnectOptions) => shared(fn, key, options),
137
+ in: (key: unknown) => ({ set: (value: unknown) => runtime.channels!.set(fn, key, value) }),
138
+ } as never
139
+ }
140
+
141
+ // ---- joins ----------------------------------------------------------------------------------------------------------
142
+
143
+ interface ListSpec { name: string; max: number; key?: ((m: never) => unknown) | undefined }
144
+ interface List extends ListSpec { box: State<unknown[]> }
145
+ interface Handle { on: Map<string, Set<(m: never) => void>>; resets: Set<() => void>; value?(v: { v: unknown; at: number }): void }
146
+ interface Join {
147
+ n: number
148
+ key: string
149
+ fn: Fn
150
+ params: unknown
151
+ refs: number
152
+ timer?: ReturnType<typeof setTimeout> | undefined
153
+ cursor?: string | undefined
154
+ joined: boolean
155
+ refused: boolean
156
+ status: State<ChannelStatus>
157
+ error: State<unknown>
158
+ resets: State<number>
159
+ lists: Map<string, List>
160
+ handles: Set<Handle>
161
+ /** `send`s waiting for the join. */
162
+ waiting: Set<() => void>
163
+ }
164
+
165
+ const joins = new Map<string, Join>()
166
+ const byN = new Map<number, Join>()
167
+ let nth = 0
168
+
169
+ const KEEP = 30_000
170
+ const ms = (keep: string | number = KEEP): number => {
171
+ if (typeof keep === 'number') return keep
172
+ const m = /^(\d+)(ms|s|m)$/.exec(keep)
173
+ return m ? +m[1]! * { ms: 1, s: 1000, m: 60_000 }[m[2] as 'ms' | 's' | 'm'] : Infinity
174
+ }
175
+
176
+ function acquire(fn: Fn, params: unknown): Join {
177
+ const key = keyOf(fn.$id, params)
178
+ let j = joins.get(key)
179
+ if (!j) {
180
+ j = createRoot(() => ({ n: ++nth, key, fn, params, refs: 0, joined: false, refused: false, status: state<ChannelStatus>('connecting'), error: state<unknown>(undefined), resets: state(0), lists: new Map(), handles: new Set(), waiting: new Set() }), null)
181
+ if (DEV && net() && !net()!.resets.includes(reset)) net()!.resets.push(reset)
182
+ joins.set(key, j)
183
+ byN.set(j.n, j)
184
+ if (live) enter(j)
185
+ else start()
186
+ }
187
+ clearTimeout(j.timer)
188
+ j.timer = undefined
189
+ j.refs++
190
+ return j
191
+ }
192
+
193
+ function release(j: Join, keep?: string | number) {
194
+ if (--j.refs) return
195
+ const t = ms(keep)
196
+ if (t === Infinity) return
197
+ if (t) j.timer = setTimeout(() => drop(j), t)
198
+ else drop(j)
199
+ }
200
+
201
+ function drop(j: Join) {
202
+ joins.delete(j.key)
203
+ byN.delete(j.n)
204
+ j.status.value = 'closed'
205
+ if (live) send(['l', j.n])
206
+ for (const w of Array.from(j.waiting)) w()
207
+ if (!joins.size) live?.close()
208
+ }
209
+
210
+ function hold(j: Join, keep?: string | number): () => void {
211
+ const token = {}
212
+ const stop = () => {
213
+ net()?.open.delete(token)
214
+ release(j, keep)
215
+ }
216
+ if (DEV) net()?.open.set(token, [`channel \`${j.key}\``, stop])
217
+ return stop
218
+ }
219
+
220
+ function listOf(j: Join, spec: ListSpec): List {
221
+ const id = `${spec.name}:${spec.max}:${spec.key ? String(spec.key) : ''}`
222
+ let l = j.lists.get(id)
223
+ if (!l) j.lists.set(id, (l = createRoot(() => ({ ...spec, box: state<unknown[]>([]) }), null)))
224
+ return l
225
+ }
226
+
227
+ const NONE: never[] = []
228
+ type View = Join | undefined
229
+
230
+ /** The part `connect` and `shared` have in common: the current join, as the argument moves. */
231
+ function follow(fn: Fn, arg: unknown, options: ConnectOptions | undefined, handle: Handle, joined?: (j: Join) => void): State<View> {
232
+ const view = state<View>(undefined)
233
+ effect(() => {
234
+ const params = typeof arg === 'function' ? (arg as () => unknown)() : arg
235
+ if (params === skip) return undefined
236
+ return untrack(() => {
237
+ const j = acquire(fn, params)
238
+ j.handles.add(handle)
239
+ joined?.(j)
240
+ const stop = hold(j, options?.keep)
241
+ onCleanup(() => {
242
+ j.handles.delete(handle)
243
+ stop()
244
+ })
245
+ return j
246
+ })
247
+ }, now => { view.value = now })
248
+ return view
249
+ }
250
+
251
+ function owned(fn: Fn) {
252
+ if (!getOwner()) throw new StartError('W051', DEV ? `\`${fn.$id}.connect()\` was called outside a component or scope: nothing would close it` : fn.$id, DEV ? 'call it in a component, a `defineState` setup or `createRoot(dispose => …)`' : undefined)
253
+ }
254
+
255
+ /** `@weft-js/start/share`'s way in: a join that nothing on the page declared, so a tab shares this socket and its lifecycle. */
256
+ export const connectBuiltIn = ($id: string) => connect({ kind: 'channel', $id, def: {} }, undefined, { keep: 0 })
257
+
258
+ function connect(fn: Fn, arg: unknown, options?: ConnectOptions): Connection<Record<string, never>, Record<string, never>> {
259
+ owned(fn)
260
+ if (currentScope()) {
261
+ const closed = { value: 'connecting' as ChannelStatus }
262
+ return { status: closed, room: { value: undefined }, error: undefined, resets: { value: 0 }, collect: () => ({ value: NONE }), on: () => () => {}, onReset: () => () => {}, send: () => Promise.reject(new FnError({ message: 'a channel has no socket on the server' }, 500)), retry() {} }
263
+ }
264
+ const handle: Handle = { on: new Map(), resets: new Set() }
265
+ const wanted: ListSpec[] = []
266
+ const view = follow(fn, arg, options, handle, j => { for (const w of wanted) listOf(j, w) })
267
+ return {
268
+ status: derived(() => view.value?.status.value ?? 'closed'),
269
+ room: derived(() => view.value?.key),
270
+ get error() { return view.value?.error.value },
271
+ resets: derived(() => view.value?.resets.value ?? 0),
272
+ collect(name, { max = 200, key } = {}) {
273
+ const spec: ListSpec = { name, max, key }
274
+ wanted.push(spec)
275
+ const had = untrack(() => view.value)
276
+ if (had) listOf(had, spec)
277
+ return derived(() => {
278
+ const j = view.value
279
+ return (j ? listOf(j, spec).box.value : NONE) as never[]
280
+ })
281
+ },
282
+ on(name, listener) {
283
+ const set = handle.on.get(name) ?? new Set()
284
+ handle.on.set(name, set)
285
+ set.add(listener)
286
+ const stop = () => void set.delete(listener)
287
+ onCleanup(stop)
288
+ return stop
289
+ },
290
+ onReset(listener) {
291
+ handle.resets.add(listener)
292
+ const stop = () => void handle.resets.delete(listener)
293
+ onCleanup(stop)
294
+ return stop
295
+ },
296
+ send(name, message) {
297
+ const j = untrack(() => view.value)
298
+ return j ? post(j, name, message) : Promise.reject(new FnError({ message: 'this channel is closed' }, 400))
299
+ },
300
+ retry() {
301
+ const j = untrack(() => view.value)
302
+ if (j?.refused) rejoin(j)
303
+ },
304
+ }
305
+ }
306
+
307
+ /** `Box.w` is the one way a write gets in, so a subclass sees every write, and `Instance` never touches it. */
308
+ class Shared<T> extends Box<T> {
309
+ declare remote?: boolean
310
+ declare pending: number
311
+ declare base: { v: T; at: number }
312
+ declare view: State<View>
313
+ declare statusOf: Derived<ChannelStatus>
314
+ declare problem: State<unknown>
315
+ get status() { return this.statusOf }
316
+ get error() { return this.problem.value }
317
+ /** A server value: applied unless a local write is still on its way. */
318
+ apply(v: { v: T; at: number }) {
319
+ if (v.at >= this.base.at) this.base = v
320
+ if (!this.pending) this.quiet(this.base.v)
321
+ }
322
+ quiet(v: T) {
323
+ this.remote = true
324
+ this.w(v)
325
+ this.remote = false
326
+ }
327
+ override w(v: T) {
328
+ if (this.remote) return super.w(v)
329
+ const j = untrack(() => this.view.value)
330
+ if (!j) return super.w(v)
331
+ super.w(v)
332
+ this.pending++
333
+ this.problem.value = undefined
334
+ void track(post(j, '$set', v).then(() => { this.pending-- }, e => {
335
+ this.pending--
336
+ this.problem.value = e
337
+ }).finally(() => { if (!this.pending) this.quiet(this.base.v) }))
338
+ }
339
+ }
340
+
341
+ function shared(fn: Fn, arg: unknown, options?: ConnectOptions): SharedBox<unknown> {
342
+ owned(fn)
343
+ const initial = fn.def.initial
344
+ const [b] = boxes(() => new Shared<unknown>(initial))
345
+ b.pending = 0
346
+ b.base = { v: initial, at: 0 }
347
+ b.problem = state<unknown>(undefined)
348
+ if (currentScope()) {
349
+ b.statusOf = { value: 'connecting' }
350
+ b.view = state<View>(undefined)
351
+ return b
352
+ }
353
+ const handle: Handle = { on: new Map(), resets: new Set(), value: v => b.apply(v) }
354
+ b.view = follow(fn, arg, options, handle, () => { b.base = { v: initial, at: 0 } })
355
+ b.statusOf = derived(() => b.view.value?.status.value ?? 'closed')
356
+ return b
357
+ }
358
+
359
+ // ---- sends ----------------------------------------------------------------------------------------------------------
360
+
361
+ const ACK_WAIT = 10_000
362
+ const acks = new Map<number, { ok: () => void; no: (e: unknown) => void; n: number }>()
363
+ let ackId = 0
364
+
365
+ async function post(j: Join, name: string, message: unknown): Promise<void> {
366
+ if (!j.joined && !j.refused) {
367
+ await new Promise<void>(ok => {
368
+ const done = () => {
369
+ clearTimeout(t)
370
+ j.waiting.delete(done)
371
+ ok()
372
+ }
373
+ const t = setTimeout(done, ACK_WAIT)
374
+ j.waiting.add(done)
375
+ })
376
+ }
377
+ if (!j.joined) throw new FnError({ ...(j.refused && { code: 'R011' }), message: j.refused ? 'not allowed in this channel' : 'no connection: not sent' }, j.refused ? 403 : 503)
378
+ const ack = ++ackId
379
+ const p = new Promise<void>((ok, no) => { acks.set(ack, { ok, no, n: j.n }) })
380
+ send(['m', j.n, ack, name, message])
381
+ return track(p)
382
+ }
383
+
384
+ // ---- the socket ------------------------------------------------------------------------------------------------------
385
+
386
+ /** What the server sends a channel's client: the first element says which. */
387
+ export type Frame =
388
+ | ['h', { b?: string }]
389
+ | ['j', number, string | undefined]
390
+ | ['m', number, string | undefined, string, never]
391
+ | ['r', number, string | undefined]
392
+ | ['e', number, { code?: string; message?: string }]
393
+ | ['a', number, number, { code?: string; message?: string } | undefined]
394
+
395
+ /** One end of a socket, as a channel holds it. */
396
+ export interface SocketLink {
397
+ send(text: string): void
398
+ close(): void
399
+ }
400
+
401
+ /** This tab's line on the browser's one socket (link.ts). `done` gets the close code. */
402
+ function browser(onFrame: (d: Frame) => void, closed: (code: number) => void): SocketLink {
403
+ return openLine('c', '', text => onFrame(JSON.parse(text)), closed)
404
+ }
405
+
406
+ /** @weft-js/core/test only: an opaque specifier, so a bundler keeps neither the fake nor the socket server (research 30 §10). */
407
+ const FAKE = './channel-fake.ts' // with its extension: nothing resolves this one for a linked package
408
+ function fake(onFrame: (d: Frame) => void, closed: (code: number) => void): SocketLink {
409
+ let link: SocketLink | undefined
410
+ let up!: () => void
411
+ void track(new Promise<void>(ok => { up = ok }))
412
+ const loading = import(/* @vite-ignore */ FAKE).then(m => m.open(onFrame, closed, up)).then(l => (link = l), e => (up(), closed(1006), Promise.reject(e))).catch(() => {})
413
+ return { send: t => void track(loading.then(() => link?.send(t))), close: () => { void loading.then(() => link?.close()) } }
414
+ }
415
+
416
+ const BASE = 500, CAP = 30_000, PING = 20_000, DEAD = 45_000, HIDDEN = 300_000
417
+
418
+ let live: SocketLink | undefined
419
+ let open = false
420
+ let running = false
421
+ let attempt = 0
422
+ let build: string | undefined
423
+ let nudge: (() => void) | undefined
424
+ let parked = false
425
+ let hiddenAt: ReturnType<typeof setTimeout> | undefined
426
+
427
+ /** A leader tab can be hidden while a sibling is looking: `@weft-js/start/share` says when this socket must stay. */
428
+ export const keepOpen: { stay?: (() => boolean) | undefined } = {}
429
+
430
+ const send = (f: unknown[]) => live?.send(JSON.stringify(f))
431
+ const statusAll = (s: ChannelStatus) => { for (const j of joins.values()) if (!j.refused) j.status.value = s }
432
+
433
+ function enter(j: Join) {
434
+ if (!open || !live) return
435
+ send(['j', j.n, j.fn.$id, j.params, j.cursor])
436
+ }
437
+
438
+ function rejoin(j: Join) {
439
+ j.refused = false
440
+ j.status.value = 'connecting'
441
+ if (running) enter(j)
442
+ else start()
443
+ }
444
+
445
+ function start() {
446
+ if (running) return
447
+ running = true
448
+ queueMicrotask(() => void run())
449
+ }
450
+
451
+ function dispatch(j: Join, name: string, msg: never) {
452
+ if (name === '$v') {
453
+ for (const h of j.handles) h.value?.(msg)
454
+ return
455
+ }
456
+ for (const l of j.lists.values()) {
457
+ if (l.name !== name) continue
458
+ const was = untrack(() => l.box.value)
459
+ const key = l.key?.(msg)
460
+ const at = l.key ? was.findIndex(m => l.key!(m as never) === key) : -1
461
+ const next = at >= 0 ? was.map((m, i) => (i === at ? msg : m)) : [...was, msg]
462
+ l.box.value = next.length > l.max ? next.slice(-l.max) : next
463
+ }
464
+ for (const h of j.handles) for (const f of h.on.get(name) ?? []) f(msg)
465
+ }
466
+
467
+ function frame(d: Frame) {
468
+ batch(() => {
469
+ const j = typeof d[1] === 'number' ? byN.get(d[1]) : undefined
470
+ if (d[0] === 'h') {
471
+ if (build && d[1].b && d[1].b !== build) rt.stale.value = true
472
+ build = d[1].b
473
+ open = true
474
+ attempt = 0
475
+ for (const x of joins.values()) if (!x.refused) enter(x)
476
+ } else if (d[0] === 'j' && j) {
477
+ j.cursor ??= d[2]
478
+ j.joined = true
479
+ j.error.value = undefined
480
+ j.status.value = 'open'
481
+ for (const w of Array.from(j.waiting)) w()
482
+ } else if (d[0] === 'm' && j) {
483
+ j.cursor = d[2]
484
+ dispatch(j, d[3], d[4])
485
+ } else if (d[0] === 'r' && j) {
486
+ j.cursor = d[2]
487
+ for (const l of j.lists.values()) l.box.value = []
488
+ j.resets.value++
489
+ for (const h of j.handles) for (const f of h.resets) f()
490
+ } else if (d[0] === 'e' && j) {
491
+ j.joined = false
492
+ j.refused = true
493
+ j.error.value = new FnError(d[2], d[2].code === 'R011' ? 403 : 400)
494
+ j.status.value = d[2].code === 'R011' ? 'unauthorized' : 'closed'
495
+ for (const w of Array.from(j.waiting)) w()
496
+ } else if (d[0] === 'a') {
497
+ const p = acks.get(d[2])
498
+ acks.delete(d[2])
499
+ if (d[3]) p?.no(new FnError(d[3], d[3].code === 'R011' ? 403 : 400))
500
+ else p?.ok()
501
+ }
502
+ })
503
+ }
504
+
505
+ const sleep = (t: number) => new Promise<void>(ok => {
506
+ const timer = setTimeout(ok, t)
507
+ nudge = () => {
508
+ clearTimeout(timer)
509
+ nudge = undefined
510
+ ok()
511
+ }
512
+ })
513
+
514
+ async function run() {
515
+ listen(true)
516
+ const fakeNet = () => (DEV ? net() : undefined)
517
+ while (joins.size) {
518
+ if (parked || ! navigator.onLine) {
519
+ statusAll('reconnecting')
520
+ await sleep(CAP)
521
+ continue
522
+ }
523
+ let code = 0
524
+ let dead: ReturnType<typeof setTimeout> | undefined
525
+ let ping: ReturnType<typeof setInterval> | undefined
526
+ const alive = () => {
527
+ clearTimeout(dead)
528
+ if (!fakeNet()) dead = setTimeout(() => live?.close(), DEAD)
529
+ }
530
+ await new Promise<void>(done => {
531
+ const take = (d: Frame) => {
532
+ alive()
533
+ frame(d)
534
+ }
535
+ live = (fakeNet() ? fake : browser)(take, c => {
536
+ code = c
537
+ done()
538
+ })
539
+ alive()
540
+ if (!fakeNet()) ping = setInterval(() => send(['p']), PING)
541
+ })
542
+ clearTimeout(dead)
543
+ clearInterval(ping)
544
+ live = undefined
545
+ open = false
546
+ for (const j of joins.values()) j.joined = false
547
+ for (const [id, p] of acks) {
548
+ acks.delete(id)
549
+ p.no(new FnError({ message: 'the connection dropped: this may or may not have been sent' }, 503))
550
+ }
551
+ if (!joins.size) break
552
+ const n = fakeNet()
553
+ if (n) n.reconnects++
554
+ if (code === 4401) for (const j of joins.values()) j.status.value = 'unauthorized'
555
+ else statusAll('reconnecting')
556
+ if (!n) await sleep(code === HANDOVER ? 0 : backoff())
557
+ }
558
+ running = false
559
+ attempt = 0
560
+ listen(false)
561
+ }
562
+
563
+ /** Full jitter, 0.5 s doubling to 30 s (research 30 §6). */
564
+ const backoff = () => Math.random() * Math.min(CAP, BASE * 2 ** attempt++)
565
+
566
+ // ---- the page -------------------------------------------------------------------------------------------------------
567
+
568
+ const wake = () => {
569
+ parked = false
570
+ nudge?.()
571
+ }
572
+ function park() {
573
+ parked = true
574
+ live?.close()
575
+ }
576
+ const parkHidden = () => {
577
+ if (keepOpen.stay?.()) hiddenAt = setTimeout(parkHidden, HIDDEN)
578
+ else park()
579
+ }
580
+ function onVisibility() {
581
+ clearTimeout(hiddenAt)
582
+ if (document.visibilityState === 'hidden') hiddenAt = setTimeout(parkHidden, HIDDEN)
583
+ else wake()
584
+ }
585
+ /** A sibling came back while this tab's socket was parked. */
586
+ export const wakeSocket = () => wake()
587
+ const onOffline = () => {
588
+ socketDown()
589
+ live?.close()
590
+ }
591
+
592
+ function listen(on: boolean) {
593
+ if (typeof window === 'undefined') return
594
+ const m = on ? 'addEventListener' : 'removeEventListener'
595
+ window[m]('online', wake)
596
+ window[m]('offline', onOffline)
597
+ window[m]('pagehide', park)
598
+ window[m]('pageshow', wake)
599
+ document[m]('visibilitychange', onVisibility)
600
+ if (!on) clearTimeout(hiddenAt)
601
+ }
602
+
603
+ /** @weft-js/core/test, after each test. */
604
+ function reset() {
605
+ for (const j of joins.values()) clearTimeout(j.timer)
606
+ joins.clear()
607
+ byN.clear()
608
+ acks.clear()
609
+ live?.close()
610
+ nudge?.()
611
+ build = undefined
612
+ parked = false
613
+ attempt = 0
614
+ net()!.reconnects = 0
615
+ untrack(() => { rt.stale.value = false })
616
+ }
package/src/client.tsx ADDED
@@ -0,0 +1,20 @@
1
+ // What an app's entry imports. `run.tsx` is the client: the router, the runtime and whatever hydrates. It's a chunk of its
2
+ // own so a page with only lazy islands can be read without it (`boot.ts`, PLAN §11); a page with work to do has it
3
+ // preloaded, and starts it at once.
4
+
5
+ import './gate.css'
6
+ import { boot } from './boot'
7
+ import type { ClientRoutes, start as run } from './run'
8
+
9
+ export type { ClientRoutes } from './run'
10
+
11
+ /**
12
+ * Hydrate the page in `#app` and take over its links. Resolves once hydrated, which on a page of lazy islands is when
13
+ * one is first needed. `eager`: don't wait for that.
14
+ */
15
+ export function start(routes: ClientRoutes, options: { container?: Element; eager?: boolean } = {}): ReturnType<typeof run> {
16
+ return new Promise((settle, fail) => {
17
+ const full = () => import('./run').then(m => m.start(routes, options)).then(settle).catch(e => (fail(e), Promise.reject(e)))
18
+ options.eager ? void full() : boot(full)
19
+ })
20
+ }