@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.
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 +93 -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 +622 -0
  19. package/src/client.tsx +20 -0
  20. package/src/contract.mjs +312 -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 +461 -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 +147 -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 +202 -0
  63. package/src/rust.ts +333 -0
  64. package/src/server.tsx +1021 -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
@@ -0,0 +1,99 @@
1
+ // The change feed's triggers, as SQL (`weft db live`, and tests). A trigger says which table and which primary keys
2
+ // changed, never a column of the row: a notification can be read by anyone who can LISTEN, and the hub only needs tags.
3
+ //
4
+ // postgresql statement triggers with transition tables, so 10k rows in one statement are one NOTIFY: up to 32 ids, or
5
+ // none past that, which means "this whole table". NOTIFY is in the transaction: a rollback sends nothing.
6
+ // sqlite no notify across processes: rows in a capped log, which the watcher reads when `data_version` moves.
7
+ // mysql the same log, read on a timer. A MySQL trigger is per row and can't reach out.
8
+
9
+ export const CHANNEL = 'weft_changes'
10
+ export const LOG = '__weft_changes'
11
+ /** More ids than this in one statement is a table-level change. */
12
+ export const MAX_IDS = 32
13
+ /** Rows SQLite's log keeps. A reader further behind than this has lost its place, and pokes every table. */
14
+ export const LOG_ROWS = 4096
15
+ export const BREAK = '\n--> statement-breakpoint\n'
16
+
17
+ const pgName = n => `"${n.replaceAll('"', '""')}"`
18
+ const myName = n => `\`${n.replaceAll('`', '``')}\``
19
+ const literal = s => `'${s.replaceAll("'", "''")}'`
20
+ const safe = t => t.replace(/\W/g, '_')
21
+
22
+ function postgres(tables) {
23
+ const fn = `CREATE OR REPLACE FUNCTION weft_changes_notify() RETURNS trigger AS $weft$
24
+ DECLARE
25
+ ids text[];
26
+ body text;
27
+ BEGIN
28
+ IF TG_OP <> 'TRUNCATE' AND TG_NARGS > 0 THEN
29
+ EXECUTE format('SELECT array_agg(id) FROM (SELECT concat_ws(%L, %s) AS id FROM weft_rows LIMIT ${MAX_IDS + 1}) s', ':', (
30
+ SELECT string_agg(format('%I', a), ', ') FROM unnest(TG_ARGV) a
31
+ )) INTO ids;
32
+ IF ids IS NULL THEN RETURN NULL; END IF;
33
+ IF cardinality(ids) <= ${MAX_IDS} THEN
34
+ body := json_build_object('t', TG_TABLE_NAME, 'i', ids)::text;
35
+ END IF;
36
+ END IF;
37
+ IF body IS NULL OR octet_length(body) > 7900 THEN
38
+ body := json_build_object('t', TG_TABLE_NAME)::text;
39
+ END IF;
40
+ PERFORM pg_notify(${literal(CHANNEL)}, body);
41
+ RETURN NULL;
42
+ END;
43
+ $weft$ LANGUAGE plpgsql`
44
+ const list = tables.flatMap(({ name, pk }) => {
45
+ const args = pk.map(literal).join(', ')
46
+ const on = pgName(name)
47
+ const trigger = (suffix, event, rows) => [
48
+ `DROP TRIGGER IF EXISTS weft_changes_${suffix} ON ${on}`,
49
+ `CREATE TRIGGER weft_changes_${suffix} AFTER ${event} ON ${on} ${rows ? `REFERENCING ${rows} TABLE AS weft_rows ` : ''}FOR EACH STATEMENT EXECUTE FUNCTION weft_changes_notify(${args})`,
50
+ ]
51
+ return [...trigger('ins', 'INSERT', 'NEW'), ...trigger('upd', 'UPDATE', 'NEW'), ...trigger('del', 'DELETE', 'OLD'), ...trigger('trn', 'TRUNCATE')]
52
+ })
53
+ return [fn, ...list]
54
+ }
55
+
56
+ function sqlite(tables) {
57
+ const log = `CREATE TABLE IF NOT EXISTS ${LOG} (seq INTEGER PRIMARY KEY AUTOINCREMENT, tbl TEXT NOT NULL, pk TEXT)`
58
+ const list = tables.flatMap(({ name, pk }) => {
59
+ const id = row => (pk.length ? pk.map(c => `${row}.${pgName(c)}`).join(` || ':' || `) : 'NULL')
60
+ const trigger = (suffix, event, row) => [
61
+ `DROP TRIGGER IF EXISTS weft_changes_${safe(name)}_${suffix}`,
62
+ `CREATE TRIGGER weft_changes_${safe(name)}_${suffix} AFTER ${event} ON ${pgName(name)} BEGIN
63
+ INSERT INTO ${LOG} (tbl, pk) VALUES (${literal(name)}, ${id(row)});
64
+ DELETE FROM ${LOG} WHERE seq <= last_insert_rowid() - ${LOG_ROWS};
65
+ END`,
66
+ ]
67
+ return [...trigger('ins', 'INSERT', 'NEW'), ...trigger('upd', 'UPDATE', 'NEW'), ...trigger('del', 'DELETE', 'OLD')]
68
+ })
69
+ return [log, ...list]
70
+ }
71
+
72
+ function mysql(tables) {
73
+ const log = `CREATE TABLE IF NOT EXISTS ${LOG} (seq BIGINT AUTO_INCREMENT PRIMARY KEY, tbl VARCHAR(64) NOT NULL, pk VARCHAR(255), at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY at_idx (at))`
74
+ const list = tables.flatMap(({ name, pk }) => {
75
+ const id = row => (pk.length ? `CONCAT_WS(':', ${pk.map(c => `${row}.${myName(c)}`).join(', ')})` : 'NULL')
76
+ const trigger = (suffix, event, row) => [
77
+ `DROP TRIGGER IF EXISTS weft_changes_${safe(name)}_${suffix}`,
78
+ `CREATE TRIGGER weft_changes_${safe(name)}_${suffix} AFTER ${event} ON ${myName(name)} FOR EACH ROW INSERT INTO ${LOG} (tbl, pk) VALUES (${literal(name)}, ${id(row)})`,
79
+ ]
80
+ return [...trigger('ins', 'INSERT', 'NEW'), ...trigger('upd', 'UPDATE', 'NEW'), ...trigger('del', 'DELETE', 'OLD')]
81
+ })
82
+ return [log, ...list]
83
+ }
84
+
85
+ /**
86
+ * The statements that make `tables` report their changes. Each can run twice.
87
+ * @param {'postgresql' | 'sqlite' | 'mysql'} dialect
88
+ * @param {{ name: string, pk: string[] }[]} tables
89
+ * @returns {string[]}
90
+ */
91
+ export function changeStatements(dialect, tables) {
92
+ if (dialect === 'postgresql') return postgres(tables)
93
+ if (dialect === 'sqlite') return sqlite(tables)
94
+ if (dialect === 'mysql') return mysql(tables)
95
+ throw new Error(`no change triggers for ${String(dialect)}`)
96
+ }
97
+
98
+ /** A migration file, in drizzle-kit's format. */
99
+ export const changeSql = (dialect, tables) => `-- weft: report changes to ${tables.map(t => t.name).join(', ')} (\`weft db live\`). Ids only, never a row.\n${changeStatements(dialect, tables).join(`;${BREAK}`)};\n`
package/src/changes.ts ADDED
@@ -0,0 +1,253 @@
1
+ // Writes from outside the app reach `.live` queries. A database tells this module which table and ids changed; it turns
2
+ // those into tags and the hub pokes whoever subscribed to them. Clients refetch through their own `query`, so `auth`
3
+ // runs per user and no row travels with a poke: a user who can't see a row gets nothing new.
4
+ //
5
+ // hold A change waits `hold` ms. A mutation's poke covers what committed before it, so an app write needs no
6
+ // second poke: before poking, the mutation sends a barrier down the same feed, which arrives after every
7
+ // change committed earlier. Whatever a barrier's tags cover is dropped. No clock is involved, and a change
8
+ // the barrier doesn't cover (cron, psql, a job with no `invalidates`) is delivered. Too slow a barrier costs
9
+ // one extra refetch, never a missed one.
10
+ // local Every instance listens and pokes only its own connections: each client gets one poke with no leader to
11
+ // elect. `listener: 'external'` is the other way round: one process listens, and publishes to the bus.
12
+ // rate After a flush the next waits `throttle` ms, so a writer in a loop can't drive refetches at its own pace.
13
+ //
14
+ // `q.live()` shows the mode (`liveMode`) and `degraded` or `reconnecting`, because a page that merely looks live is worse
15
+ // than one that says it isn't. Sources are in db/changes-*.ts; each probes what it can do before it says so.
16
+
17
+ import { StartError } from './data'
18
+ import type { DbHandle } from './db/core'
19
+
20
+ export type { LiveMode } from './data'
21
+
22
+ /** What changed: never what it changed to. No `id`: the whole table. */
23
+ export interface Change { table: string; id?: string }
24
+
25
+ /** What a source learned about this database, by trying it. `feed` sees every change; `poll` only estimates. */
26
+ export interface Probe {
27
+ mode: 'feed' | 'poll' | 'off'
28
+ /** `postgres-notify`, `sqlite-data-version`, `mysql-log`, `mongo-change-stream`, `postgres-stats`… */
29
+ via: string
30
+ /** Why this and not something better. */
31
+ why?: string
32
+ fix?: string
33
+ }
34
+
35
+ /** Where a source says what changed. */
36
+ export interface Sink {
37
+ changes(list: Change[]): void
38
+ /** A barrier arrived: what came before it and these tags cover is on its way as a poke. */
39
+ settled(tags: string[]): void
40
+ /** Changes may have gone unseen (a reconnect): every table is poked. */
41
+ lost(): void
42
+ down(why: string): void
43
+ up(): void
44
+ }
45
+
46
+ /** A change feed: what reports writes to the database, whoever made them. */
47
+ export interface Source {
48
+ readonly name: string
49
+ readonly tables: string[]
50
+ /** `listener: 'external'`: a separate process listens, and this app only reads the bus. */
51
+ readonly external?: boolean | undefined
52
+ /** Dev: the tags of one change. Default `[table, `${table}:${id}`]`. */
53
+ readonly tagged?: ((change: Change) => string[]) | undefined
54
+ /** Ms a change waits for its barrier, when a feed delivers in slower batches than the default 25. */
55
+ readonly hold?: number
56
+ /** The migration's input (`weft db live`). */
57
+ defs?(): Promise<TableDef[]>
58
+ readonly dialect?: string
59
+ probe(): Promise<Probe>
60
+ /** Starts from `probe`'s answer; resolves listening, with the way to stop. */
61
+ start(sink: Sink, probe: Probe): Promise<() => unknown>
62
+ /** Send a barrier through the feed. Omitted: only the hold window dedupes. */
63
+ settle?(tags: string[]): Promise<void>
64
+ }
65
+
66
+ /** A table as `Source.defs` reports it. */
67
+ export interface TableDef { name: string; pk: string[] }
68
+
69
+ /** `dbChanges`: how to listen, and how a change turns into tags. */
70
+ export interface ChangeOptions {
71
+ /** Direct `postgres://…` for the listener, when the app's URL goes through a pooler. */
72
+ listenUrl?: string
73
+ /** `'external'`: a separately deployed listener publishes to the bus (`listen` from `@weft-js/start/listen`). */
74
+ listener?: 'external'
75
+ /** Map a change to tags your queries use. Default `[table, `${table}:${id}`]`. */
76
+ tagged?: (change: Change) => string[]
77
+ /** Ms between looks where the database can't push: SQLite's `data_version` (100), MySQL's log (500), Postgres's stats (2000). */
78
+ interval?: number
79
+ }
80
+
81
+ const NAME = Symbol.for('drizzle:Name')
82
+ const nameOf = (t: unknown): string => (typeof t === 'string' ? t : (t as Record<symbol, string> | null)?.[NAME] ?? (() => { throw new StartError('R017', `\`dbChanges\` got something that isn't a table: ${String(t)}`, 'pass Drizzle tables, or their names') })())
83
+
84
+ const CORE = { sqlite: 'sqlite-core', postgresql: 'pg-core', mysql: 'mysql-core' }
85
+
86
+ /** A table's primary key columns, from its Drizzle definition. */
87
+ interface Key { name: string; primary?: boolean }
88
+
89
+ async function pkOf(table: unknown, dialect: DbHandle['dialect']): Promise<string[]> {
90
+ const { getTableConfig } = await import(/* @vite-ignore */ `drizzle-orm/${CORE[dialect]}`)
91
+ const { columns, primaryKeys } = getTableConfig(table)
92
+ const inline = columns.filter((c: Key) => c.primary).map((c: Key) => c.name)
93
+ return inline.length ? inline : (primaryKeys[0]?.columns ?? []).map((c: Key) => c.name)
94
+ }
95
+
96
+ /** `realtime({ changes: dbChanges(db, [todos, notes]) })`: these tables' writes reach `.live`, whoever made them. */
97
+ export function dbChanges(db: { $weft?: DbHandle }, tables: unknown[], options: ChangeOptions = {}): Source {
98
+ const handle = db?.$weft
99
+ if (!handle?.changes) throw new StartError('R017', '`dbChanges` needs a client from `@weft-js/start/db/sqlite`, `/postgres` or `/mysql`', 'for MongoDB use `mongoChanges` from `@weft-js/db/mongo`; for another client, write a `Source`')
100
+ const names = tables.map(nameOf)
101
+ let source: Promise<Omit<Source, 'name' | 'tables'>> | undefined
102
+ const load = () => (source ??= handle.changes!(names, options))
103
+ return {
104
+ name: handle.dialect,
105
+ dialect: handle.dialect,
106
+ tables: names,
107
+ external: options.listener === 'external',
108
+ tagged: options.tagged,
109
+ defs: () => Promise.all(tables.map(async t => ({ name: nameOf(t), pk: typeof t === 'string' ? [] : await pkOf(t, handle.dialect) }))),
110
+ probe: () => load().then(s => s.probe()),
111
+ start: (sink, probe) => load().then(s => s.start(sink, probe)),
112
+ settle: tags => load().then(s => s.settle?.(tags)),
113
+ }
114
+ }
115
+
116
+ /** Set by a platform, read at run time: this process lives for one request. */
117
+ export function functionHost(env: Record<string, string | undefined> = (globalThis as unknown as { process?: { env: Record<string, string | undefined> } }).process?.env ?? {}): string | undefined {
118
+ if (env.VERCEL) return 'Vercel'
119
+ if (env.NETLIFY || env.NETLIFY_LOCAL) return 'Netlify'
120
+ if (env.AWS_LAMBDA_FUNCTION_NAME) return 'AWS Lambda'
121
+ if (env.AZURE_FUNCTIONS_ENVIRONMENT) return 'Azure Functions'
122
+ if (env.FUNCTION_TARGET) return 'Cloud Functions'
123
+ if (globalThis.navigator?.userAgent === 'Cloudflare-Workers') return 'Cloudflare Workers'
124
+ return undefined
125
+ }
126
+
127
+ /** What a change sends to live queries, as tags to refetch. */
128
+ export interface Poke { t: string[]; /** Tag prefixes: `todos:` for every row of a table-level change. */ p?: string[]; /** Tags a barrier covered: a subscription with one of them is poked already. */ k?: string[] }
129
+
130
+ /** Where a feed's pokes go: this instance, and the rest through the bus. */
131
+ export interface FeedOut {
132
+ emit(poke: Poke): void
133
+ /** Bumps the shared cache, so a refetch can't be answered with what the change made stale. */
134
+ bump?(tags: string[]): void
135
+ status(down: string | undefined): void
136
+ }
137
+
138
+ /** A running feed, between its source and the hub. */
139
+ export interface Feed {
140
+ start(probe: Probe): Promise<void>
141
+ stop(): Promise<void>
142
+ /** Before a mutation's poke. Never throws: a dead feed must not fail a write that committed. */
143
+ settle(tags: string[]): Promise<void>
144
+ covers(tag: string): boolean
145
+ readonly running: boolean
146
+ }
147
+
148
+ const unref = <T,>(t: T) => ((t as { unref?(): void } | null | undefined)?.unref?.(), t)
149
+
150
+ /** Batches a source's changes into pokes, held and throttled so a burst of writes is one refetch. */
151
+ export function createFeed(source: Source, out: FeedOut, { hold = source.hold ?? 25, throttle = 250, barrier = 1000 } = {}): Feed {
152
+ const known = new Set(source.tables)
153
+ let pending = new Set<string>(), prefixes = new Set<string>(), covered = new Set<string>()
154
+ let timer: ReturnType<typeof setTimeout> | undefined
155
+ let last = 0
156
+ let first: number | undefined
157
+ let stopping: (() => unknown) | undefined
158
+ let running = false
159
+ let isDown = false
160
+
161
+ const flush = () => {
162
+ timer = first = undefined
163
+ last = Date.now()
164
+ const t = [...pending], p = [...prefixes], k = [...covered]
165
+ pending = new Set()
166
+ prefixes = new Set()
167
+ covered = new Set()
168
+ if (t.length || p.length) out.emit({ t, ...(p.length && { p }), ...(k.length && { k }) })
169
+ }
170
+ /** Each change gets its `hold`, for a barrier to reach it; a writer that never stops gets a flush per `throttle`. */
171
+ const arm = () => {
172
+ const now = Date.now()
173
+ first ??= now
174
+ clearTimeout(timer)
175
+ timer = unref(setTimeout(flush, Math.max(Math.min(hold, first + throttle - now), last + throttle - now)))
176
+ }
177
+
178
+ const tagsOf = (change: Change) => source.tagged?.(change) ?? (change.id === undefined ? [change.table] : [change.table, `${change.table}:${change.id}`])
179
+
180
+ const sink: Sink = {
181
+ changes(list) {
182
+ const seen: string[] = []
183
+ for (const c of list) {
184
+ if (!known.has(c.table)) continue
185
+ for (const t of tagsOf(c)) {
186
+ pending.add(t)
187
+ covered.delete(t) // a change after the barrier: no poke covers it
188
+ seen.push(t)
189
+ }
190
+ if (c.id === undefined) prefixes.add(`${c.table}:`)
191
+ }
192
+ if (!seen.length) return
193
+ out.bump?.(seen)
194
+ arm()
195
+ },
196
+ settled(tags) {
197
+ for (const t of tags) {
198
+ if (pending.delete(t)) covered.add(t)
199
+ else if (prefixes.delete(`${t}:`)) covered.add(t)
200
+ }
201
+ if (pending.size || prefixes.size) return
202
+ clearTimeout(timer)
203
+ timer = first = undefined
204
+ covered = new Set()
205
+ },
206
+ lost() {
207
+ sink.changes(source.tables.map(table => ({ table })))
208
+ },
209
+ down(why) {
210
+ if (isDown) return
211
+ isDown = true
212
+ out.status(why)
213
+ },
214
+ up() {
215
+ if (!isDown) return
216
+ isDown = false
217
+ out.status(undefined)
218
+ },
219
+ }
220
+
221
+ return {
222
+ get running() { return running },
223
+ covers(tag) {
224
+ const at = tag.indexOf(':')
225
+ return known.has(at < 0 ? tag : tag.slice(0, at))
226
+ },
227
+ async start(probe) {
228
+ if (running) return
229
+ running = true
230
+ try {
231
+ stopping = await source.start(sink, probe)
232
+ } catch (e) {
233
+ running = false
234
+ throw e
235
+ }
236
+ },
237
+ async stop() {
238
+ clearTimeout(timer)
239
+ timer = undefined
240
+ running = false
241
+ const stop = stopping
242
+ stopping = undefined
243
+ await stop?.()
244
+ },
245
+ async settle(tags) {
246
+ const ours = tags.filter(t => this.covers(t))
247
+ if (!ours.length || !source.settle) return
248
+ let late: ReturnType<typeof setTimeout> | undefined
249
+ await Promise.race([source.settle(ours), new Promise<void>(ok => { late = unref(setTimeout(ok, barrier)) })]).catch(() => {})
250
+ clearTimeout(late)
251
+ },
252
+ }
253
+ }
@@ -0,0 +1,55 @@
1
+ // @weft-js/core/test's socket (research 32 §7): the real socket.ts and hub, a memory bus, frames through JSON.
2
+
3
+ import { fakeScope, fns, net, runtime, track } from './data'
4
+ import type { Frame, SocketLink } from './channel'
5
+
6
+ /** A socket to the real server code in this process. */
7
+ const exec_ = async () => (runtime.exec ??= await import('./exec'))
8
+
9
+ export async function open(frame: (d: Frame) => void, done: (code: number) => void, up: () => void): Promise<SocketLink> {
10
+ const n = net()!
11
+ const [{ hub, resetHub }, { attach, publish }, exec] = await Promise.all([import('./hub'), import('./socket'), exec_()])
12
+ if (!n.online()) throw new Error('offline')
13
+ if (!n.resets.includes(resetHub)) n.resets.push(resetHub)
14
+ const h = hub()
15
+ h.dev = true
16
+ runtime.poke ??= (tags, origin) => hub().poke(tags, origin)
17
+ const request = new Request('http://localhost/_weft/ws')
18
+ const rooms = new Map<number, string>()
19
+ const joining = new Map<number, () => void>()
20
+ let closed = false
21
+ const peer = {
22
+ send(t: string) {
23
+ const d = JSON.parse(t)
24
+ frame(d)
25
+ if (d[0] === 'j' || d[0] === 'e') joining.get(d[1])?.()
26
+ },
27
+ close(code: number) {
28
+ if (closed) return
29
+ closed = true
30
+ n.ws = undefined
31
+ for (const go of joining.values()) go()
32
+ done(code)
33
+ },
34
+ backlog: () => 0,
35
+ }
36
+ const socket = await attach(peer, request, fakeScope(exec, n, request), h)
37
+ n.ws = {
38
+ publish: (id, params, name, msg) => publish(fns.get(id)!, params, name, msg),
39
+ drop: () => { peer.close(1006); socket.close() }, // both ends, as a real cut: the server runs `left`
40
+ recheck: () => socket.recheck(),
41
+ }
42
+ up()
43
+ return {
44
+ send(text) {
45
+ const f = JSON.parse(text)
46
+ if (f[0] === 'm') n.sent.push({ id: rooms.get(f[1]) ?? '', name: f[3], msg: f[4] })
47
+ if (f[0] === 'j') {
48
+ rooms.set(f[1], f[2])
49
+ void track(new Promise<void>(ok => { joining.set(f[1], ok) })) // `net.settle()` waits for the join's answer
50
+ }
51
+ socket.message(text)
52
+ },
53
+ close: () => socket.close(),
54
+ }
55
+ }