@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/link.ts
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// One browser, one socket (research 45 §5). Every tab gets the same thing it always had, a duplex line of text frames,
|
|
2
|
+
// and only one tab, the leader, holds the WebSocket under them all. `channel.ts`, `live.ts` and `share.ts` ride it and
|
|
3
|
+
// know nothing about tabs: a line is a socket to them, and to the server, which gives each its own connection (mux.ts).
|
|
4
|
+
// That is why `id`, `exceptSelf`, `left`, `limits` and acks all mean what they did: per tab.
|
|
5
|
+
//
|
|
6
|
+
// campaign a Web Lock with a queue, so taking over needs no timer, and a ping for a leader that's frozen, not gone.
|
|
7
|
+
// tab death each tab holds a lock of its own and the leader queues on it: it comes through however the tab went.
|
|
8
|
+
// routing frames go up one BroadcastChannel only the leader reads, and down one per tab, so a frame wakes one tab.
|
|
9
|
+
// wire `f:<line>:<text>`, a prefix and not an envelope: the frame inside is never parsed here.
|
|
10
|
+
// session a socket keeps the cookies of its upgrade, and another tab may have signed in since. A tab that didn't dial
|
|
11
|
+
// asks the server who it is and compares; if they differ, the leader dials again.
|
|
12
|
+
//
|
|
13
|
+
// Without Web Locks every tab leads itself: its own socket, which is what every tab used to have.
|
|
14
|
+
|
|
15
|
+
/** Bump when a message here changes shape: tabs of two shapes share nothing, and each is its own client. */
|
|
16
|
+
const WIRE = 1
|
|
17
|
+
const NAME = `weft:link:${WIRE}`
|
|
18
|
+
const PING = 20_000, DEAD = 45_000, BASE = 200, CAP = 30_000, IDLE = 1000
|
|
19
|
+
export const BEAT = 5000
|
|
20
|
+
const PONG = 1500
|
|
21
|
+
/** The line went because the leader did: not the network's fault, so reopen at once. */
|
|
22
|
+
export const HANDOVER = 4900
|
|
23
|
+
|
|
24
|
+
// getRandomValues, not randomUUID: a page on plain http (a LAN address) has no randomUUID, and this runs on import.
|
|
25
|
+
export const tab = typeof crypto === 'undefined' ? '' : crypto.getRandomValues(new Uint32Array(1))[0]!.toString(36)
|
|
26
|
+
const locks = (): LockManager | undefined => (typeof navigator !== 'undefined' ? navigator.locks : undefined)
|
|
27
|
+
export const shareable = () => !!locks() && typeof BroadcastChannel !== 'undefined'
|
|
28
|
+
|
|
29
|
+
/** What a test reads. */
|
|
30
|
+
export const linkStats = { role: 'none' as 'none' | 'leader' | 'follower', steals: 0, dials: 0 }
|
|
31
|
+
|
|
32
|
+
// ---- a lock, a queue and a ping ---------------------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export interface Campaign { probe(): void; stop(): void; leading(): boolean; pinged?: () => void }
|
|
35
|
+
|
|
36
|
+
/** `lead` when this tab takes the lock; `lose` when another tab takes it from under this one. */
|
|
37
|
+
export function campaign(name: string, won: () => void, lose: () => void, stolen?: () => void): Campaign {
|
|
38
|
+
const l = locks()
|
|
39
|
+
const lockName = `${name}:leader`
|
|
40
|
+
let on = true, leads = false
|
|
41
|
+
let release: (() => void) | undefined
|
|
42
|
+
let abort: AbortController | undefined
|
|
43
|
+
let waiting: ReturnType<typeof setTimeout> | undefined
|
|
44
|
+
let pongAt = 0
|
|
45
|
+
const hold = () => {
|
|
46
|
+
leads = true
|
|
47
|
+
clearTimeout(waiting) // a ping that went out before there was anyone to answer it
|
|
48
|
+
waiting = undefined
|
|
49
|
+
won()
|
|
50
|
+
}
|
|
51
|
+
if (!l || typeof BroadcastChannel === 'undefined') {
|
|
52
|
+
queueMicrotask(() => { if (on) hold() })
|
|
53
|
+
return { probe() {}, leading: () => leads, stop() { on = leads = false } }
|
|
54
|
+
}
|
|
55
|
+
const bc = new BroadcastChannel(`${name}:e`)
|
|
56
|
+
const me: Campaign = {
|
|
57
|
+
leading: () => leads,
|
|
58
|
+
probe() {
|
|
59
|
+
// Each hover asks, so a recent answer has to do: one message a second, not one a move.
|
|
60
|
+
if (leads || waiting !== undefined || !on || Date.now() - pongAt < 1000) return
|
|
61
|
+
bc.postMessage({ ping: tab })
|
|
62
|
+
const asked = Date.now()
|
|
63
|
+
waiting = setTimeout(() => {
|
|
64
|
+
waiting = undefined
|
|
65
|
+
// Late means this tab was the frozen one, and the answer is queued behind this timer: ask again.
|
|
66
|
+
if (Date.now() - asked > PONG * 1.5) return me.probe()
|
|
67
|
+
stolen?.()
|
|
68
|
+
const queued = abort
|
|
69
|
+
elect(true)
|
|
70
|
+
queued?.abort()
|
|
71
|
+
}, PONG)
|
|
72
|
+
},
|
|
73
|
+
stop() {
|
|
74
|
+
on = false
|
|
75
|
+
leads = false
|
|
76
|
+
clearTimeout(waiting)
|
|
77
|
+
clearInterval(timer)
|
|
78
|
+
abort?.abort()
|
|
79
|
+
release?.()
|
|
80
|
+
bc.close()
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
bc.onmessage = ({ data }) => {
|
|
84
|
+
if (data.ping && leads) {
|
|
85
|
+
bc.postMessage({ pong: data.ping })
|
|
86
|
+
me.pinged?.()
|
|
87
|
+
} else if (data.pong === tab) {
|
|
88
|
+
pongAt = Date.now()
|
|
89
|
+
clearTimeout(waiting)
|
|
90
|
+
waiting = undefined
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function elect(steal = false) {
|
|
94
|
+
const ctl = new AbortController()
|
|
95
|
+
abort = ctl
|
|
96
|
+
l!.request(lockName, steal ? { steal: true } : { signal: ctl.signal }, () => new Promise<void>(ok => {
|
|
97
|
+
release = ok
|
|
98
|
+
hold()
|
|
99
|
+
})).catch(() => {
|
|
100
|
+
if (!on || abort !== ctl) return // this tab left the queue on purpose
|
|
101
|
+
if (leads) {
|
|
102
|
+
leads = false
|
|
103
|
+
lose()
|
|
104
|
+
}
|
|
105
|
+
elect()
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
const timer = setInterval(() => me.probe(), BEAT)
|
|
109
|
+
elect()
|
|
110
|
+
return me
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- this tab's own lock ----------------------------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
let tabHeld = false
|
|
116
|
+
let tabFree: (() => void) | undefined
|
|
117
|
+
const whenHeld: (() => void)[] = []
|
|
118
|
+
|
|
119
|
+
/** Held before the tab says anything, so a sibling that queues on it can't read a live tab as dead. */
|
|
120
|
+
export function holdTab(then: () => void) {
|
|
121
|
+
if (tabHeld || !locks()) return then()
|
|
122
|
+
whenHeld.push(then)
|
|
123
|
+
if (whenHeld.length > 1) return
|
|
124
|
+
locks()!.request(`${NAME}:tab:${tab}`, () => new Promise<void>(ok => {
|
|
125
|
+
tabFree = ok
|
|
126
|
+
tabHeld = true
|
|
127
|
+
for (const f of whenHeld.splice(0)) f()
|
|
128
|
+
})).catch(() => {})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Comes through when that tab is gone, crash included. */
|
|
132
|
+
export function freeTab() {
|
|
133
|
+
tabHeld = false
|
|
134
|
+
tabFree?.()
|
|
135
|
+
tabFree = undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const whenGone = (t: string, fn: () => void) => void locks()?.request(`${NAME}:tab:${t}`, () => fn()).catch(() => {})
|
|
139
|
+
|
|
140
|
+
// ---- lines, as a tab sees them ----------------------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
export interface Line { send(text: string): void; close(): void }
|
|
143
|
+
interface Mine { id: string; hello: string; frame(text: string): void; closed(code: number, reason?: string): void }
|
|
144
|
+
|
|
145
|
+
const lines = new Map<string, Mine>()
|
|
146
|
+
let nth = 0
|
|
147
|
+
|
|
148
|
+
declare const __WEFT_SOCKETS__: boolean | undefined
|
|
149
|
+
/** Whether this deploy can hold a socket. `weft build` says no for a function host (Netlify, Vercel, Lambda…). */
|
|
150
|
+
export const SOCKETS = typeof __WEFT_SOCKETS__ === 'undefined' || __WEFT_SOCKETS__
|
|
151
|
+
|
|
152
|
+
/** Sockets that never said hello in a row: the page's way to learn this host has none. */
|
|
153
|
+
export let socketFailures = 0
|
|
154
|
+
/** The one who asked goes round SSE once, and the socket gets another chance after. Where the build said none, always. */
|
|
155
|
+
export const noSockets = () => {
|
|
156
|
+
if (!SOCKETS) return true
|
|
157
|
+
const none = socketFailures >= 2
|
|
158
|
+
if (none) socketFailures = 0
|
|
159
|
+
return none
|
|
160
|
+
}
|
|
161
|
+
export const probeLink = () => election?.probe()
|
|
162
|
+
|
|
163
|
+
function end(l: Mine, code: number, reason?: string) {
|
|
164
|
+
lines.delete(l.id)
|
|
165
|
+
l.closed(code, reason)
|
|
166
|
+
}
|
|
167
|
+
const endAll = (code: number) => { for (const l of Array.from(lines.values())) end(l, code) }
|
|
168
|
+
|
|
169
|
+
function arrive(text: string) {
|
|
170
|
+
const at = text.indexOf(':', 2)
|
|
171
|
+
const l = lines.get(text.slice(2, at))
|
|
172
|
+
if (!l) return
|
|
173
|
+
if (text[0] === 'x') {
|
|
174
|
+
const split = text.indexOf(':', at + 1)
|
|
175
|
+
return end(l, +text.slice(at + 1, split), text.slice(split + 1))
|
|
176
|
+
}
|
|
177
|
+
l.frame(text.slice(at + 1))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** `c`: a socket's frames (socket.ts). `r`: realtime events, opened with a `/_weft/rt` body (hub.ts). */
|
|
181
|
+
export function openLine(kind: 'c' | 'r', hello: string, frame: (text: string) => void, closed: (code: number, reason?: string) => void): Line {
|
|
182
|
+
start()
|
|
183
|
+
const id = `${tab}.${++nth}`
|
|
184
|
+
const l: Mine = { id, hello: `o:${id}:${kind}:${hello}`, frame, closed }
|
|
185
|
+
lines.set(id, l)
|
|
186
|
+
say(id, 'o', l.hello)
|
|
187
|
+
if (!election?.leading()) election?.probe()
|
|
188
|
+
return {
|
|
189
|
+
send(text) { if (lines.has(id)) say(id, 'f', `f:${id}:${text}`) },
|
|
190
|
+
close() {
|
|
191
|
+
if (!lines.has(id)) return
|
|
192
|
+
say(id, 'x', `x:${id}:`)
|
|
193
|
+
end(l, 1000)
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- up and down the tabs ---------------------------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
type Up = { tab: string; id: string; k: 'o' | 'x' | 'f'; l: string }
|
|
201
|
+
type Note =
|
|
202
|
+
| { t: 'lead' } | { t: 'lost'; code: number; never: boolean } | { t: 'who'; tab: string } | { t: 'you'; to: string; u?: string } | { t: 'redial' }
|
|
203
|
+
| { t: 'bye'; tab: string }
|
|
204
|
+
|
|
205
|
+
let running = false
|
|
206
|
+
let notes: BroadcastChannel | undefined
|
|
207
|
+
let down: BroadcastChannel | undefined
|
|
208
|
+
let election: Campaign | undefined
|
|
209
|
+
/** A tab that didn't dial holds its first lines until the session is checked. */
|
|
210
|
+
let gate: Up[] | undefined
|
|
211
|
+
|
|
212
|
+
function say(id: string, k: Up['k'], line: string) {
|
|
213
|
+
const m: Up = { tab, id, k, l: line }
|
|
214
|
+
if (gate) gate.push(m)
|
|
215
|
+
else if (election?.leading()) take(m)
|
|
216
|
+
else if (shareable()) holdTab(() => upTo.postMessage(m))
|
|
217
|
+
// else: this tab leads itself in a moment, and `lead` opens what's here
|
|
218
|
+
}
|
|
219
|
+
let upTo: BroadcastChannel
|
|
220
|
+
|
|
221
|
+
function start() {
|
|
222
|
+
if (running) return
|
|
223
|
+
running = true
|
|
224
|
+
if (shareable()) {
|
|
225
|
+
notes = new BroadcastChannel(NAME)
|
|
226
|
+
notes.onmessage = e => note(e.data)
|
|
227
|
+
down = new BroadcastChannel(`${NAME}:down:${tab}`)
|
|
228
|
+
down.onmessage = e => arrive(e.data)
|
|
229
|
+
upTo = new BroadcastChannel(`${NAME}:up`)
|
|
230
|
+
}
|
|
231
|
+
listen()
|
|
232
|
+
election = campaign(NAME, lead, () => hangUp(HANDOVER), () => void linkStats.steals++)
|
|
233
|
+
linkStats.role = 'follower'
|
|
234
|
+
if (!shareable() || session) return
|
|
235
|
+
gate = []
|
|
236
|
+
checkSession()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function note(m: Note) {
|
|
240
|
+
if (m.t === 'lead' && !election?.leading()) {
|
|
241
|
+
linkStats.role = 'follower'
|
|
242
|
+
endAll(HANDOVER)
|
|
243
|
+
} else if (m.t === 'lost') {
|
|
244
|
+
if (election?.leading()) return
|
|
245
|
+
if (m.never) socketFailures++
|
|
246
|
+
endAll(m.code)
|
|
247
|
+
}
|
|
248
|
+
else if (m.t === 'who') { if (election?.leading()) notes!.postMessage({ t: 'you', to: m.tab, u: open ? user : undefined }) }
|
|
249
|
+
else if (m.t === 'you' && m.to === tab) theirs?.(m.u)
|
|
250
|
+
else if (m.t === 'redial') { if (election?.leading() && ws) lost(ws, HANDOVER, true) }
|
|
251
|
+
else if (m.t === 'bye') dropTab(m.tab)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let session: Promise<void> | undefined
|
|
255
|
+
let theirs: ((u: string | undefined) => void) | undefined
|
|
256
|
+
|
|
257
|
+
/** One round trip, once, the first time a tab that didn't dial opens a line: a new socket used to cost the same. */
|
|
258
|
+
function checkSession() {
|
|
259
|
+
const mine = fetch('/_weft/rt', { method: 'POST', headers: { 'content-type': 'application/json', 'x-weft': '1' }, body: '{"w":1}' }).then(r => (r.ok ? r.json() : undefined)).then(r => r?.u as string | undefined).catch(() => undefined)
|
|
260
|
+
const leader = new Promise<string | undefined>(ok => {
|
|
261
|
+
theirs = ok
|
|
262
|
+
notes!.postMessage({ t: 'who', tab })
|
|
263
|
+
setTimeout(ok, 1000)
|
|
264
|
+
})
|
|
265
|
+
session = Promise.all([mine, leader]).then(([me, them]) => {
|
|
266
|
+
theirs = undefined
|
|
267
|
+
if (me !== undefined && them !== undefined && me !== them) notes!.postMessage({ t: 'redial' })
|
|
268
|
+
const held = gate
|
|
269
|
+
gate = undefined
|
|
270
|
+
for (const m of held ?? []) holdTab(() => (election?.leading() ? take(m) : upTo.postMessage(m)))
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---- the leader -------------------------------------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
let ws: WebSocket | undefined
|
|
277
|
+
let open = false
|
|
278
|
+
let user: string | undefined
|
|
279
|
+
let attempt = 0
|
|
280
|
+
let redialed = 0
|
|
281
|
+
let retry: ReturnType<typeof setTimeout> | undefined
|
|
282
|
+
let ping: ReturnType<typeof setInterval> | undefined
|
|
283
|
+
let dead: ReturnType<typeof setTimeout> | undefined
|
|
284
|
+
let idle: ReturnType<typeof setTimeout> | undefined
|
|
285
|
+
const queue: string[] = []
|
|
286
|
+
/** Which lines each tab has open on this socket. */
|
|
287
|
+
const opened = new Map<string, Set<string>>()
|
|
288
|
+
const ears = new Map<string, BroadcastChannel>()
|
|
289
|
+
let upFrom: BroadcastChannel | undefined
|
|
290
|
+
|
|
291
|
+
const offline = () => ! navigator.onLine
|
|
292
|
+
const send = (line: string) => {
|
|
293
|
+
if (open) ws!.send(line)
|
|
294
|
+
else {
|
|
295
|
+
queue.push(line)
|
|
296
|
+
wantSocket()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function take(m: Up) {
|
|
301
|
+
let set = opened.get(m.tab)
|
|
302
|
+
if (m.k === 'o') {
|
|
303
|
+
if (!set) {
|
|
304
|
+
opened.set(m.tab, (set = new Set()))
|
|
305
|
+
if (m.tab !== tab) whenGone(m.tab, () => dropTab(m.tab))
|
|
306
|
+
}
|
|
307
|
+
set.add(m.id)
|
|
308
|
+
clearTimeout(idle)
|
|
309
|
+
idle = undefined
|
|
310
|
+
} else if (m.k === 'x') set?.delete(m.id)
|
|
311
|
+
// A line that closed before the socket came never has to be opened.
|
|
312
|
+
if (m.k === 'x' && !open) {
|
|
313
|
+
const hello = queue.findIndex(q => q.startsWith(`o:${m.id}:`))
|
|
314
|
+
if (hello >= 0) queue.splice(hello, 1)
|
|
315
|
+
} else send(m.l)
|
|
316
|
+
if (![...opened.values()].some(s => s.size)) idle ??= setTimeout(() => {
|
|
317
|
+
idle = undefined
|
|
318
|
+
if (ws && ![...opened.values()].some(s => s.size)) lost(ws, 1000)
|
|
319
|
+
}, IDLE)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function dropTab(t: string) {
|
|
323
|
+
const set = opened.get(t)
|
|
324
|
+
if (!set) return
|
|
325
|
+
opened.delete(t)
|
|
326
|
+
ears.get(t)?.close()
|
|
327
|
+
ears.delete(t)
|
|
328
|
+
for (const id of set) take({ tab: t, id, k: 'x', l: `x:${id}:` })
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function arriveFrom(text: string) {
|
|
332
|
+
clearTimeout(dead)
|
|
333
|
+
dead = setTimeout(() => ws && lost(ws, 1006), DEAD)
|
|
334
|
+
if (text === 'p') return
|
|
335
|
+
if (text[0] === 'h') {
|
|
336
|
+
user = text.slice(2)
|
|
337
|
+
open = true
|
|
338
|
+
attempt = socketFailures = 0
|
|
339
|
+
for (const l of queue.splice(0)) ws!.send(l)
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
const owner = text.slice(2, text.indexOf('.', 2))
|
|
343
|
+
// The session this socket has ran out, and the cookies may have moved on: a new socket finds out, once.
|
|
344
|
+
const stale = text[0] === 'x' && text.includes(':4401:') && Date.now() - redialed > 30_000
|
|
345
|
+
if (stale) redialed = Date.now()
|
|
346
|
+
forward(owner, text)
|
|
347
|
+
if (stale && ws) lost(ws, HANDOVER, true)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function forward(owner: string, text: string) {
|
|
351
|
+
if (owner === tab) return arrive(text)
|
|
352
|
+
if (text[0] === 'x') opened.get(owner)?.delete(text.slice(2, text.indexOf(':', 2)))
|
|
353
|
+
let ear = ears.get(owner)
|
|
354
|
+
if (!ear) ears.set(owner, (ear = new BroadcastChannel(`${NAME}:down:${owner}`)))
|
|
355
|
+
ear.postMessage(text)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function wantSocket() {
|
|
359
|
+
if (!SOCKETS || ws || retry !== undefined || !election?.leading() || offline()) return
|
|
360
|
+
if (!attempt) return dial()
|
|
361
|
+
retry = setTimeout(() => {
|
|
362
|
+
retry = undefined
|
|
363
|
+
dial()
|
|
364
|
+
}, Math.random() * Math.min(CAP, BASE * 2 ** attempt))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function dial() {
|
|
368
|
+
if (ws || !election?.leading()) return
|
|
369
|
+
linkStats.dials++
|
|
370
|
+
const s = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_weft/ws?mux=1`)
|
|
371
|
+
ws = s
|
|
372
|
+
s.onmessage = e => arriveFrom(e.data)
|
|
373
|
+
s.onclose = () => lost(s, 1006)
|
|
374
|
+
ping = setInterval(() => { if (open) s.send('p') }, PING)
|
|
375
|
+
dead = setTimeout(() => lost(s, 1006), DEAD)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Not from the close event alone: offline, that can take as long as it likes, and WebKit's does. */
|
|
379
|
+
function lost(s: WebSocket, code: number, tell = true) {
|
|
380
|
+
if (ws !== s) return
|
|
381
|
+
const never = !open && code === 1006
|
|
382
|
+
ws = undefined
|
|
383
|
+
open = false
|
|
384
|
+
user = undefined
|
|
385
|
+
if (code !== HANDOVER) attempt++
|
|
386
|
+
if (never) socketFailures++
|
|
387
|
+
else if (code === 1006) socketFailures = 0
|
|
388
|
+
s.onmessage = s.onclose = null
|
|
389
|
+
s.close()
|
|
390
|
+
clearInterval(ping)
|
|
391
|
+
clearTimeout(dead)
|
|
392
|
+
queue.length = 0
|
|
393
|
+
opened.clear()
|
|
394
|
+
if (tell) notes?.postMessage({ t: 'lost', code, never })
|
|
395
|
+
endAll(code)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Offline, close first: a frame queued ahead of the close frame holds it back, and the server never hears. */
|
|
399
|
+
export function socketDown() {
|
|
400
|
+
if (ws) lost(ws, 1006)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function hangUp(code: number) {
|
|
404
|
+
clearTimeout(idle)
|
|
405
|
+
clearTimeout(retry)
|
|
406
|
+
idle = retry = undefined
|
|
407
|
+
// Not told: whoever leads next says so, and that ends every line in one go.
|
|
408
|
+
if (ws) lost(ws, code, false)
|
|
409
|
+
else endAll(code)
|
|
410
|
+
upFrom?.close()
|
|
411
|
+
upFrom = undefined
|
|
412
|
+
for (const ear of ears.values()) ear.close()
|
|
413
|
+
ears.clear()
|
|
414
|
+
attempt = 0
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function lead() {
|
|
418
|
+
linkStats.role = 'leader'
|
|
419
|
+
if (shareable()) {
|
|
420
|
+
upFrom = new BroadcastChannel(`${NAME}:up`)
|
|
421
|
+
upFrom.onmessage = e => take(e.data)
|
|
422
|
+
notes!.postMessage({ t: 'lead' })
|
|
423
|
+
}
|
|
424
|
+
// What this tab held for the session check goes straight out, and its lines were waiting on nobody.
|
|
425
|
+
if (gate) {
|
|
426
|
+
const held = gate
|
|
427
|
+
gate = undefined
|
|
428
|
+
for (const m of held) if (m) take(m)
|
|
429
|
+
} else for (const l of lines.values()) take({ tab, id: l.id, k: 'o', l: l.hello })
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---- the page ---------------------------------------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
function halt() {
|
|
435
|
+
if (!running) return
|
|
436
|
+
running = false
|
|
437
|
+
notes?.postMessage({ t: 'bye', tab })
|
|
438
|
+
election?.stop()
|
|
439
|
+
election = undefined
|
|
440
|
+
hangUp(1001)
|
|
441
|
+
for (const bc of [notes, down, upTo]) bc?.close()
|
|
442
|
+
notes = down = undefined
|
|
443
|
+
freeTab()
|
|
444
|
+
gate = undefined
|
|
445
|
+
session = undefined
|
|
446
|
+
linkStats.role = 'none'
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
let listening = false
|
|
450
|
+
function listen() {
|
|
451
|
+
if (listening) return
|
|
452
|
+
listening = true
|
|
453
|
+
addEventListener('pagehide', halt)
|
|
454
|
+
addEventListener('offline', socketDown)
|
|
455
|
+
addEventListener('online', () => {
|
|
456
|
+
clearTimeout(retry)
|
|
457
|
+
retry = undefined
|
|
458
|
+
attempt = 0
|
|
459
|
+
if (queue.length) wantSocket()
|
|
460
|
+
})
|
|
461
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The `db.adapter@1` default (research 13 §2.2): a list source over a Drizzle table. `columns` keeps its meaning here,
|
|
2
|
+
// so a table's other columns stay out of what's selected, returned or written.
|
|
3
|
+
|
|
4
|
+
import { and, asc, count, desc, eq, gt, inArray, isNull, lt, or, sql, type Column, type SQL } from 'drizzle-orm'
|
|
5
|
+
import { likeOf, pageOf, plan, rowOf, storedId, written, type DbAdapter, type ListPage, type ListParams, type ListSource, type SourceSpec } from './list'
|
|
6
|
+
|
|
7
|
+
/** The Drizzle client and table a list source runs on. */
|
|
8
|
+
export interface DrizzleTarget { db: object; table: object }
|
|
9
|
+
|
|
10
|
+
/** What every dialect's query builders share by name, though no type says so. */
|
|
11
|
+
type Chain = PromiseLike<object[]> & { from(t: object): Chain; where(w?: SQL): Chain; orderBy(...o: SQL[]): Chain; limit(n: number): Chain; offset(n: number): Chain; values(v: object): Chain; set(v: object): Chain; returning(s?: object): Chain }
|
|
12
|
+
interface Db { select(s: object): Chain; insert(t: object): Chain; update(t: object): Chain; delete(t: object): Chain }
|
|
13
|
+
|
|
14
|
+
/** A `ListSource` over a Drizzle table. */
|
|
15
|
+
export function drizzleSource<Row = Record<string, unknown>>({ db: handle, table }: DrizzleTarget, spec: SourceSpec): ListSource<Row> {
|
|
16
|
+
const db = handle as Db
|
|
17
|
+
const col = (name: string) => (table as Record<string, Column>)[name]!
|
|
18
|
+
const selection = Object.fromEntries(Object.values(spec.columns).map(c => [c, col(c)]))
|
|
19
|
+
const public_ = rowOf<Row>(spec)
|
|
20
|
+
const id = col(storedId(spec))
|
|
21
|
+
const reading = (rows: object[]) => rows.map(public_)
|
|
22
|
+
const one = async (query: Chain) => reading(await query)[0] ?? null
|
|
23
|
+
return {
|
|
24
|
+
async list(p: ListParams): Promise<ListPage<Row>> {
|
|
25
|
+
const q = plan(spec, p)
|
|
26
|
+
const filters: (SQL | undefined)[] = q.where.map(w => {
|
|
27
|
+
const some = w.values.filter(v => v !== null)
|
|
28
|
+
const hit = some.length === 1 ? eq(col(w.column), some[0]) : some.length ? inArray(col(w.column), some) : undefined
|
|
29
|
+
return w.values.includes(null) ? or(hit, isNull(col(w.column))) : hit
|
|
30
|
+
})
|
|
31
|
+
if (q.search) filters.push(or(...q.search.columns.map(c => sql`lower(${col(c)}) like ${likeOf(q.search!.text)} escape '!'`)))
|
|
32
|
+
const where = and(...filters)
|
|
33
|
+
const by = q.sort?.dir === 'desc' ? desc : asc
|
|
34
|
+
const past = q.sort?.dir === 'desc' ? lt : gt
|
|
35
|
+
const keyset = p.after ? (q.sort ? or(past(col(q.sort.column), p.after[0]), and(eq(col(q.sort.column), p.after[0]), past(id, p.after[1]))) : past(id, p.after[1])) : undefined
|
|
36
|
+
const order = q.sort ? [by(col(q.sort.column)), by(id)] : [asc(id)]
|
|
37
|
+
const cursor = p.mode === 'cursor'
|
|
38
|
+
const rows = await db.select(selection).from(table).where(and(where, keyset)).orderBy(...order).limit(cursor ? p.limit + 1 : p.limit).offset(p.offset)
|
|
39
|
+
const total = cursor ? undefined : ((await db.select({ n: count() }).from(table).where(where)) as { n: number }[])[0]!.n
|
|
40
|
+
return pageOf(reading(rows), p, { sort: q.sort?.field, id: q.id.field }, total)
|
|
41
|
+
},
|
|
42
|
+
get: id_ => one(db.select(selection).from(table).where(eq(id, id_)).limit(1)),
|
|
43
|
+
create: async data => (await one(db.insert(table).values(written(spec, data, true)).returning(selection)))!,
|
|
44
|
+
update: (id_, patch) => { const row = written(spec, patch); return one(Object.keys(row).length ? db.update(table).set(row).where(eq(id, id_)).returning(selection) : db.select(selection).from(table).where(eq(id, id_)).limit(1)) },
|
|
45
|
+
remove: async id_ => (await db.delete(table).where(eq(id, id_)).returning({ id })).length > 0,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `db.adapter@1` over a Drizzle table. */
|
|
50
|
+
export const adapter: DbAdapter<DrizzleTarget> = { name: 'drizzle', source: drizzleSource }
|