@human-synthesis/norns 0.0.15 → 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/src/server/db.js CHANGED
@@ -136,6 +136,67 @@ export async function postgres(url, opts = {}) {
136
136
  return drizzle(pool, opts.drizzle);
137
137
  }
138
138
 
139
+ /**
140
+ * Apply committed SQL migrations to a SQLite-backed Drizzle instance.
141
+ *
142
+ * Walks `dirs` (a root like `migrations/` whose subdirectories are module
143
+ * migration sets, or an explicit list of dirs), applies `*.sql` files in
144
+ * name order, and records each in `_norns_migrations` so re-runs are
145
+ * no-ops. Statements are split on drizzle-kit's `--> statement-breakpoint`
146
+ * marker. Local/dev helper — production D1 migrates via
147
+ * `wrangler d1 migrations apply`.
148
+ *
149
+ * @param {any} db Drizzle SQLite instance
150
+ * @param {string | string[]} dirs
151
+ * @returns {Promise<string[]>} ids of newly applied migration files
152
+ */
153
+ export async function applyMigrations(db, dirs) {
154
+ const [{ sql }, fs, path] = await Promise.all([
155
+ importDynamic('drizzle-orm'),
156
+ importDynamic('node:fs'),
157
+ importDynamic('node:path')
158
+ ]);
159
+ await db.run(sql.raw('CREATE TABLE IF NOT EXISTS "_norns_migrations" ("name" text PRIMARY KEY)'));
160
+ const rows = await db.all(sql.raw('SELECT "name" FROM "_norns_migrations"'));
161
+ const applied = new Set(rows.map((r) => r.name));
162
+
163
+ const byName = (a, b) => a.localeCompare(b);
164
+ const files = [];
165
+ for (const root of Array.isArray(dirs) ? dirs : [dirs]) {
166
+ if (!fs.existsSync(root)) continue;
167
+ const entries = fs.readdirSync(root, { withFileTypes: true });
168
+ for (const entry of entries.sort((a, b) => byName(a.name, b.name))) {
169
+ if (entry.isDirectory()) {
170
+ const sqls = fs
171
+ .readdirSync(path.join(root, entry.name))
172
+ .filter((f) => f.endsWith('.sql'))
173
+ .sort(byName);
174
+ for (const f of sqls) {
175
+ files.push({ id: `${entry.name}/${f}`, file: path.join(root, entry.name, f) });
176
+ }
177
+ } else if (entry.name.endsWith('.sql')) {
178
+ files.push({ id: entry.name, file: path.join(root, entry.name) });
179
+ }
180
+ }
181
+ }
182
+
183
+ const ran = [];
184
+ for (const { id, file } of files) {
185
+ if (applied.has(id)) continue;
186
+ const text = fs.readFileSync(file, 'utf-8');
187
+ const stmts = text
188
+ .split(/-->\s*statement-breakpoint/)
189
+ .map((s) => s.trim())
190
+ .filter(Boolean);
191
+ for (const stmt of stmts) await db.run(sql.raw(stmt));
192
+ await db.run(
193
+ sql.raw(`INSERT INTO "_norns_migrations" ("name") VALUES ('${id.replaceAll("'", "''")}')`)
194
+ );
195
+ ran.push(id);
196
+ }
197
+ return ran;
198
+ }
199
+
139
200
  /**
140
201
  * Run `fn` inside a Drizzle transaction. Uniform across drivers.
141
202
  *
@@ -0,0 +1,86 @@
1
+ import { publishRefresh } from './live.js';
2
+
3
+ /**
4
+ * Event bus behind `container.resolve('events')` — the target of generated
5
+ * `emit` steps and spec triggers.
6
+ *
7
+ * Local mode (default): `emit` dispatches in-process, awaiting every matching
8
+ * handler. Queue mode (`{ queue }` — a Cloudflare Queues producer binding):
9
+ * `emit` enqueues `{ name, payload }` and delivery happens in the consumer
10
+ * Worker, whose queue handler calls `events.consumer()` on an instance with
11
+ * the same handlers registered.
12
+ *
13
+ * `on(name)` matches exact names; `on('*')` sees everything.
14
+ *
15
+ * @param {{ queue?: { send(body: *): Promise<void> | void } }} [opts]
16
+ */
17
+ export function createEvents({ queue } = {}) {
18
+ /** @type {Map<string, Set<(payload: *, name: string) => *>>} */
19
+ const handlers = new Map();
20
+
21
+ async function dispatch(name, payload) {
22
+ const matched = [...(handlers.get(name) ?? []), ...(handlers.get('*') ?? [])];
23
+ for (const fn of matched) await fn(payload, name);
24
+ return matched.length;
25
+ }
26
+
27
+ return {
28
+ on(name, fn) {
29
+ if (!handlers.has(name)) handlers.set(name, new Set());
30
+ handlers.get(name).add(fn);
31
+ return () => handlers.get(name)?.delete(fn);
32
+ },
33
+
34
+ async emit(name, payload) {
35
+ if (queue) return queue.send({ name, payload });
36
+ return dispatch(name, payload);
37
+ },
38
+
39
+ /** Consumer-side delivery, bypassing the queue. */
40
+ dispatch,
41
+
42
+ /**
43
+ * Cloudflare Queues batch handler: `queue(batch) { return events.consumer()(batch) }`.
44
+ * Acks per message; a throwing handler retries that message only.
45
+ */
46
+ consumer() {
47
+ return async (batch) => {
48
+ for (const msg of batch.messages) {
49
+ try {
50
+ await dispatch(msg.body.name, msg.body.payload);
51
+ msg.ack?.();
52
+ } catch {
53
+ msg.retry?.();
54
+ }
55
+ }
56
+ };
57
+ }
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Wire generated trigger tables (`[{ on, action }]`) into the bus. The
63
+ * payload convention matches emitted `emit` steps: `{ row, input, user }`;
64
+ * actions fall back to `{ id: row.id }` input when the event carries none.
65
+ *
66
+ * @param {*} container
67
+ * @param {{ on: string, action: { run(ctx: *): * } }[]} triggers
68
+ * @returns {() => void} unsubscribe-all
69
+ */
70
+ export function registerTriggers(container, triggers) {
71
+ const events = container.resolve('events');
72
+ const offs = triggers.map(({ on, action }) =>
73
+ events.on(on, async (payload = {}) => {
74
+ const result = await action.run({
75
+ input: payload.input ?? (payload.row ? { id: payload.row.id } : {}),
76
+ container,
77
+ user: payload.user
78
+ });
79
+ await publishRefresh(container, action.refresh);
80
+ return result;
81
+ })
82
+ );
83
+ return () => {
84
+ for (const off of offs) off();
85
+ };
86
+ }
@@ -0,0 +1,48 @@
1
+ import { error } from '@sveltejs/kit';
2
+
3
+ /**
4
+ * Deny-by-default policy guard over the CEL-compiled policy objects the
5
+ * generator emits (`{ read: { check, where }, write: { check, where }, run? }`).
6
+ *
7
+ * - Missing policy, missing rule, or a rule without a `check` function → 403.
8
+ * - A `check` that throws counts as a denial, never as a bypass.
9
+ *
10
+ * @param {*} policy emitted policy object (e.g. `OrderPolicy`)
11
+ * @param {'read' | 'write'} kind
12
+ * @param {{ row?: *, user?: * }} [ctx]
13
+ * @returns {true}
14
+ */
15
+ export function guard(policy, kind, { row, user } = {}) {
16
+ const rule = policy?.[kind];
17
+ if (typeof rule?.check !== 'function') throw error(403, 'forbidden');
18
+ let ok = false;
19
+ try {
20
+ ok = rule.check(row, user) === true;
21
+ } catch {
22
+ ok = false;
23
+ }
24
+ if (!ok) throw error(403, 'forbidden');
25
+ return true;
26
+ }
27
+
28
+ /**
29
+ * Guard a named action against `policy.run[action]` predicates. Actions
30
+ * without a run rule pass (the write guard is the floor; run rules narrow).
31
+ *
32
+ * @param {*} policy
33
+ * @param {string} action
34
+ * @param {{ row?: *, user?: * }} [ctx]
35
+ * @returns {true}
36
+ */
37
+ export function guardRun(policy, action, { row, user } = {}) {
38
+ const rule = policy?.run?.[action];
39
+ if (rule === undefined) return true;
40
+ let ok = false;
41
+ try {
42
+ ok = (typeof rule === 'function' ? rule(row, user) : rule) === true;
43
+ } catch {
44
+ ok = false;
45
+ }
46
+ if (!ok) throw error(403, 'forbidden');
47
+ return true;
48
+ }
@@ -0,0 +1,54 @@
1
+ /** @typedef {import('@sveltejs/kit').Handle} Handle */
2
+
3
+ /**
4
+ * Normalize a better-auth user for policy predicates: `owner` compares
5
+ * `row[ownerField] === user.id`; `role:x` checks `user.roles.includes('x')`.
6
+ * better-auth's admin plugin stores roles as a comma-separated `role` string.
7
+ *
8
+ * @param {*} user
9
+ * @returns {{ id: *, roles: string[] } | null}
10
+ */
11
+ export function normalizeUser(user) {
12
+ if (!user) return null;
13
+ const roles = Array.isArray(user.roles)
14
+ ? user.roles
15
+ : typeof user.role === 'string'
16
+ ? user.role.split(',').map((r) => r.trim()).filter(Boolean)
17
+ : [];
18
+ return { ...user, roles };
19
+ }
20
+
21
+ /**
22
+ * Session middleware over a better-auth instance (`betterAuth({...})`):
23
+ *
24
+ * - requests under `basePath` (default `/api/auth`) go straight to
25
+ * better-auth's fetch handler (sign-in/out, callbacks, etc.)
26
+ * - for everything else the session is resolved once per request;
27
+ * `event.locals.user` / `event.locals.session` are set, and — when
28
+ * `contextHandle` already attached a request scope — `user` and `session`
29
+ * are bound into `event.locals.container` so downstream code can
30
+ * `container.resolve('user')`.
31
+ *
32
+ * Anonymous requests get `user: null`; policy guards deny by default.
33
+ *
34
+ * @param {{ handler(req: Request): Response | Promise<Response>, api: { getSession(opts: { headers: Headers }): * } }} auth
35
+ * @param {{ basePath?: string }} [opts]
36
+ * @returns {Handle}
37
+ */
38
+ export function authHandle(auth, { basePath = '/api/auth' } = {}) {
39
+ return async ({ event, resolve }) => {
40
+ if (event.url.pathname === basePath || event.url.pathname.startsWith(`${basePath}/`)) {
41
+ return auth.handler(event.request);
42
+ }
43
+ const session = await auth.api.getSession({ headers: event.request.headers });
44
+ const user = normalizeUser(session?.user ?? null);
45
+ event.locals.session = session?.session ?? null;
46
+ event.locals.user = user;
47
+ const scope = event.locals.container;
48
+ if (scope) {
49
+ scope.single('user', () => user);
50
+ scope.single('session', () => event.locals.session);
51
+ }
52
+ return resolve(event);
53
+ };
54
+ }
@@ -3,7 +3,18 @@ export { withScope, getScope, getContainer } from './scope.js';
3
3
  export { boot, createApp } from './boot.js';
4
4
  export { contextHandle } from './handle/context.js';
5
5
  export { errorHandle } from './handle/error.js';
6
- export { route } from './route.js';
6
+ export { authHandle, normalizeUser } from './handle/auth.js';
7
+ export { route, setSerializer, getSerializer } from './route.js';
7
8
  export { page } from './page.js';
8
9
  export { validate, ValidationError } from './validate.js';
9
- export { betterSqlite, d1, libsql, postgres, withTransaction } from './db.js';
10
+ export { guard, guardRun } from './guard.js';
11
+ export { machine } from './machine.js';
12
+ export { createEvents, registerTriggers } from './events.js';
13
+ export { cronMatches, cronTriggers, scheduledHandler, startCronShim } from './cron.js';
14
+ export { r2Storage, dirStorage } from './storage.js';
15
+ export { Room, roomStub } from './room.js';
16
+ export { REFRESH_EVENT, dependsKey, createLive, publishRefresh, liveHandler, remoteAction } from './live.js';
17
+ export { betterSqlite, d1, libsql, postgres, withTransaction, applyMigrations } from './db.js';
18
+ // Runtime-safe re-export for generated policies.c — the kernel entry itself
19
+ // pulls CLI-only node builtins and must stay out of worker bundles.
20
+ export { compileWhere } from '../kernel/expr-compile.js';
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Live-query bridge (R-11) — publishes "these queries changed" signals to
3
+ * connected clients so pages with `live: true` queries can re-run their
4
+ * loads.
5
+ *
6
+ * Dev/local (single process): refresh signals ride the in-process event bus
7
+ * and stream out of `/_norns/live` as SSE directly.
8
+ *
9
+ * Workers prod (many isolates): pass the `ROOM` Durable Object namespace as
10
+ * `room` — publishes are forwarded to the Room instance (R-08) and the SSE
11
+ * endpoint proxies to it, so every isolate's clients share one fanout point.
12
+ *
13
+ * The payload is only ever a list of query addresses — no row data crosses
14
+ * the live channel; clients re-run their loads through the normal
15
+ * policy-guarded path.
16
+ */
17
+
18
+ import { route } from './route.js';
19
+
20
+ export const REFRESH_EVENT = 'norns:refresh';
21
+
22
+ /** SvelteKit `depends`/`invalidate` key for a query address. */
23
+ export const dependsKey = (address) => `norns:${address}`;
24
+
25
+ const ENC = new TextEncoder();
26
+
27
+ /**
28
+ * @param {{
29
+ * events: { emit(name: string, payload: *): *, on(name: string, fn: (payload: *) => *): () => void },
30
+ * room?: { idFromName(name: string): *, get(id: *): { fetch(input: *, init?: *): Promise<Response> } },
31
+ * roomName?: string,
32
+ * heartbeatMs?: number
33
+ * }} opts
34
+ */
35
+ export function createLive({ events, room, roomName = 'live', heartbeatMs = 15000 }) {
36
+ const stub = () => room.get(room.idFromName(roomName));
37
+
38
+ return {
39
+ /** Announce that these query addresses have (potentially) new data. */
40
+ async publish(queries) {
41
+ if (!Array.isArray(queries) || queries.length === 0) return;
42
+ await events.emit(REFRESH_EVENT, { queries });
43
+ if (room) {
44
+ await stub().fetch('https://room/publish', {
45
+ method: 'POST',
46
+ headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify({ queries })
48
+ });
49
+ }
50
+ },
51
+
52
+ /** @param {(payload: { queries: string[] }) => *} fn */
53
+ subscribe(fn) {
54
+ return events.on(REFRESH_EVENT, fn);
55
+ },
56
+
57
+ /** SSE handler for the `/_norns/live` endpoint. */
58
+ handler(event) {
59
+ if (room) {
60
+ return stub().fetch('https://room/sse', { headers: event.request.headers });
61
+ }
62
+ let off = () => {};
63
+ let timer;
64
+ let open = true;
65
+ const stream = new ReadableStream({
66
+ start(controller) {
67
+ const send = (text) => {
68
+ if (!open) return;
69
+ try {
70
+ controller.enqueue(ENC.encode(text));
71
+ } catch {
72
+ open = false;
73
+ }
74
+ };
75
+ send(': connected\n\n');
76
+ off = events.on(REFRESH_EVENT, (payload) => send(`data: ${JSON.stringify(payload)}\n\n`));
77
+ timer = setInterval(() => send(': ping\n\n'), heartbeatMs);
78
+ },
79
+ cancel() {
80
+ open = false;
81
+ off();
82
+ clearInterval(timer);
83
+ }
84
+ });
85
+ return new Response(stream, {
86
+ headers: {
87
+ 'content-type': 'text/event-stream',
88
+ 'cache-control': 'no-cache',
89
+ connection: 'keep-alive'
90
+ }
91
+ });
92
+ }
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Publish an action's refresh list through the container's live bridge, if
98
+ * one is bound. Shared by form actions, remote actions, and triggers.
99
+ */
100
+ export async function publishRefresh(container, refresh) {
101
+ if (!Array.isArray(refresh) || refresh.length === 0) return;
102
+ if (!container?.has?.('live')) return;
103
+ await container.resolve('live').publish(refresh);
104
+ }
105
+
106
+ /**
107
+ * Generated `/_norns/live/+server.c` GET handler — streams refresh signals
108
+ * for the app booted into `event.locals.container`.
109
+ */
110
+ export function liveHandler(event) {
111
+ const container = event.locals?.container;
112
+ if (!container?.has?.('live')) {
113
+ return new Response('live bridge not enabled', { status: 404 });
114
+ }
115
+ return container.resolve('live').handler(event);
116
+ }
117
+
118
+ /**
119
+ * Wrap a generated action unit as a `transport: remote` POST endpoint:
120
+ * same schema validation and guarded `run` as the form-action path, plus
121
+ * refresh publication, returned as JSON (or the app serializer).
122
+ *
123
+ * @param {{ input?: *, run(ctx: *): *, refresh?: string[] }} action
124
+ */
125
+ export function remoteAction(action) {
126
+ return route({
127
+ input: action.input,
128
+ handler: async ({ input, container, event, user }) => {
129
+ const result = await action.run({ input, container, event, user });
130
+ await publishRefresh(container, action.refresh);
131
+ return result;
132
+ }
133
+ });
134
+ }
@@ -0,0 +1,35 @@
1
+ import { error } from '@sveltejs/kit';
2
+
3
+ /**
4
+ * Runtime status-machine enforcement over a spec transitions map
5
+ * (`{ draft: ['submitted'], submitted: ['paid', 'cancelled'], ... }`).
6
+ * Unknown states and undeclared transitions are always denied.
7
+ *
8
+ * @param {Record<string, string[]>} transitions
9
+ */
10
+ export function machine(transitions) {
11
+ const states = Object.keys(transitions).sort();
12
+
13
+ // Same rule as the generated schema default: the state no transition
14
+ // targets; falls back to the (sorted) first.
15
+ const targeted = new Set(Object.values(transitions).flat());
16
+ const sources = states.filter((s) => !targeted.has(s));
17
+ const initial = sources.length === 1 ? sources[0] : states[0];
18
+
19
+ return {
20
+ states,
21
+ initial,
22
+ can(from, to) {
23
+ return (transitions[from] ?? []).includes(to);
24
+ },
25
+ next(from) {
26
+ return [...(transitions[from] ?? [])];
27
+ },
28
+ assert(from, to) {
29
+ if (!this.can(from, to)) {
30
+ throw error(409, `invalid transition ${String(from)} -> ${String(to)}`);
31
+ }
32
+ return to;
33
+ }
34
+ };
35
+ }
@@ -1,5 +1,6 @@
1
1
  import { fail } from '@sveltejs/kit';
2
2
  import { validate, ValidationError } from './validate.js';
3
+ import { publishRefresh } from './live.js';
3
4
 
4
5
  /** @typedef {import('@sveltejs/kit').ServerLoadEvent} ServerLoadEvent */
5
6
  /** @typedef {import('@sveltejs/kit').RequestEvent} RequestEvent */
@@ -52,9 +53,10 @@ export const page = {
52
53
 
53
54
  /**
54
55
  * Wrap a SvelteKit `actions` object. Each action takes `{ input?, run }`
55
- * — `input` is a schema, `run` is the handler.
56
+ * — `input` is a schema, `run` is the handler. An action's `refresh` list
57
+ * is published through the container's live bridge after a successful run.
56
58
  *
57
- * @param {Record<string, { input?: any, run: (ctx: ActionContext) => any | Promise<any> }>} spec
59
+ * @param {Record<string, { input?: any, run: (ctx: ActionContext) => any | Promise<any>, refresh?: string[] }>} spec
58
60
  * @returns {Record<string, (event: RequestEvent) => Promise<any>>}
59
61
  */
60
62
  actions(spec) {
@@ -78,12 +80,14 @@ export const page = {
78
80
  throw e;
79
81
  }
80
82
  }
81
- return def.run({
83
+ const result = await def.run({
82
84
  input,
83
85
  container: event.locals.container,
84
86
  event,
85
87
  user: event.locals.user
86
88
  });
89
+ await publishRefresh(event.locals.container, def.refresh);
90
+ return result;
87
91
  };
88
92
  }
89
93
  return out;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Durable Object Room host (R-08) — one instance per room, holding live
3
+ * connections and in-memory state between requests (validated by the R-13
4
+ * spike on local workerd).
5
+ *
6
+ * Deploy shape: the app exports a DO class extending Room and binds it as
7
+ * `ROOM` (emit-wrangler adds the binding when specs need it):
8
+ *
9
+ * export class NornsRoom extends Room {}
10
+ *
11
+ * Paths served by `fetch`:
12
+ * /ws WebSocket upgrade; incoming messages go to `onMessage`
13
+ * /sse text/event-stream; every `broadcast` becomes a `data:` frame
14
+ * /publish POST (worker-internal) — body is broadcast to every client
15
+ * anything else → `{ clients, ticks }` snapshot
16
+ *
17
+ * Subclass hooks: `onMessage(data, ws)`, `onTick()`, `persist()`,
18
+ * `snapshot()` (first SSE frame for a new client), and the knobs `tickMs`
19
+ * (0 disables ticks) / `persistEvery` (persist() every N ticks; also runs
20
+ * once when the last client leaves). Ticks only run while clients are
21
+ * connected.
22
+ */
23
+
24
+ const ENC = new TextEncoder();
25
+
26
+ export class Room {
27
+ tickMs = 1000;
28
+ persistEvery = 0;
29
+
30
+ constructor(state, env) {
31
+ this.state = state;
32
+ this.env = env;
33
+ this.sockets = new Set();
34
+ this.streams = new Set();
35
+ this.ticks = 0;
36
+ this.timer = null;
37
+ }
38
+
39
+ /* -- subclass hooks ------------------------------------------------ */
40
+
41
+ async onMessage(_data, _ws) {}
42
+ async onTick() {}
43
+ async persist() {}
44
+ snapshot() {
45
+ return null;
46
+ }
47
+
48
+ /* -- connections --------------------------------------------------- */
49
+
50
+ get clients() {
51
+ return this.sockets.size + this.streams.size;
52
+ }
53
+
54
+ /** Send to every connected client (WS + SSE). Returns the client count. */
55
+ broadcast(message) {
56
+ const payload = typeof message === 'string' ? message : JSON.stringify(message);
57
+ for (const ws of this.sockets) {
58
+ try {
59
+ ws.send(payload);
60
+ } catch {
61
+ this.#leave(this.sockets, ws);
62
+ }
63
+ }
64
+ const frame = ENC.encode(`data: ${payload}\n\n`);
65
+ for (const writer of this.streams) {
66
+ writer.write(frame).catch(() => this.#leave(this.streams, writer));
67
+ }
68
+ return this.clients;
69
+ }
70
+
71
+ #join(set, item) {
72
+ set.add(item);
73
+ if (this.timer === null && this.tickMs > 0) {
74
+ this.timer = setInterval(() => this.#tick(), this.tickMs);
75
+ }
76
+ }
77
+
78
+ #leave(set, item) {
79
+ if (!set.delete(item)) return;
80
+ if (this.clients === 0) {
81
+ if (this.timer !== null) {
82
+ clearInterval(this.timer);
83
+ this.timer = null;
84
+ }
85
+ Promise.resolve(this.persist()).catch(() => {});
86
+ }
87
+ }
88
+
89
+ async #tick() {
90
+ this.ticks += 1;
91
+ await this.onTick();
92
+ if (this.persistEvery > 0 && this.ticks % this.persistEvery === 0) {
93
+ await this.persist();
94
+ }
95
+ }
96
+
97
+ /* -- request surface ------------------------------------------------ */
98
+
99
+ async fetch(request) {
100
+ const url = new URL(request.url);
101
+
102
+ if (url.pathname === '/ws') {
103
+ if (typeof WebSocketPair === 'undefined') {
104
+ return new Response('WebSocket unsupported on this runtime', { status: 501 });
105
+ }
106
+ const pair = new WebSocketPair();
107
+ const [client, server] = Object.values(pair);
108
+ server.accept();
109
+ this.#join(this.sockets, server);
110
+ server.addEventListener('message', (e) => {
111
+ Promise.resolve(this.onMessage(e.data, server)).catch(() => {});
112
+ });
113
+ server.addEventListener('close', () => this.#leave(this.sockets, server));
114
+ return new Response(null, { status: 101, webSocket: client });
115
+ }
116
+
117
+ if (url.pathname === '/sse') {
118
+ const { readable, writable } = new TransformStream();
119
+ const writer = writable.getWriter();
120
+ writer.write(ENC.encode(': connected\n\n')).catch(() => {});
121
+ const first = this.snapshot();
122
+ if (first !== null && first !== undefined) {
123
+ writer.write(ENC.encode(`data: ${JSON.stringify(first)}\n\n`)).catch(() => {});
124
+ }
125
+ this.#join(this.streams, writer);
126
+ request.signal?.addEventListener('abort', () => {
127
+ this.#leave(this.streams, writer);
128
+ writer.close().catch(() => {});
129
+ });
130
+ return new Response(readable, {
131
+ headers: {
132
+ 'content-type': 'text/event-stream',
133
+ 'cache-control': 'no-cache',
134
+ connection: 'keep-alive'
135
+ }
136
+ });
137
+ }
138
+
139
+ if (url.pathname === '/publish' && request.method === 'POST') {
140
+ let body = null;
141
+ try {
142
+ body = await request.json();
143
+ } catch {
144
+ return Response.json({ ok: false, error: 'body must be JSON' }, { status: 400 });
145
+ }
146
+ const clients = this.broadcast(body ?? {});
147
+ return Response.json({ ok: true, clients });
148
+ }
149
+
150
+ return Response.json({ clients: this.clients, ticks: this.ticks });
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Resolve the DO stub for a named room on a namespace binding (`env.ROOM`).
156
+ *
157
+ * @param {{ idFromName(name: string): *, get(id: *): * }} namespace
158
+ * @param {string} [name]
159
+ */
160
+ export function roomStub(namespace, name = 'default') {
161
+ return namespace.get(namespace.idFromName(name));
162
+ }