@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/live.ts
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
// The realtime client (research 30 §6): one connection per tab, a line on the browser's one socket (link.ts), one subscription per function + input, shared by every
|
|
2
|
+
// reader and owned by this module, not by a component (PLAN §4.2), so an unmounting tab loses neither its subscription
|
|
3
|
+
// nor its folded state for `keep`. `q.live()` imports this on its first call; `stream` through `stream.ts`, so an app
|
|
4
|
+
// that uses neither ships none of it.
|
|
5
|
+
//
|
|
6
|
+
// A poke is a local `cache.invalidate`: readers refetch through `query`, and the keyed list writes what changed.
|
|
7
|
+
|
|
8
|
+
import { DEV, batch, createRoot, derived, effect, getOwner, onCleanup, state, untrack, type Derived, type State } from '@weft-js/core'
|
|
9
|
+
import type { AuthScope, Context, Fn, LiveApi, LiveMode, LiveOptions, LiveStatus, Skip, StandardSchemaV1 } from './data'
|
|
10
|
+
import type { Body, SubEntry, Tracked } from './hub'
|
|
11
|
+
import { HANDOVER, noSockets, openLine, type Line } from './link'
|
|
12
|
+
|
|
13
|
+
// `data.ts` hands these over (`use`). A static import would close a cycle with its `import('./live')`, and the bundler
|
|
14
|
+
// would then keep this chunk, the hub and the split they force in apps that never read `.live`.
|
|
15
|
+
let cache: LiveApi['cache'], currentScope: LiveApi['currentScope'], fns: LiveApi['fns'], FnError: LiveApi['FnError'], keyOf: LiveApi['keyOf'], net: LiveApi['net'], rt: LiveApi['rt'], runtime: LiveApi['runtime'], skip: LiveApi['skip'], StartError: LiveApi['StartError'], track: LiveApi['track']
|
|
16
|
+
export const use = (api: LiveApi) => void ({ cache, currentScope, fns, FnError, keyOf, net, rt, runtime, skip, StartError, track } = api)
|
|
17
|
+
|
|
18
|
+
// ---- definitions ----------------------------------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
type In<S> = S extends StandardSchemaV1<infer I, unknown> ? I : void
|
|
21
|
+
type Out<S> = S extends StandardSchemaV1<unknown, infer O> ? O : undefined
|
|
22
|
+
|
|
23
|
+
/** Where a stream stands; `done` is the end of a generator. */
|
|
24
|
+
export type StreamStatus = LiveStatus | 'done'
|
|
25
|
+
|
|
26
|
+
/** `stream()`: the input, the message schema and the generator. */
|
|
27
|
+
export interface StreamDef<S, M, A = undefined> {
|
|
28
|
+
input?: S
|
|
29
|
+
/** Checked in dev and tests (C002), and the contract's `message`. */
|
|
30
|
+
message?: StandardSchemaV1<unknown, M>
|
|
31
|
+
auth?: A
|
|
32
|
+
/** `lastEventId`: the last `tracked` id the client saw, on a resume. Stop when `signal` aborts. */
|
|
33
|
+
run: (args: { input: Out<S>; ctx: Context; signal: AbortSignal; lastEventId?: string }) => AsyncIterable<M | Tracked<M>>
|
|
34
|
+
$id?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `.live()` on a stream: what the value starts as, and how messages fold into it. */
|
|
38
|
+
export interface StreamOptions<M, T> extends LiveOptions {
|
|
39
|
+
/** Keep every message: `fold(acc, message)`. Its state lives in the cache, so a remount finds it. */
|
|
40
|
+
fold?: (acc: T, message: M) => T
|
|
41
|
+
initial?: T
|
|
42
|
+
/** Messages the server holds for a slow client. A fold defaults to 256; without one, the latest wins. */
|
|
43
|
+
buffer?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A stream as a page reads it. */
|
|
47
|
+
export interface Stream<T> {
|
|
48
|
+
/** The latest message, or the fold. */
|
|
49
|
+
readonly value: T
|
|
50
|
+
readonly status: Derived<StreamStatus>
|
|
51
|
+
readonly error: unknown
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What `stream()` returns. */
|
|
55
|
+
export interface StreamFn<I, M> {
|
|
56
|
+
(input: Arg<I>): Stream<M | undefined>
|
|
57
|
+
<T>(input: Arg<I>, options: StreamOptions<M, T> & { initial: T }): Stream<T>
|
|
58
|
+
(input: Arg<I>, options: StreamOptions<M, M>): Stream<M | undefined>
|
|
59
|
+
readonly kind: 'stream'
|
|
60
|
+
readonly $id: string
|
|
61
|
+
}
|
|
62
|
+
type Arg<I> = I | (() => I | Skip)
|
|
63
|
+
|
|
64
|
+
const FN = Symbol.for('weft.fn')
|
|
65
|
+
let auto = 0
|
|
66
|
+
|
|
67
|
+
export function stream<M, S extends StandardSchemaV1 | undefined = undefined, const A extends AuthScope | undefined = undefined>(def: StreamDef<S, M, A>): StreamFn<In<S>, M> {
|
|
68
|
+
const id = def.$id ?? `s${++auto}`
|
|
69
|
+
const fn: Fn = { kind: 'stream', $id: id, def: def as unknown as Fn['def'] }
|
|
70
|
+
fns.set(id, fn)
|
|
71
|
+
return Object.assign((input: unknown, options?: StreamOptions<never, unknown>) => read(fn, input, options), { kind: 'stream', $id: id, [FN]: fn }) as unknown as StreamFn<In<S>, M>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- subscriptions --------------------------------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
interface View { fold?: ((acc: never, m: never) => unknown) | undefined; initial: unknown; box: State<unknown> }
|
|
77
|
+
interface Sub {
|
|
78
|
+
n: number
|
|
79
|
+
key: string
|
|
80
|
+
fn: Fn
|
|
81
|
+
input: unknown
|
|
82
|
+
refs: number
|
|
83
|
+
timer?: ReturnType<typeof setTimeout> | undefined
|
|
84
|
+
/** Stream: the last id, sent back on a resume. Query: its tags, from the ack. */
|
|
85
|
+
cursor?: string | undefined
|
|
86
|
+
tags: string[]
|
|
87
|
+
buffer?: number
|
|
88
|
+
acked: boolean
|
|
89
|
+
views: Map<string, View>
|
|
90
|
+
error: State<unknown>
|
|
91
|
+
done: State<boolean>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const subs = new Map<string, Sub>()
|
|
95
|
+
const byN = new Map<number, Sub>()
|
|
96
|
+
let nth = 0
|
|
97
|
+
|
|
98
|
+
const KEEP = 30_000
|
|
99
|
+
const ms = (keep: string | number = KEEP): number => {
|
|
100
|
+
if (typeof keep === 'number') return keep
|
|
101
|
+
const m = /^(\d+)(ms|s|m)$/.exec(keep)
|
|
102
|
+
return m ? +m[1]! * { ms: 1, s: 1000, m: 60_000 }[m[2] as 'ms' | 's' | 'm'] : Infinity // 'session'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function acquire(fn: Fn, input: unknown): Sub {
|
|
106
|
+
const key = keyOf(fn.$id, input)
|
|
107
|
+
let s = subs.get(key)
|
|
108
|
+
if (!s) {
|
|
109
|
+
s = createRoot(() => ({ n: ++nth, key, fn, input, refs: 0, tags: [], acked: false, views: new Map(), error: state<unknown>(undefined), done: state(false) }), null)
|
|
110
|
+
// Not at the top level: a side effect there would keep this chunk in apps that never read `.live`.
|
|
111
|
+
if (DEV && net() && !net()!.resets.includes(reset)) net()!.resets.push(reset)
|
|
112
|
+
subs.set(key, s)
|
|
113
|
+
byN.set(s.n, s)
|
|
114
|
+
sync()
|
|
115
|
+
}
|
|
116
|
+
clearTimeout(s.timer)
|
|
117
|
+
s.timer = undefined
|
|
118
|
+
s.refs++
|
|
119
|
+
return s
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function release(s: Sub, keep?: string | number) {
|
|
123
|
+
if (--s.refs) return
|
|
124
|
+
const t = ms(keep)
|
|
125
|
+
if (t === Infinity) return
|
|
126
|
+
if (t) s.timer = setTimeout(() => drop(s), t)
|
|
127
|
+
else drop(s)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function drop(s: Sub) {
|
|
131
|
+
subs.delete(s.key)
|
|
132
|
+
byN.delete(s.n)
|
|
133
|
+
sync()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A reader in the owner of `hold`'s caller. T012 sees it until it lets go. */
|
|
137
|
+
function held(s: Sub, keep?: string | number): () => void {
|
|
138
|
+
const token = {}
|
|
139
|
+
const stop = () => {
|
|
140
|
+
net()?.open.delete(token)
|
|
141
|
+
release(s, keep)
|
|
142
|
+
}
|
|
143
|
+
if (DEV) net()?.open.set(token, [`live subscription \`${s.key}\``, stop])
|
|
144
|
+
return stop
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `q.live()`'s subscription, from data.ts. */
|
|
148
|
+
export function hold(fn: Fn, input: unknown, options?: LiveOptions): () => void {
|
|
149
|
+
return held(acquire(fn, input), options?.keep)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function read(fn: Fn, input: unknown, o: StreamOptions<never, unknown> = {}): Stream<unknown> {
|
|
153
|
+
if (!getOwner())
|
|
154
|
+
throw new StartError('W051', DEV ? `\`${fn.$id}()\` (a stream) 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)
|
|
155
|
+
const scope = currentScope()
|
|
156
|
+
if (scope) {
|
|
157
|
+
scope.live = true // the server renders `initial`, and the client hydrates to the same
|
|
158
|
+
return { value: o.initial, status: rt.status, error: undefined }
|
|
159
|
+
}
|
|
160
|
+
const view = state<{ s: Sub; v: View } | undefined>(undefined)
|
|
161
|
+
const fold = o.fold ? String(o.fold) : ''
|
|
162
|
+
effect(() => {
|
|
163
|
+
const arg = typeof input === 'function' ? (input as () => unknown)() : input
|
|
164
|
+
if (arg === skip) return undefined
|
|
165
|
+
return untrack(() => {
|
|
166
|
+
const s = acquire(fn, arg)
|
|
167
|
+
if (o.fold) s.buffer ??= o.buffer ?? 256
|
|
168
|
+
else if (o.buffer) s.buffer ??= o.buffer
|
|
169
|
+
let v = s.views.get(fold)
|
|
170
|
+
if (!v) s.views.set(fold, (v = createRoot(() => ({ fold: o.fold, initial: o.initial, box: state<unknown>(o.initial) }), null)))
|
|
171
|
+
v.fold = o.fold
|
|
172
|
+
onCleanup(held(s, o.keep))
|
|
173
|
+
return { s, v }
|
|
174
|
+
})
|
|
175
|
+
}, now => { view.value = now })
|
|
176
|
+
const status = derived(() => (view.value?.s.done.value ? 'done' : rt.status.value))
|
|
177
|
+
return {
|
|
178
|
+
get value() { const now = view.value; return now ? now.v.box.value : o.initial },
|
|
179
|
+
status,
|
|
180
|
+
get error() { return view.value?.s.error.value },
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- the connection ---------------------------------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/** How the client reaches `/_weft/rt`: SSE, a shared socket, or a fake under test. */
|
|
187
|
+
export interface Link {
|
|
188
|
+
/** Resolves when the connection ends. */
|
|
189
|
+
open(body: Body, frame: (event: string, data: unknown) => void, signal: AbortSignal, alive: () => void): Promise<void>
|
|
190
|
+
change(body: Body): Promise<void>
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const post = (body: Body, signal?: AbortSignal) =>
|
|
194
|
+
fetch('/_weft/rt', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream', 'x-weft': '1' }, body: JSON.stringify(body), ...(signal && { signal }) })
|
|
195
|
+
|
|
196
|
+
const http: Link = {
|
|
197
|
+
async open(body, heard, signal, alive) {
|
|
198
|
+
const res = await post(body, signal)
|
|
199
|
+
if (!res.ok) {
|
|
200
|
+
const { e } = await res.json().catch(() => ({ e: {} }))
|
|
201
|
+
throw new FnError(e, res.status)
|
|
202
|
+
}
|
|
203
|
+
const reader = res.body!.getReader()
|
|
204
|
+
const decoder = new TextDecoder()
|
|
205
|
+
let buf = ''
|
|
206
|
+
for (;;) {
|
|
207
|
+
const { done, value } = await reader.read()
|
|
208
|
+
if (done) return
|
|
209
|
+
alive()
|
|
210
|
+
buf += decoder.decode(value, { stream: true })
|
|
211
|
+
for (let i; (i = buf.indexOf('\n\n')) >= 0; buf = buf.slice(i + 2)) {
|
|
212
|
+
if (buf[0] === ':') continue
|
|
213
|
+
const line = buf.indexOf('\n')
|
|
214
|
+
heard(buf.slice(7, line), JSON.parse(buf.slice(line + 7, i)))
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
async change(body) {
|
|
219
|
+
const res = await post(body)
|
|
220
|
+
if (!res.ok) throw new FnError({}, res.status)
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A line on the browser's one socket (link.ts): the same events, and `/_weft/rt`'s SSE is for a host with no sockets. */
|
|
225
|
+
let line: Line | undefined
|
|
226
|
+
const socket: Link = {
|
|
227
|
+
open(body, heard, signal, alive) {
|
|
228
|
+
return new Promise<void>((done, fail) => {
|
|
229
|
+
const l = (line = openLine('r', JSON.stringify(body), text => {
|
|
230
|
+
alive()
|
|
231
|
+
const at = text.indexOf(':'), event = text.slice(0, at)
|
|
232
|
+
if (event !== 'ping') heard(event, JSON.parse(text.slice(at + 1)))
|
|
233
|
+
}, (code, reason) => {
|
|
234
|
+
if (line === l) line = undefined
|
|
235
|
+
if (code === HANDOVER) retry = 0
|
|
236
|
+
if (code === 4000 && reason) fail(new FnError(JSON.parse(reason), 429))
|
|
237
|
+
else done()
|
|
238
|
+
}))
|
|
239
|
+
signal.addEventListener('abort', () => l.close())
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
change(body) {
|
|
243
|
+
if (!line) return Promise.reject(new FnError({}, 503))
|
|
244
|
+
line.send(JSON.stringify(body))
|
|
245
|
+
return Promise.resolve()
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @weft-js/core/test only (`live-fake.ts`). The specifier hides it from the bundler: an `import()` it can see would keep the
|
|
251
|
+
* fake, and through it the hub, as chunks of every app, used or not.
|
|
252
|
+
*/
|
|
253
|
+
const FAKE = './live-fake.ts'
|
|
254
|
+
const fake: Link = {
|
|
255
|
+
open(body, heard, signal) {
|
|
256
|
+
let up!: () => void
|
|
257
|
+
void track(new Promise<void>(ok => { up = ok })) // `net.settle()` waits for the connection, not for its end
|
|
258
|
+
return import(/* @vite-ignore */ FAKE).then(m => m.open(body, heard, signal, up, subs, (close: () => unknown) => (closeHub = close))).finally(up)
|
|
259
|
+
},
|
|
260
|
+
change: async body => (await import(/* @vite-ignore */ FAKE)).change(body),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Two sockets that never answered, and this host has none. */
|
|
264
|
+
const link = (): Link => (DEV && net() ? fake : noSockets() ? http : socket)
|
|
265
|
+
let via: Link = socket
|
|
266
|
+
let closeHub: (() => unknown) | undefined
|
|
267
|
+
|
|
268
|
+
const BASE = 500, CAP = 30_000, DEAD = 45_000, HIDDEN = 300_000
|
|
269
|
+
|
|
270
|
+
/** Full jitter: anywhere from 0 to 0.5 s, 1 s, 2 s … 30 s. */
|
|
271
|
+
export const backoff = (attempt: number, random = Math.random) => random() * Math.min(CAP, BASE * 2 ** attempt)
|
|
272
|
+
|
|
273
|
+
interface Live { ac: AbortController; k?: string; sent: Set<number> }
|
|
274
|
+
let conn: Live | undefined
|
|
275
|
+
let running = false
|
|
276
|
+
let attempt = 0
|
|
277
|
+
/** The poke cursor. Seeded by the page, it covers what was published between the render and this connection. */
|
|
278
|
+
let cursor: string | undefined
|
|
279
|
+
let build: string | undefined
|
|
280
|
+
let seeded = false
|
|
281
|
+
let retry: number | undefined
|
|
282
|
+
let nudge: (() => void) | undefined
|
|
283
|
+
let hiddenAt: ReturnType<typeof setTimeout> | undefined
|
|
284
|
+
let parked = false
|
|
285
|
+
let changing: Promise<void> | undefined
|
|
286
|
+
let pokes = new Set<string>()
|
|
287
|
+
let pokeTimer: ReturnType<typeof setTimeout> | undefined
|
|
288
|
+
|
|
289
|
+
/** The page's `#weft-rt`, on the tab's first connect. */
|
|
290
|
+
function readPage() {
|
|
291
|
+
const el = document.getElementById('weft-rt')
|
|
292
|
+
if (!el) return
|
|
293
|
+
const page = JSON.parse(el.textContent) as { c?: string; b?: string }
|
|
294
|
+
el.remove()
|
|
295
|
+
cursor = page.c
|
|
296
|
+
build = page.b
|
|
297
|
+
seeded = !!cursor
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const entry = (s: Sub): SubEntry => (s.fn.kind === 'query' ? [s.n, 'q', s.fn.$id, s.input] : [s.n, 's', s.fn.$id, s.input, s.cursor ?? '', s.buffer ?? 0])
|
|
301
|
+
const set = (status: LiveStatus) => { rt.status.value = status }
|
|
302
|
+
interface Linked { m: LiveMode; d?: 1 }
|
|
303
|
+
/** What's positional in a frame: `[subscription, ...]`. What the rest of the fields mean goes by the event. */
|
|
304
|
+
type Row = { 0: number; 1: never; 2: unknown }
|
|
305
|
+
|
|
306
|
+
/** The connection is up: how live that is depends on the server's feed. */
|
|
307
|
+
function linked(l?: Linked) {
|
|
308
|
+
rt.mode.value = l?.m ?? 'app'
|
|
309
|
+
set(l?.d ? 'reconnecting' : l?.m === 'poll' || l?.m === 'off' ? 'degraded' : 'live')
|
|
310
|
+
if (DEV && l && l.m !== 'feed' && l.m !== 'external' && !warned) {
|
|
311
|
+
warned = true
|
|
312
|
+
console.warn(`weft: R016 live queries are \`degraded\` (mode \`${l.m}\`): writes from outside the app arrive late or not at all. The server log says why.`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
let warned = false
|
|
316
|
+
const offline = () => ! navigator.onLine
|
|
317
|
+
|
|
318
|
+
function flushPokes() {
|
|
319
|
+
const tags = [...pokes]
|
|
320
|
+
pokes = new Set()
|
|
321
|
+
pokeTimer = undefined
|
|
322
|
+
void cache.invalidate(...tags)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function frame(event: string, data: unknown) {
|
|
326
|
+
const d = data as Row & Record<'b' | 'k' | 'c', string> & { l?: Linked; t: string[]; retry: number }
|
|
327
|
+
batch(() => {
|
|
328
|
+
if (event === 'h') {
|
|
329
|
+
if (build && d.b && d.b !== build) rt.stale.value = true
|
|
330
|
+
build = d.b
|
|
331
|
+
if (conn) conn.k = runtime.conn = d.k
|
|
332
|
+
cursor ??= d.c
|
|
333
|
+
attempt = 0
|
|
334
|
+
linked(d.l)
|
|
335
|
+
sync() // what changed while connecting
|
|
336
|
+
} else if (event === 'a') {
|
|
337
|
+
const s = byN.get(d[0])
|
|
338
|
+
if (!s) return
|
|
339
|
+
s.tags = d[1]
|
|
340
|
+
s.error.value = undefined
|
|
341
|
+
if (DEV && !s.tags.length) console.warn(`weft: W053 \`${s.fn.$id}\` is read with \`.live()\` but declares no \`tags\`: no mutation can ever poke it.\n fix: add \`tags\` to the query`)
|
|
342
|
+
// Data older than the subscription may have missed a write. A page's first connect is covered by its cursor.
|
|
343
|
+
if (!s.acked && !seeded && s.tags.length) void cache.invalidate(...s.tags)
|
|
344
|
+
s.acked = true
|
|
345
|
+
} else if (event === 'p') {
|
|
346
|
+
cursor = d.c
|
|
347
|
+
for (const t of d.t) pokes.add(t)
|
|
348
|
+
if (net()) flushPokes()
|
|
349
|
+
else pokeTimer ??= setTimeout(flushPokes, 50 + Math.random() * 250)
|
|
350
|
+
} else if (event === 'R') {
|
|
351
|
+
cursor = d.c
|
|
352
|
+
void cache.invalidate(...[...subs.values()].flatMap(s => s.tags))
|
|
353
|
+
} else if (event === 'm') {
|
|
354
|
+
const s = byN.get(d[0])
|
|
355
|
+
if (!s) return
|
|
356
|
+
if (d[1] !== null) s.cursor = d[1]
|
|
357
|
+
s.error.value = undefined
|
|
358
|
+
for (const v of s.views.values()) v.box.value = v.fold ? v.fold(v.box.value as never, d[2] as never) : d[2]
|
|
359
|
+
} else if (event === 'r') {
|
|
360
|
+
const s = byN.get(d[0])
|
|
361
|
+
if (!s) return
|
|
362
|
+
s.cursor = undefined
|
|
363
|
+
for (const v of s.views.values()) v.box.value = v.initial
|
|
364
|
+
} else if (event === 'f') {
|
|
365
|
+
const s = byN.get(d[0])
|
|
366
|
+
if (s) s.done.value = true
|
|
367
|
+
} else if (event === 'e') {
|
|
368
|
+
const s = byN.get(d[0])
|
|
369
|
+
if (s) s.error.value = new FnError(d[1], (d[1] as { code?: string }).code === 'A002' ? 401 : 500)
|
|
370
|
+
} else if (event === 'L') linked(d as unknown as Linked)
|
|
371
|
+
else if (event === 'b') retry = d.retry
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Woken early by `online`, by the tab showing again and by a change to the set. */
|
|
376
|
+
const sleep = (t: number) => new Promise<void>(ok => {
|
|
377
|
+
const timer = setTimeout(ok, t)
|
|
378
|
+
nudge = () => {
|
|
379
|
+
clearTimeout(timer)
|
|
380
|
+
nudge = undefined
|
|
381
|
+
ok()
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
async function run() {
|
|
386
|
+
running = true
|
|
387
|
+
listen(true)
|
|
388
|
+
readPage()
|
|
389
|
+
while (subs.size) {
|
|
390
|
+
if (parked || offline()) {
|
|
391
|
+
set(parked ? 'reconnecting' : 'offline')
|
|
392
|
+
await sleep(CAP)
|
|
393
|
+
continue
|
|
394
|
+
}
|
|
395
|
+
const ac = new AbortController()
|
|
396
|
+
conn = { ac, sent: new Set(byN.keys()) }
|
|
397
|
+
let dead: ReturnType<typeof setTimeout> | undefined
|
|
398
|
+
const alive = () => {
|
|
399
|
+
clearTimeout(dead)
|
|
400
|
+
if (!net()) dead = setTimeout(() => ac.abort(), DEAD)
|
|
401
|
+
}
|
|
402
|
+
alive()
|
|
403
|
+
retry = undefined
|
|
404
|
+
try {
|
|
405
|
+
await (via = link()).open({ c: cursor, s: [...subs.values()].filter(s => !s.done.value).map(entry) }, frame, ac.signal, alive)
|
|
406
|
+
} catch (e) {
|
|
407
|
+
if (!ac.signal.aborted && e instanceof FnError) for (const s of subs.values()) untrack(() => { s.error.value = e })
|
|
408
|
+
}
|
|
409
|
+
clearTimeout(dead)
|
|
410
|
+
conn = undefined
|
|
411
|
+
runtime.conn = undefined
|
|
412
|
+
seeded = false
|
|
413
|
+
if (!subs.size) break
|
|
414
|
+
const n = net()
|
|
415
|
+
if (n) n.reconnects++
|
|
416
|
+
set(offline() ? 'offline' : 'reconnecting')
|
|
417
|
+
if (!n) await sleep(retry ?? backoff(attempt++))
|
|
418
|
+
}
|
|
419
|
+
running = false
|
|
420
|
+
attempt = 0
|
|
421
|
+
listen(false)
|
|
422
|
+
set('connecting')
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Bring the server's set up to date with `subs`, in place. A 404 (another instance, a restart) reconnects. */
|
|
426
|
+
function sync() {
|
|
427
|
+
if (changing) return
|
|
428
|
+
changing = Promise.resolve().then(async () => {
|
|
429
|
+
changing = undefined
|
|
430
|
+
if (!subs.size) return conn?.ac.abort()
|
|
431
|
+
if (!running) return void run()
|
|
432
|
+
const live = conn
|
|
433
|
+
if (!live?.k) return
|
|
434
|
+
const add = [...byN.values()].filter(s => !live.sent.has(s.n))
|
|
435
|
+
const gone = [...live.sent].filter(n => !byN.has(n))
|
|
436
|
+
if (!add.length && !gone.length) return
|
|
437
|
+
for (const s of add) live.sent.add(s.n)
|
|
438
|
+
for (const n of gone) live.sent.delete(n)
|
|
439
|
+
try {
|
|
440
|
+
await via.change({ k: live.k, s: add.map(entry), d: gone })
|
|
441
|
+
} catch {
|
|
442
|
+
live.ac.abort()
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
void track(changing)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---- the page -------------------------------------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
const wake = () => {
|
|
451
|
+
parked = false
|
|
452
|
+
nudge?.()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function park() {
|
|
456
|
+
parked = true
|
|
457
|
+
conn?.ac.abort()
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function onOffline() {
|
|
461
|
+
set('offline')
|
|
462
|
+
conn?.ac.abort()
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function onVisibility() {
|
|
466
|
+
clearTimeout(hiddenAt)
|
|
467
|
+
if (document.visibilityState === 'hidden') hiddenAt = setTimeout(park, HIDDEN)
|
|
468
|
+
else wake()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function listen(on: boolean) {
|
|
472
|
+
if (typeof window === 'undefined') return
|
|
473
|
+
const m = on ? 'addEventListener' : 'removeEventListener'
|
|
474
|
+
window[m]('online', wake)
|
|
475
|
+
window[m]('offline', onOffline)
|
|
476
|
+
window[m]('pagehide', park)
|
|
477
|
+
window[m]('pageshow', wake)
|
|
478
|
+
document[m]('visibilitychange', onVisibility)
|
|
479
|
+
if (!on) clearTimeout(hiddenAt)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** @weft-js/core/test, after each test. */
|
|
483
|
+
function reset() {
|
|
484
|
+
for (const s of subs.values()) clearTimeout(s.timer)
|
|
485
|
+
subs.clear()
|
|
486
|
+
byN.clear()
|
|
487
|
+
conn?.ac.abort()
|
|
488
|
+
nudge?.()
|
|
489
|
+
clearTimeout(pokeTimer)
|
|
490
|
+
pokes = new Set()
|
|
491
|
+
cursor = build = retry = undefined
|
|
492
|
+
seeded = parked = false
|
|
493
|
+
attempt = 0
|
|
494
|
+
const n = net()!
|
|
495
|
+
n.reconnects = 0
|
|
496
|
+
n.rt = undefined
|
|
497
|
+
void closeHub?.()
|
|
498
|
+
untrack(() => { rt.stale.value = false; rt.status.value = 'connecting' })
|
|
499
|
+
}
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// `z.media()` (research 29 §6): the record of an upload or a CMS image, in the shape `<Media src>` already takes, so
|
|
2
|
+
// `<Media src={post.cover} alt="…" />` needs no conversion. Zod's `z` is an ES namespace and can't be extended, so this
|
|
3
|
+
// `z` is Zod's API plus `media()`.
|
|
4
|
+
|
|
5
|
+
import { z as zod } from 'zod'
|
|
6
|
+
import type { MediaAsset } from '@weft-js/core/media'
|
|
7
|
+
|
|
8
|
+
const record = () =>
|
|
9
|
+
zod.object({
|
|
10
|
+
kind: zod.enum(['photo', 'graphic', 'vector', 'video', 'audio']),
|
|
11
|
+
src: zod.string(),
|
|
12
|
+
width: zod.number().int().nonnegative(),
|
|
13
|
+
height: zod.number().int().nonnegative(),
|
|
14
|
+
type: zod.string(),
|
|
15
|
+
sources: zod.array(zod.object({ type: zod.string(), srcset: zod.string() })),
|
|
16
|
+
dominant: zod.string().optional(),
|
|
17
|
+
placeholder: zod.string().optional(),
|
|
18
|
+
name: zod.string(),
|
|
19
|
+
quality: zod.enum(['ssimulacra2', 'fixed', 'original']).optional(),
|
|
20
|
+
poster: zod.string().optional(),
|
|
21
|
+
duration: zod.number().optional(),
|
|
22
|
+
audio: zod.boolean().optional(),
|
|
23
|
+
animated: zod.boolean().optional(),
|
|
24
|
+
loop: zod.number().optional(),
|
|
25
|
+
copy: zod.object({ src: zod.string(), type: zod.string(), bytes: zod.number().optional(), ratio: zod.number().optional() }).optional(),
|
|
26
|
+
swap: zod.enum(['intent', 'idle', 'never']).optional(),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** Zod's API plus `media()`, the record of an upload. */
|
|
30
|
+
// oxlint-disable-next-line no-redeclare -- a value and the namespace below that holds `z.infer`, one name as Zod's has
|
|
31
|
+
export const z = { ...zod, media: record } as typeof zod & { media: typeof record }
|
|
32
|
+
|
|
33
|
+
/** Zod's type helpers, under the name that has `media()`. */
|
|
34
|
+
export declare namespace z {
|
|
35
|
+
/** A schema's output type. */
|
|
36
|
+
export type infer<T> = zod.infer<T>
|
|
37
|
+
/** A schema's input type. */
|
|
38
|
+
export type input<T> = zod.input<T>
|
|
39
|
+
/** A schema's output type, under Zod's other name. */
|
|
40
|
+
export type output<T> = zod.output<T>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type { MediaAsset }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The app's migrations in order (research 28 §2, 34 §4): dev reads them per request, a build embeds them. `drizzle/` has
|
|
2
|
+
// a journal; Native's `migrations/` is numbered `.sql` files; Prisma's `prisma/migrations/` is a folder per migration.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const DIRS = ['drizzle', 'migrations', 'prisma/migrations']
|
|
8
|
+
|
|
9
|
+
export function readMigrations(root, dir = DIRS.find(d => existsSync(join(root, d)))) {
|
|
10
|
+
if (!dir) return []
|
|
11
|
+
const base = join(root, dir)
|
|
12
|
+
const journal = join(base, 'meta', '_journal.json')
|
|
13
|
+
if (existsSync(journal)) return JSON.parse(readFileSync(journal, 'utf8')).entries.map(e => ({ tag: e.tag, sql: readFileSync(join(base, `${e.tag}.sql`), 'utf8') }))
|
|
14
|
+
if (!existsSync(base)) return []
|
|
15
|
+
return readdirSync(base).sort().flatMap(name => {
|
|
16
|
+
const file = name.endsWith('.sql') ? join(base, name) : join(base, name, 'migration.sql')
|
|
17
|
+
return existsSync(file) ? [{ tag: name.replace(/\.sql$/, ''), sql: readFileSync(file, 'utf8') }] : []
|
|
18
|
+
})
|
|
19
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** What a tab compares, to learn a socket opened before it signed in isn't its own. Opaque, and only the same browser sees it. */
|
|
2
|
+
export async function sessionOf(ctx: unknown) {
|
|
3
|
+
const id = String((ctx as { user?: { id?: string | number } })?.user?.id ?? '')
|
|
4
|
+
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`weft:${id}`))
|
|
5
|
+
return btoa(String.fromCharCode(...new Uint8Array(hash).slice(0, 12)))
|
|
6
|
+
}
|