@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/share.ts
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// `interaction({ share })` past this page (research 45 §4, §5). The only layer that knows tabs and the network, and only
|
|
2
|
+
// an app that shares loads it: the `weft()` plugin imports it where `share:` is used.
|
|
3
|
+
//
|
|
4
|
+
// tabs every tab posts its bits to one BroadcastChannel and keeps every sibling's, so there's no relay to run.
|
|
5
|
+
// leader one tab holds a Web Lock and the `$share` join, on the same socket and lifecycle as this tab's `channel`s.
|
|
6
|
+
// It sends the OR of the tabs' bits, at most one frame per GAP_NET, each carrying every key that changed.
|
|
7
|
+
// death each tab holds a lock of its own and siblings queue on it: it comes through the moment the tab is gone,
|
|
8
|
+
// crash included, with no heartbeat. Without Web Locks every tab is its own client and ages out by beat.
|
|
9
|
+
// stall a frozen leader keeps its lock, so a tab that gets no pong steals it. Two sockets, briefly, is the failure mode.
|
|
10
|
+
// remote other tabs, plus other clients. Cleared by a zero from the server, by a link down past GRACE, or by TTL.
|
|
11
|
+
//
|
|
12
|
+
// Everything here writes `remote` through `heard`, and only this page's three boxes come in through `set`: an echo has
|
|
13
|
+
// no path. A tab is a pure function of `tabs` and `clients`, and the socket a pure diff against them (`reconcile`), so a
|
|
14
|
+
// new leader runs the code an old one ran.
|
|
15
|
+
|
|
16
|
+
import { createRoot, effect, untrack } from '@weft-js/core'
|
|
17
|
+
import { NONE, seams, type Counts, type ShareLine } from '@weft-js/core/interaction'
|
|
18
|
+
import { connectBuiltIn, keepOpen, wakeSocket } from './channel'
|
|
19
|
+
import { BEAT, campaign, freeTab, holdTab, probeLink, tab, whenGone, type Campaign } from './link'
|
|
20
|
+
|
|
21
|
+
/** Ms between flushes to the other tabs. */
|
|
22
|
+
export const GAP_TAB = 16
|
|
23
|
+
/** Ms between flushes to the server. */
|
|
24
|
+
export const GAP_NET = 60
|
|
25
|
+
/** Ms a held key stands without being said again. */
|
|
26
|
+
export const TTL = 15_000
|
|
27
|
+
/** Ms what's held stands after the link drops, so a handover doesn't flash. */
|
|
28
|
+
export const GRACE = 1500
|
|
29
|
+
/** Entries one frame carries. */
|
|
30
|
+
export const BATCH = 400
|
|
31
|
+
/** Ms without a change before the socket may close. */
|
|
32
|
+
export const IDLE = 5000
|
|
33
|
+
|
|
34
|
+
/** Bump when a message below changes shape: tabs of two shapes then share nothing, and each is its own client. */
|
|
35
|
+
const WIRE = 1
|
|
36
|
+
const CHANNEL = `weft:ui:${WIRE}`
|
|
37
|
+
|
|
38
|
+
type Pair = [key: string, bits: number]
|
|
39
|
+
type Triple = [id: string, key: string, bits: number]
|
|
40
|
+
type Msg =
|
|
41
|
+
| { t: 'tab'; tab: string; keys: Pair[]; shown: boolean; k?: string | undefined }
|
|
42
|
+
| { t: 'bits'; tab: string; e: Pair[] }
|
|
43
|
+
| { t: 'shown'; tab: string; shown: boolean }
|
|
44
|
+
| { t: 'net'; s: Triple[] }
|
|
45
|
+
| { t: 'link'; up: boolean }
|
|
46
|
+
| { t: 'roll' } | { t: 'bye'; tab: string }
|
|
47
|
+
|
|
48
|
+
interface Client { bits: number; at: number; stale?: boolean }
|
|
49
|
+
|
|
50
|
+
/** What a test reads: `role`, and how much went where. */
|
|
51
|
+
export const shareStats = { role: 'none' as 'none' | 'leader' | 'follower', tab, conn: undefined as string | undefined, handed: 0, steals: 0, frames: 0, keysSent: 0, bc: 0 }
|
|
52
|
+
|
|
53
|
+
const noLocks = () => typeof navigator === 'undefined' || !navigator.locks
|
|
54
|
+
|
|
55
|
+
const lines = new Map<string, (c: Counts) => void>()
|
|
56
|
+
const shown = new Map<string, Counts>()
|
|
57
|
+
/** key → bits, per tab, mine included. */
|
|
58
|
+
const tabs = new Map<string, Map<string, number>>()
|
|
59
|
+
const tabAt = new Map<string, number>()
|
|
60
|
+
const visible = new Map<string, boolean>()
|
|
61
|
+
const mine = new Map<string, number>()
|
|
62
|
+
const watched = new Set<string>()
|
|
63
|
+
const siblings = new Set<string>()
|
|
64
|
+
/** key → other clients. Everything the leader joined; a follower keeps what its own lines want. */
|
|
65
|
+
const clients = new Map<string, Map<string, Client>>()
|
|
66
|
+
|
|
67
|
+
const locks = () => !noLocks()
|
|
68
|
+
const leading = () => shareStats.role === 'leader'
|
|
69
|
+
const looking = () => typeof document === 'undefined' || document.visibilityState !== 'hidden'
|
|
70
|
+
|
|
71
|
+
let bc: BroadcastChannel | undefined
|
|
72
|
+
let running = false
|
|
73
|
+
let lockReady = false
|
|
74
|
+
const early: Msg[] = []
|
|
75
|
+
let election: Campaign | undefined
|
|
76
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
77
|
+
|
|
78
|
+
function post(m: Msg) {
|
|
79
|
+
if (locks() && !lockReady) return void early.push(m)
|
|
80
|
+
shareStats.bc++
|
|
81
|
+
bc?.postMessage(m)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- what a tab shows ------------------------------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function render(key: string) {
|
|
87
|
+
const heard = lines.get(key)
|
|
88
|
+
if (!heard) return
|
|
89
|
+
let hover = 0, focus = 0, pressed = 0
|
|
90
|
+
const add = (bits: number) => {
|
|
91
|
+
if (bits & 1) hover++
|
|
92
|
+
if (bits & 2) focus++
|
|
93
|
+
if (bits & 4) pressed++
|
|
94
|
+
}
|
|
95
|
+
for (const [t, keys] of tabs) if (t !== tab) add(keys.get(key) ?? 0)
|
|
96
|
+
for (const e of clients.get(key)?.values() ?? []) add(e.bits)
|
|
97
|
+
const was = shown.get(key) ?? NONE
|
|
98
|
+
if (was.hover === hover && was.focus === focus && was.pressed === pressed) return
|
|
99
|
+
const now = hover || focus || pressed ? { hover, focus, pressed } : NONE
|
|
100
|
+
shown.set(key, now)
|
|
101
|
+
heard(now)
|
|
102
|
+
}
|
|
103
|
+
const renderAll = () => { for (const k of lines.keys()) render(k) }
|
|
104
|
+
|
|
105
|
+
function dropTab(t: string) {
|
|
106
|
+
visible.delete(t)
|
|
107
|
+
if (!tabs.delete(t)) return
|
|
108
|
+
tabAt.delete(t)
|
|
109
|
+
watched.delete(t)
|
|
110
|
+
renderAll()
|
|
111
|
+
reconcile()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** It comes through when the tab is gone, however it went. The tab held it before it first said anything. */
|
|
115
|
+
function watch(t: string) {
|
|
116
|
+
if (t === tab || watched.has(t) || !locks()) return
|
|
117
|
+
watched.add(t)
|
|
118
|
+
whenGone(t, () => dropTab(t))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function hear(s: Triple[]) {
|
|
122
|
+
const at = Date.now(), touched = new Set<string>()
|
|
123
|
+
for (const [id, key, bits] of s) {
|
|
124
|
+
if (siblings.has(id) || (!leading() && !lines.has(key))) continue
|
|
125
|
+
let held = clients.get(key)
|
|
126
|
+
if (bits) {
|
|
127
|
+
if (!held) clients.set(key, (held = new Map()))
|
|
128
|
+
held.set(id, { bits, at })
|
|
129
|
+
} else if (!held?.delete(id)) continue
|
|
130
|
+
touched.add(key)
|
|
131
|
+
}
|
|
132
|
+
for (const k of touched) render(k)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function dropClients(which: (e: Client, id: string) => boolean) {
|
|
136
|
+
for (const [key, held] of clients) {
|
|
137
|
+
let changed = false
|
|
138
|
+
for (const [id, e] of held) if (which(e, id)) changed = held.delete(id)
|
|
139
|
+
if (!held.size) clients.delete(key)
|
|
140
|
+
if (changed) render(key)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Down: what's held stands for GRACE, so a handover doesn't flash. Up: holders re-say, and what nobody re-says goes. */
|
|
145
|
+
let settling: ReturnType<typeof setTimeout> | undefined
|
|
146
|
+
function link(on: boolean) {
|
|
147
|
+
clearTimeout(settling)
|
|
148
|
+
if (on) for (const held of clients.values()) for (const e of held.values()) e.stale = true
|
|
149
|
+
settling = setTimeout(() => dropClients(e => !on || !!e.stale), GRACE)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function receive(m: Msg) {
|
|
153
|
+
if (m.t === 'tab') {
|
|
154
|
+
tabs.set(m.tab, new Map(m.keys))
|
|
155
|
+
tabAt.set(m.tab, Date.now())
|
|
156
|
+
visible.set(m.tab, m.shown)
|
|
157
|
+
if (m.k && !siblings.has(m.k)) {
|
|
158
|
+
siblings.add(m.k)
|
|
159
|
+
dropClients((_, id) => id === m.k)
|
|
160
|
+
}
|
|
161
|
+
watch(m.tab)
|
|
162
|
+
renderAll()
|
|
163
|
+
reconcile()
|
|
164
|
+
if (m.shown) wakeIfLeading()
|
|
165
|
+
// A tab that arrived late has what the socket already heard.
|
|
166
|
+
if (leading()) {
|
|
167
|
+
const s: Triple[] = []
|
|
168
|
+
for (const [key] of m.keys) for (const [id, e] of clients.get(key) ?? []) s.push([id, key, e.bits])
|
|
169
|
+
if (s.length) post({ t: 'net', s })
|
|
170
|
+
}
|
|
171
|
+
} else if (m.t === 'bits') {
|
|
172
|
+
const keys = tabs.get(m.tab) ?? new Map<string, number>()
|
|
173
|
+
tabs.set(m.tab, keys)
|
|
174
|
+
tabAt.set(m.tab, Date.now())
|
|
175
|
+
for (const [key, bits] of m.e) keys.set(key, bits)
|
|
176
|
+
watch(m.tab)
|
|
177
|
+
for (const [key] of m.e) render(key)
|
|
178
|
+
reconcile()
|
|
179
|
+
} else if (m.t === 'shown') {
|
|
180
|
+
visible.set(m.tab, m.shown)
|
|
181
|
+
if (m.shown) wakeIfLeading()
|
|
182
|
+
} else if (m.t === 'roll') announce()
|
|
183
|
+
else if (m.t === 'bye') dropTab(m.tab)
|
|
184
|
+
else if (m.t === 'net') hear(m.s)
|
|
185
|
+
else if (m.t === 'link') link(m.up)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const wakeIfLeading = () => { if (leading()) wakeSocket() }
|
|
189
|
+
|
|
190
|
+
function announce() { post({ t: 'tab', tab, keys: [...mine], shown: looking(), ...(locks() ? {} : { k: shareStats.conn }) }) }
|
|
191
|
+
|
|
192
|
+
/** 200 rows mounting are one post and one frame. */
|
|
193
|
+
let queued = false
|
|
194
|
+
function changedKeys() {
|
|
195
|
+
if (queued) return
|
|
196
|
+
queued = true
|
|
197
|
+
queueMicrotask(() => {
|
|
198
|
+
queued = false
|
|
199
|
+
if (!running) return
|
|
200
|
+
announce()
|
|
201
|
+
reconcile()
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- to the other tabs, once a frame ----------------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
const dirtyTab = new Set<string>()
|
|
208
|
+
let tabLast = 0
|
|
209
|
+
let tabTimer: ReturnType<typeof setTimeout> | undefined
|
|
210
|
+
function flushTab() {
|
|
211
|
+
tabTimer = undefined
|
|
212
|
+
tabLast = performance.now()
|
|
213
|
+
if (dirtyTab.size) post({ t: 'bits', tab, e: [...dirtyTab].map(key => [key, mine.get(key) ?? 0]) })
|
|
214
|
+
dirtyTab.clear()
|
|
215
|
+
}
|
|
216
|
+
function queueTab(key: string) {
|
|
217
|
+
dirtyTab.add(key)
|
|
218
|
+
if (tabTimer !== undefined) return
|
|
219
|
+
const wait = GAP_TAB - (performance.now() - tabLast)
|
|
220
|
+
if (wait <= 0) flushTab()
|
|
221
|
+
else tabTimer = setTimeout(flushTab, wait)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- the leader: the OR of every tab, on one join ---------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
type Conn = ReturnType<typeof connectBuiltIn>
|
|
227
|
+
let conn: Conn | undefined
|
|
228
|
+
let stopConn: (() => void) | undefined
|
|
229
|
+
let open = false
|
|
230
|
+
/** key → what the server was last told. In here means asked for. */
|
|
231
|
+
const sent = new Map<string, number>()
|
|
232
|
+
const refused = new Set<string>()
|
|
233
|
+
const dirtyNet = new Set<string>()
|
|
234
|
+
const toAdd = new Set<string>()
|
|
235
|
+
const toDrop = new Set<string>()
|
|
236
|
+
let netLast = 0
|
|
237
|
+
let idle: ReturnType<typeof setTimeout> | undefined
|
|
238
|
+
let netTimer: ReturnType<typeof setTimeout> | undefined
|
|
239
|
+
|
|
240
|
+
const upstream = (key: string) => {
|
|
241
|
+
if (!locks()) return mine.get(key) ?? 0
|
|
242
|
+
let bits = 0
|
|
243
|
+
for (const keys of tabs.values()) bits |= keys.get(key) ?? 0
|
|
244
|
+
return bits
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function wanted() {
|
|
248
|
+
if (!locks()) return new Set(mine.keys())
|
|
249
|
+
const out = new Set<string>()
|
|
250
|
+
for (const keys of tabs.values()) for (const k of keys.keys()) out.add(k)
|
|
251
|
+
return out
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One frame per GAP_NET, `u` first: the bucket is 20 a second, and a frame carries every key that's waiting. */
|
|
255
|
+
function netTick() {
|
|
256
|
+
netTimer = undefined
|
|
257
|
+
if (!conn || !open) return
|
|
258
|
+
let name: 'u' | 'set' | undefined, msg: unknown, size = 0
|
|
259
|
+
if (toAdd.size || toDrop.size) {
|
|
260
|
+
const add = [...toAdd].slice(0, BATCH), drop = [...toDrop].slice(0, BATCH - add.length)
|
|
261
|
+
for (const k of add) toAdd.delete(k)
|
|
262
|
+
for (const k of drop) toDrop.delete(k)
|
|
263
|
+
name = 'u'
|
|
264
|
+
msg = { add, drop }
|
|
265
|
+
size = add.length + drop.length
|
|
266
|
+
} else {
|
|
267
|
+
const e: Pair[] = []
|
|
268
|
+
for (const key of dirtyNet) {
|
|
269
|
+
if (e.length === BATCH) break
|
|
270
|
+
dirtyNet.delete(key)
|
|
271
|
+
if (!sent.has(key) || refused.has(key)) continue
|
|
272
|
+
const bits = upstream(key)
|
|
273
|
+
// Off and on again inside the gap: nothing to say. The latest value is all that's kept.
|
|
274
|
+
if (bits !== sent.get(key)) {
|
|
275
|
+
sent.set(key, bits)
|
|
276
|
+
e.push([key, bits])
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (e.length) {
|
|
280
|
+
name = 'set'
|
|
281
|
+
msg = { e }
|
|
282
|
+
size = e.length
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (name) {
|
|
286
|
+
netLast = performance.now()
|
|
287
|
+
shareStats.frames++
|
|
288
|
+
shareStats.keysSent += size
|
|
289
|
+
conn.send(name as never, msg as never).catch(() => {}) // a dropped link re-says everything
|
|
290
|
+
}
|
|
291
|
+
if (toAdd.size || toDrop.size || dirtyNet.size) netSoon()
|
|
292
|
+
}
|
|
293
|
+
/** Not a timer when the gap has passed: the leader is often a hidden tab, where a timer waits a second or more. */
|
|
294
|
+
function netSoon() {
|
|
295
|
+
const wait = GAP_NET - (performance.now() - netLast)
|
|
296
|
+
if (wait > 0) {
|
|
297
|
+
netTimer ??= setTimeout(netTick, wait)
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
clearTimeout(netTimer)
|
|
301
|
+
netTick()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Whatever changed in `tabs`, the socket follows. Nothing wanted for a while, no socket: a page left its rows behind. */
|
|
305
|
+
function reconcile() {
|
|
306
|
+
if (!leading()) return
|
|
307
|
+
const want = wanted()
|
|
308
|
+
if (want.size) {
|
|
309
|
+
clearTimeout(idle)
|
|
310
|
+
idle = undefined
|
|
311
|
+
dial()
|
|
312
|
+
} else idle ??= setTimeout(() => {
|
|
313
|
+
idle = undefined
|
|
314
|
+
if (leading() && !wanted().size) hangUp()
|
|
315
|
+
}, IDLE)
|
|
316
|
+
if (!open) return
|
|
317
|
+
for (const key of want) {
|
|
318
|
+
if (sent.has(key)) {
|
|
319
|
+
if (upstream(key) !== sent.get(key)) dirtyNet.add(key)
|
|
320
|
+
continue
|
|
321
|
+
}
|
|
322
|
+
sent.set(key, 0)
|
|
323
|
+
toDrop.delete(key)
|
|
324
|
+
if (!refused.has(key)) toAdd.add(key)
|
|
325
|
+
if (upstream(key)) dirtyNet.add(key)
|
|
326
|
+
}
|
|
327
|
+
for (const key of Array.from(sent.keys())) {
|
|
328
|
+
if (want.has(key)) continue
|
|
329
|
+
sent.delete(key)
|
|
330
|
+
dirtyNet.delete(key)
|
|
331
|
+
clients.delete(key)
|
|
332
|
+
if (!toAdd.delete(key)) toDrop.add(key)
|
|
333
|
+
}
|
|
334
|
+
if (toAdd.size || toDrop.size || dirtyNet.size) netSoon()
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function linked(live: boolean) {
|
|
338
|
+
if (live === open) return
|
|
339
|
+
open = live
|
|
340
|
+
if (!live) {
|
|
341
|
+
sent.clear()
|
|
342
|
+
toAdd.clear()
|
|
343
|
+
toDrop.clear()
|
|
344
|
+
dirtyNet.clear()
|
|
345
|
+
refused.clear()
|
|
346
|
+
clearTimeout(netTimer)
|
|
347
|
+
netTimer = undefined
|
|
348
|
+
}
|
|
349
|
+
link(live)
|
|
350
|
+
post({ t: 'link', up: live })
|
|
351
|
+
if (live) reconcile()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function dial() {
|
|
355
|
+
if (conn) return
|
|
356
|
+
stopConn = createRoot(dispose => {
|
|
357
|
+
conn = connectBuiltIn('$share')
|
|
358
|
+
conn.on('b', ({ s }: { s: Triple[] }) => {
|
|
359
|
+
hear(s)
|
|
360
|
+
if (locks()) post({ t: 'net', s })
|
|
361
|
+
})
|
|
362
|
+
conn.on('ok', (m: { id: string; no: string[] }) => {
|
|
363
|
+
shareStats.conn = m.id
|
|
364
|
+
const lost: Triple[] = []
|
|
365
|
+
for (const k of m.no) {
|
|
366
|
+
refused.add(k)
|
|
367
|
+
dirtyNet.delete(k)
|
|
368
|
+
for (const id of clients.get(k)?.keys() ?? []) lost.push([id, k, 0])
|
|
369
|
+
}
|
|
370
|
+
if (lost.length) {
|
|
371
|
+
hear(lost)
|
|
372
|
+
if (locks()) post({ t: 'net', s: lost })
|
|
373
|
+
}
|
|
374
|
+
if (!locks()) announce()
|
|
375
|
+
})
|
|
376
|
+
effect(() => conn!.status.value, s => linked(s === 'open'))
|
|
377
|
+
return dispose
|
|
378
|
+
}, null)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function hangUp() {
|
|
382
|
+
linked(false)
|
|
383
|
+
stopConn?.()
|
|
384
|
+
stopConn = conn = undefined
|
|
385
|
+
shareStats.conn = undefined
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function lead() {
|
|
389
|
+
shareStats.role = 'leader'
|
|
390
|
+
shareStats.handed++
|
|
391
|
+
link(false)
|
|
392
|
+
post({ t: 'link', up: false })
|
|
393
|
+
post({ t: 'roll' })
|
|
394
|
+
reconcile()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---- the page --------------------------------------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
function up() {
|
|
400
|
+
if (running) return
|
|
401
|
+
running = true
|
|
402
|
+
bc = new BroadcastChannel(CHANNEL)
|
|
403
|
+
bc.onmessage = e => receive(e.data)
|
|
404
|
+
tabs.set(tab, mine)
|
|
405
|
+
holdTab(() => {
|
|
406
|
+
lockReady = true
|
|
407
|
+
for (const m of early.splice(0)) post(m)
|
|
408
|
+
})
|
|
409
|
+
shareStats.role = 'follower'
|
|
410
|
+
election = campaign(CHANNEL, lead, () => {
|
|
411
|
+
hangUp()
|
|
412
|
+
shareStats.role = 'follower'
|
|
413
|
+
}, () => void shareStats.steals++)
|
|
414
|
+
election.pinged = sayHeld // a hidden leader's own beat is throttled; a sibling's ping isn't
|
|
415
|
+
announce()
|
|
416
|
+
post({ t: 'roll' })
|
|
417
|
+
timer = setInterval(beat, BEAT)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function beat() {
|
|
421
|
+
const now = Date.now()
|
|
422
|
+
dropClients(e => now - e.at > TTL)
|
|
423
|
+
if (!locks()) {
|
|
424
|
+
announce()
|
|
425
|
+
for (const [t, at] of tabAt) if (now - at > TTL) dropTab(t)
|
|
426
|
+
}
|
|
427
|
+
if (leading()) sayHeld()
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** A held bit is said again, so that if this instance's server dies, peers age it out. Nothing held, nothing sent. */
|
|
431
|
+
let saidAt = 0
|
|
432
|
+
function sayHeld() {
|
|
433
|
+
if (Date.now() - saidAt < BEAT - 1000) return
|
|
434
|
+
saidAt = Date.now()
|
|
435
|
+
for (const [key, bits] of sent) if (bits) {
|
|
436
|
+
sent.set(key, -1)
|
|
437
|
+
dirtyNet.add(key)
|
|
438
|
+
}
|
|
439
|
+
if (dirtyNet.size) netSoon()
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function halt() {
|
|
443
|
+
if (!running) return
|
|
444
|
+
post({ t: 'bye', tab })
|
|
445
|
+
running = false
|
|
446
|
+
lockReady = false
|
|
447
|
+
hangUp()
|
|
448
|
+
shareStats.role = 'none'
|
|
449
|
+
clearInterval(timer)
|
|
450
|
+
clearTimeout(settling)
|
|
451
|
+
clearTimeout(idle)
|
|
452
|
+
idle = undefined
|
|
453
|
+
election?.stop()
|
|
454
|
+
election = undefined
|
|
455
|
+
freeTab()
|
|
456
|
+
bc?.close()
|
|
457
|
+
bc = undefined
|
|
458
|
+
tabs.clear()
|
|
459
|
+
tabAt.clear()
|
|
460
|
+
visible.clear()
|
|
461
|
+
watched.clear()
|
|
462
|
+
clients.clear()
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (typeof window !== 'undefined') {
|
|
466
|
+
addEventListener('pagehide', halt)
|
|
467
|
+
addEventListener('pageshow', e => {
|
|
468
|
+
if (!(e).persisted || !lines.size) return
|
|
469
|
+
up()
|
|
470
|
+
renderAll()
|
|
471
|
+
})
|
|
472
|
+
document.addEventListener('visibilitychange', () => { if (running) post({ t: 'shown', tab, shown: looking() }) })
|
|
473
|
+
keepOpen.stay = () => [...visible.values()].some(Boolean)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
seams.line = (key, heard): ShareLine => {
|
|
477
|
+
lines.set(key, heard)
|
|
478
|
+
mine.set(key, 0)
|
|
479
|
+
if (!running) up()
|
|
480
|
+
changedKeys()
|
|
481
|
+
untrack(() => render(key))
|
|
482
|
+
return {
|
|
483
|
+
set(bits) {
|
|
484
|
+
mine.set(key, bits)
|
|
485
|
+
queueTab(key)
|
|
486
|
+
if (!running) return
|
|
487
|
+
reconcile()
|
|
488
|
+
if (bits && !leading()) {
|
|
489
|
+
election?.probe()
|
|
490
|
+
probeLink()
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
leave() {
|
|
494
|
+
lines.delete(key)
|
|
495
|
+
shown.delete(key)
|
|
496
|
+
mine.delete(key)
|
|
497
|
+
dirtyTab.delete(key)
|
|
498
|
+
if (!running) return
|
|
499
|
+
if (!leading()) clients.delete(key)
|
|
500
|
+
changedKeys()
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
}
|
package/src/sheets.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// A page's stylesheets as two files: what every route shares, and what its own route adds, each hashed and cached for
|
|
2
|
+
// good. A build splits CSS by chunk, so a page would link 5 to 11 sheets. Only what's on every route is shared, so no
|
|
3
|
+
// route's CSS can reach a page it wasn't written for. The chunks' own sheets stay: a client route change fetches those.
|
|
4
|
+
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { dirname, resolve } from 'node:path'
|
|
8
|
+
|
|
9
|
+
/** A relative `url()` means something else from another file, and that file keeps its own link. */
|
|
10
|
+
const movable = css => !/url\(\s*(?!["']?(?:\/|data:|https?:|#|%23))/.test(css) && !/@import\b/.test(css)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string[][]} routes each route's sheets, in cascade order
|
|
14
|
+
* @param {(name: string) => string} text
|
|
15
|
+
*/
|
|
16
|
+
export function planSheets(routes, text) {
|
|
17
|
+
const files = {}
|
|
18
|
+
const join = has => {
|
|
19
|
+
if (!has.length) return undefined
|
|
20
|
+
const css = has.map(text).join('')
|
|
21
|
+
const file = `assets/sheet-${createHash('sha256').update(css).digest('base64url').slice(0, 8)}.css`
|
|
22
|
+
files[file] = css
|
|
23
|
+
return { file, has }
|
|
24
|
+
}
|
|
25
|
+
const everywhere = n => routes.every(r => r.includes(n))
|
|
26
|
+
const shared = join([...new Set(routes.flat())].filter(n => everywhere(n) && movable(text(n))))
|
|
27
|
+
const seen = new Map()
|
|
28
|
+
for (const list of routes) {
|
|
29
|
+
const route = join([...new Set(list)].filter(n => !everywhere(n) && movable(text(n))))
|
|
30
|
+
if (route) seen.set(route.file, route)
|
|
31
|
+
}
|
|
32
|
+
return { shared, routes: [...seen.values()], files }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const FROM = /(?:^|\n)\s*(?:import|export)\s[^'"\n]*?from\s+['"](\.[^'"]+)['"]/g
|
|
36
|
+
const EXTS = ['', '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts']
|
|
37
|
+
|
|
38
|
+
/** The app's own source files `files` import: where the islands a route can show are declared. */
|
|
39
|
+
export function reach(files) {
|
|
40
|
+
const seen = new Set()
|
|
41
|
+
const walk = f => {
|
|
42
|
+
if (seen.has(f)) return
|
|
43
|
+
seen.add(f)
|
|
44
|
+
for (const [, spec] of readFileSync(f, 'utf8').matchAll(FROM)) {
|
|
45
|
+
const to = EXTS.map(e => resolve(dirname(f), spec) + e).find(c => /\.[jt]sx?$/.test(c) && existsSync(c))
|
|
46
|
+
if (to) walk(to)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
files.filter(existsSync).forEach(walk)
|
|
50
|
+
return seen
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Which of the plan's sheets a page's list goes in. What neither holds keeps its own link. */
|
|
54
|
+
export function pick(plan, list) {
|
|
55
|
+
const left = new Set(list)
|
|
56
|
+
const out = []
|
|
57
|
+
if (plan.shared?.has.some(n => left.has(n))) {
|
|
58
|
+
out.push(plan.shared)
|
|
59
|
+
plan.shared.has.forEach(n => left.delete(n))
|
|
60
|
+
}
|
|
61
|
+
let best, most = 0
|
|
62
|
+
for (const r of plan.routes) {
|
|
63
|
+
const hits = r.has.filter(n => left.has(n)).length
|
|
64
|
+
if (hits > most && r.has.every(n => list.includes(n) || left.has(n))) { best = r; most = hits }
|
|
65
|
+
}
|
|
66
|
+
if (!best) for (const r of plan.routes) { // a route's sheet may hold a little more than this page showed: never another route's
|
|
67
|
+
const hits = r.has.filter(n => left.has(n)).length
|
|
68
|
+
if (hits > most) { best = r; most = hits }
|
|
69
|
+
}
|
|
70
|
+
if (best) {
|
|
71
|
+
out.push(best)
|
|
72
|
+
best.has.forEach(n => left.delete(n))
|
|
73
|
+
}
|
|
74
|
+
return { sheets: out, rest: list.filter(n => left.has(n)) }
|
|
75
|
+
}
|