@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/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
+ }