@human-synthesis/norns 0.1.0 → 0.2.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.
@@ -40,6 +40,150 @@ export function liveQueries(addresses, invalidate, opts = {}) {
40
40
  return () => source.close();
41
41
  }
42
42
 
43
+ /**
44
+ * Read a streaming Endpoint (K-23 typed SSE frames) as an async iterator
45
+ * (R-16). Backpressure-aware — the response body is only pulled when the
46
+ * consumer asks for the next frame — and abortable: breaking out of the
47
+ * loop (or firing `opts.signal`) aborts the underlying request.
48
+ *
49
+ * for await (const frame of streamSource('/api/chat', { input: { prompt } }))
50
+ * text += frame.delta
51
+ *
52
+ * @param {string} url the Endpoint's declared route
53
+ * @param {{ input?: *, method?: string, signal?: AbortSignal, fetch?: typeof fetch }} [opts]
54
+ * `input` is sent as a JSON POST body (streaming endpoints take their
55
+ * input up front); omit it for GET-style streams.
56
+ * @returns {AsyncGenerator<*>} parsed `data:` frames (JSON when possible)
57
+ */
58
+ export async function* streamSource(url, opts = {}) {
59
+ const f = opts.fetch ?? globalThis.fetch;
60
+ const controller = new AbortController();
61
+ const abort = () => controller.abort();
62
+ opts.signal?.addEventListener('abort', abort, { once: true });
63
+
64
+ const hasInput = opts.input !== undefined;
65
+ const res = await f(url, {
66
+ method: opts.method ?? (hasInput ? 'POST' : 'GET'),
67
+ headers: {
68
+ accept: 'text/event-stream',
69
+ ...(hasInput ? { 'content-type': 'application/json' } : {})
70
+ },
71
+ body: hasInput ? JSON.stringify(opts.input) : undefined,
72
+ signal: controller.signal
73
+ });
74
+ if (!res.ok || !res.body) {
75
+ throw new Error(`streamSource ${url}: ${res.status}${res.body ? '' : ' — no body'}`);
76
+ }
77
+
78
+ const reader = res.body.getReader();
79
+ const decoder = new TextDecoder();
80
+ let buffer = '';
81
+ try {
82
+ while (true) {
83
+ const { done, value } = await reader.read();
84
+ if (done) break;
85
+ buffer += decoder.decode(value, { stream: true });
86
+ let cut;
87
+ while ((cut = buffer.indexOf('\n\n')) !== -1) {
88
+ const event = buffer.slice(0, cut);
89
+ buffer = buffer.slice(cut + 2);
90
+ const data = event
91
+ .split('\n')
92
+ .filter((line) => line.startsWith('data:'))
93
+ .map((line) => line.slice(5).replace(/^ /, ''))
94
+ .join('\n');
95
+ if (data === '') continue;
96
+ try {
97
+ yield JSON.parse(data);
98
+ } catch {
99
+ yield data;
100
+ }
101
+ }
102
+ }
103
+ } finally {
104
+ opts.signal?.removeEventListener('abort', abort);
105
+ controller.abort();
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Connect to a Room over WebSocket with reconnect/backoff (R-16). Frames
111
+ * are the `{ type, ...payload }` envelope the Room contract declares in
112
+ * `messages`; `send(type, payload)` queues while disconnected and flushes
113
+ * on (re)connect. Reconnects use exponential backoff with jitter and stop
114
+ * after `close()`.
115
+ *
116
+ * @param {string} name room address (`module.Worker.name`), used to build
117
+ * the default path `/_norns/room/<name>/ws`
118
+ * @param {{ path?: string, WebSocket?: typeof WebSocket, backoffMs?: number,
119
+ * maxBackoffMs?: number }} [opts]
120
+ * @returns {{ send(type: string, payload?: object): void,
121
+ * on(type: string, fn: (frame: *) => void): () => void, close(): void }}
122
+ * `on('*', fn)` receives every frame; other types match `frame.type`.
123
+ */
124
+ export function roomChannel(name, opts = {}) {
125
+ const WS = opts.WebSocket ?? globalThis.WebSocket;
126
+ const path = opts.path ?? `/_norns/room/${encodeURIComponent(name)}/ws`;
127
+ const handlers = new Map();
128
+ const queue = [];
129
+ let socket = null;
130
+ let timer = null;
131
+ let attempts = 0;
132
+ let closed = typeof WS !== 'function'; // SSR-safe: stay inert without a WebSocket
133
+
134
+ const backoff = () => {
135
+ const capped = Math.min(opts.maxBackoffMs ?? 15_000, (opts.backoffMs ?? 500) * 2 ** attempts);
136
+ return capped / 2 + Math.random() * (capped / 2);
137
+ };
138
+
139
+ const connect = () => {
140
+ if (closed) return;
141
+ socket = new WS(path);
142
+ socket.onopen = () => {
143
+ attempts = 0;
144
+ while (queue.length > 0) socket.send(queue.shift());
145
+ };
146
+ socket.onmessage = (e) => {
147
+ let frame;
148
+ try {
149
+ frame = JSON.parse(e.data);
150
+ } catch {
151
+ return;
152
+ }
153
+ for (const fn of handlers.get(frame?.type) ?? []) fn(frame);
154
+ for (const fn of handlers.get('*') ?? []) fn(frame);
155
+ };
156
+ socket.onclose = () => {
157
+ socket = null;
158
+ if (closed) return;
159
+ timer = setTimeout(connect, backoff());
160
+ attempts += 1;
161
+ };
162
+ socket.onerror = () => socket?.close?.();
163
+ };
164
+ connect();
165
+
166
+ return {
167
+ send(type, payload) {
168
+ const message = JSON.stringify({ type, ...(payload ?? {}) });
169
+ if (socket?.readyState === 1) socket.send(message);
170
+ else queue.push(message);
171
+ },
172
+ on(type, fn) {
173
+ const set = handlers.get(type) ?? new Set();
174
+ set.add(fn);
175
+ handlers.set(type, set);
176
+ return () => set.delete(fn);
177
+ },
178
+ close() {
179
+ closed = true;
180
+ clearTimeout(timer);
181
+ socket?.close?.();
182
+ socket = null;
183
+ }
184
+ };
185
+ }
186
+
43
187
  /**
44
188
  * Call a `transport: remote` action endpoint
45
189
  * (`module.Action.name` → POST `/api/<module>/<name>`).
@@ -5,6 +5,7 @@ import { authHandle } from './handle/auth.js';
5
5
  import { errorHandle } from './handle/error.js';
6
6
  import { setSerializer } from './route.js';
7
7
  import { createEvents, registerTriggers } from './events.js';
8
+ import { createJobs, registerJobs } from './job.js';
8
9
  import { createLive } from './live.js';
9
10
  import { scheduledHandler, startCronShim } from './cron.js';
10
11
 
@@ -44,6 +45,13 @@ export function createApp() {
44
45
  * cron ones are served by the returned `scheduled` handler.
45
46
  * - `queue` — Cloudflare Queues producer binding; `emit` enqueues instead of
46
47
  * dispatching in-process.
48
+ * - `jobs` — generated job tables (`lib/<m>/jobs.c` `jobs` exports), nested
49
+ * arrays flattened. Wired to `job:<address>` bus messages with
50
+ * retry/backoff/DLQ semantics; a `jobs` facade singleton (enqueue) is
51
+ * bound automatically unless a feature bound one.
52
+ * - `services` — generated service tables (`lib/<m>/services.c` `services`
53
+ * exports), flattened; each client is container-registered under its unit
54
+ * address so custom bodies can `container.resolve('crm.Service.mailer')`.
47
55
  * - `cronShim: true` — local minute-timer for cron triggers (`norns dev`).
48
56
  * - `auth` — a better-auth-shaped instance (`.handler(request)` +
49
57
  * `.api.getSession({ headers })`); requests under `authBasePath`
@@ -62,6 +70,8 @@ export function createApp() {
62
70
  * handleError?: import('@sveltejs/kit').HandleServerError,
63
71
  * serializer?: import('./route.js').Serializer | null,
64
72
  * triggers?: *[],
73
+ * jobs?: *[],
74
+ * services?: *[],
65
75
  * queue?: { send(body: *): Promise<void> | void },
66
76
  * cronShim?: boolean,
67
77
  * room?: *,
@@ -112,6 +122,18 @@ export async function boot(opts = {}) {
112
122
  );
113
123
  }
114
124
 
125
+ if (!container.has('jobs')) {
126
+ container.single('jobs', () => createJobs(container));
127
+ }
128
+ const jobTables = (opts.jobs ?? []).flat(Infinity);
129
+ if (jobTables.length > 0) registerJobs(container, jobTables);
130
+
131
+ for (const table of (opts.services ?? []).flat(Infinity)) {
132
+ for (const [address, client] of Object.entries(table ?? {})) {
133
+ if (!container.has(address)) container.single(address, () => client);
134
+ }
135
+ }
136
+
115
137
  const triggers = (opts.triggers ?? []).flat(Infinity);
116
138
  if (triggers.length > 0) {
117
139
  registerTriggers(container, triggers.filter((t) => !t.schedule));
package/src/server/db.js CHANGED
@@ -100,7 +100,11 @@ export async function betterSqlite(path, opts = {}) {
100
100
  * @returns {Promise<any>}
101
101
  */
102
102
  export async function d1(binding, opts = {}) {
103
- const { drizzle } = await importDynamic('drizzle-orm/d1');
103
+ // Literal specifier on purpose: wrangler's esbuild must bundle the D1
104
+ // driver into the worker (a runtime importDynamic() can never resolve
105
+ // inside a workerd bundle). Safe to resolve statically — drizzle-orm is
106
+ // a hard dependency of every generated app, unlike the native drivers.
107
+ const { drizzle } = await import('drizzle-orm/d1');
104
108
  return drizzle(binding, opts.drizzle);
105
109
  }
106
110
 
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Endpoint shell runtime (D14/K-23).
3
+ *
4
+ * The generated `routes<route>/+server.c` calls `endpoint(def)` with the
5
+ * spec-derived contract plus the custom body: auth verifies *before*
6
+ * anything else runs, input validates before the body, output validates
7
+ * after it — or, with `def.stream`, the body's yielded frames are served
8
+ * as SSE, each checked against the declared frame shape.
9
+ *
10
+ * Credentials resolve at request time from the env binding named in spec
11
+ * (container `env` token or process.env) — never from spec values.
12
+ */
13
+
14
+ import { json, error } from '@sveltejs/kit';
15
+
16
+ import { envOf, hmacHex, shapeIssues } from './service.js';
17
+
18
+ /** Verify the inbound request against `def.auth` (401 on mismatch). */
19
+ async function verifyAuth(def, event, bodyText, container) {
20
+ const { mode, binding, header } = def.auth ?? { mode: 'none' };
21
+ if (mode === 'none') return;
22
+ const value = envOf(container)[binding];
23
+ if (!value) {
24
+ throw error(500, { message: `Endpoint ${def.name}: env binding "${binding}" is not set` });
25
+ }
26
+ const headers = event.request.headers;
27
+ const ok =
28
+ mode === 'bearer'
29
+ ? headers.get('authorization') === `Bearer ${value}`
30
+ : mode === 'basic'
31
+ ? headers.get('authorization') === `Basic ${btoa(value)}`
32
+ : mode === 'header'
33
+ ? headers.get(header) === value
34
+ : mode === 'hmac'
35
+ ? headers.get('x-signature') === (await hmacHex(value, bodyText))
36
+ : false;
37
+ if (!ok) throw error(401, { message: 'unauthorized' });
38
+ }
39
+
40
+ // Query-string values arrive as strings; nudge them toward the declared type.
41
+ const COERCE = {
42
+ int: (s) => (/^-?\d+$/.test(s) ? Number(s) : s),
43
+ number: (s) => (s !== '' && !Number.isNaN(Number(s)) ? Number(s) : s),
44
+ money: (s) => (s !== '' && !Number.isNaN(Number(s)) ? Number(s) : s),
45
+ bool: (s) => (s === 'true' ? true : s === 'false' ? false : s)
46
+ };
47
+
48
+ function coerceQuery(shape, raw) {
49
+ for (const [key, t] of Object.entries(shape ?? {})) {
50
+ const spec = (typeof t === 'string' ? t : t?.type ?? '').replace(/\?$/, '');
51
+ if (COERCE[spec] && typeof raw[key] === 'string') raw[key] = COERCE[spec](raw[key]);
52
+ }
53
+ return raw;
54
+ }
55
+
56
+ function sseResponse(def, frames) {
57
+ const enc = new TextEncoder();
58
+ const stream = new ReadableStream({
59
+ async start(controller) {
60
+ try {
61
+ for await (const frame of frames) {
62
+ const issues = def.stream?.frame ? shapeIssues(def.stream.frame, frame, 'frame') : [];
63
+ if (issues.length > 0) throw new Error(`Endpoint ${def.name}: ${issues.join('; ')}`);
64
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(frame)}\n\n`));
65
+ }
66
+ controller.enqueue(enc.encode('event: done\ndata: {}\n\n'));
67
+ } catch (e) {
68
+ const message = String(e?.message ?? e);
69
+ controller.enqueue(enc.encode(`event: error\ndata: ${JSON.stringify({ message })}\n\n`));
70
+ }
71
+ controller.close();
72
+ }
73
+ });
74
+ return new Response(stream, {
75
+ headers: {
76
+ 'content-type': 'text/event-stream',
77
+ 'cache-control': 'no-cache',
78
+ connection: 'keep-alive'
79
+ }
80
+ });
81
+ }
82
+
83
+ /**
84
+ * @param {{
85
+ * name: string,
86
+ * auth?: { mode: string, binding?: string, header?: string },
87
+ * input?: Record<string, *>,
88
+ * output?: Record<string, *>,
89
+ * stream?: { frame: Record<string, *> },
90
+ * body: (ctx: { input: *, container: *, event: *, user: * }) => *
91
+ * }} def
92
+ * @returns {(event: import('@sveltejs/kit').RequestEvent) => Promise<Response>}
93
+ */
94
+ export function endpoint(def) {
95
+ if (typeof def?.body !== 'function') throw new Error('endpoint(): `body` is required');
96
+
97
+ return async (event) => {
98
+ const container = event.locals.container;
99
+ const method = event.request.method;
100
+ const usesBody = method !== 'GET' && method !== 'DELETE' && method !== 'HEAD';
101
+ const bodyText = usesBody ? await event.request.text() : '';
102
+ await verifyAuth(def, event, bodyText, container);
103
+
104
+ let input;
105
+ if (usesBody) {
106
+ try {
107
+ input = bodyText === '' ? {} : JSON.parse(bodyText);
108
+ } catch {
109
+ throw error(400, { message: `Endpoint ${def.name}: body is not valid JSON` });
110
+ }
111
+ } else {
112
+ input = coerceQuery(def.input, Object.fromEntries(event.url.searchParams));
113
+ }
114
+ if (def.input) {
115
+ const issues = shapeIssues(def.input, input, 'input');
116
+ if (issues.length > 0) {
117
+ throw error(400, { message: `Endpoint ${def.name}: ${issues.join('; ')}`, issues });
118
+ }
119
+ }
120
+
121
+ const ctx = { input, container, event, user: event.locals.user };
122
+
123
+ if (def.stream) {
124
+ let frames = def.body(ctx);
125
+ if (!frames?.[Symbol.asyncIterator]) frames = await frames;
126
+ if (!frames?.[Symbol.asyncIterator]) {
127
+ throw error(500, { message: `Endpoint ${def.name}: stream body must return an async iterable of frames` });
128
+ }
129
+ return sseResponse(def, frames);
130
+ }
131
+
132
+ const result = await def.body(ctx);
133
+ if (result instanceof Response) return result;
134
+ if (def.output) {
135
+ const issues = shapeIssues(def.output, result, 'output');
136
+ if (issues.length > 0) {
137
+ throw error(500, { message: `Endpoint ${def.name}: ${issues.join('; ')}`, issues });
138
+ }
139
+ }
140
+ return json(result ?? null);
141
+ };
142
+ }
@@ -15,6 +15,9 @@ export { r2Storage, dirStorage } from './storage.js';
15
15
  export { Room, roomStub } from './room.js';
16
16
  export { REFRESH_EVENT, dependsKey, createLive, publishRefresh, liveHandler, remoteAction } from './live.js';
17
17
  export { betterSqlite, d1, libsql, postgres, withTransaction, applyMigrations } from './db.js';
18
+ export { serviceClient, ServiceError } from './service.js';
19
+ export { job, runJob, registerJobs, createJobs, backoffMs } from './job.js';
20
+ export { endpoint } from './endpoint.js';
18
21
  // Runtime-safe re-export for generated policies.c — the kernel entry itself
19
22
  // pulls CLI-only node builtins and must stay out of worker bundles.
20
23
  export { compileWhere } from '../kernel/expr-compile.js';
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Job runtime (K-22/R-14). Generated `lib/<m>/jobs.c` wraps each unit in
3
+ * `job({...})`; `registerJobs` subscribes them to `job:<address>` bus
4
+ * messages, and the `jobs` container facade enqueues through the same bus —
5
+ * production rides Cloudflare Queues (boot `queue` opt) while dev runs
6
+ * inline, awaited, with the same retry/backoff/DLQ semantics.
7
+ */
8
+
9
+ /**
10
+ * @typedef {{
11
+ * address: string,
12
+ * retry?: { attempts: number, backoff: 'none'|'fixed'|'exponential', baseMs?: number },
13
+ * dlq?: string,
14
+ * concurrency?: number,
15
+ * run: (ctx: { input: *, container: *, user?: * }) => Promise<*>
16
+ * }} JobDef
17
+ */
18
+
19
+ /** Identity wrapper — the shape is the contract. @param {JobDef} def */
20
+ export function job(def) {
21
+ return def;
22
+ }
23
+
24
+ /** Delay before retry `attempt` (1-based) under a retry policy. */
25
+ export function backoffMs(retry, attempt) {
26
+ const base = retry?.baseMs ?? 1000;
27
+ switch (retry?.backoff) {
28
+ case 'fixed':
29
+ return base;
30
+ case 'exponential':
31
+ return base * 2 ** (attempt - 1);
32
+ default:
33
+ return 0;
34
+ }
35
+ }
36
+
37
+ const sleep = (ms) => (ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve());
38
+
39
+ /**
40
+ * Run a job under its declared retry policy. On exhaustion: with a `dlq`,
41
+ * a `dlq:<name>` event carries the failure and the message counts as
42
+ * handled; without one the error rethrows (in queue mode Cloudflare then
43
+ * applies its own message retry).
44
+ *
45
+ * @param {JobDef} jobDef
46
+ * @param {{ input: *, container: *, user?: * }} ctx
47
+ */
48
+ export async function runJob(jobDef, ctx) {
49
+ const attempts = jobDef.retry?.attempts ?? 1;
50
+ for (let attempt = 1; ; attempt++) {
51
+ try {
52
+ return await jobDef.run(ctx);
53
+ } catch (err) {
54
+ if (attempt < attempts) {
55
+ await sleep(backoffMs(jobDef.retry, attempt));
56
+ continue;
57
+ }
58
+ if (jobDef.dlq) {
59
+ await ctx.container.resolve('events').emit(`dlq:${jobDef.dlq}`, {
60
+ job: jobDef.address,
61
+ input: ctx.input,
62
+ error: String(err?.message ?? err),
63
+ attempts: attempt
64
+ });
65
+ return undefined;
66
+ }
67
+ throw err;
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Wire job tables (generated `jobs` maps, address → JobDef) into the bus.
74
+ *
75
+ * @param {*} container
76
+ * @param {Record<string, JobDef> | Record<string, JobDef>[]} tables
77
+ * @returns {() => void} unsubscribe-all
78
+ */
79
+ export function registerJobs(container, tables) {
80
+ const events = container.resolve('events');
81
+ const offs = [];
82
+ for (const table of Array.isArray(tables) ? tables : [tables]) {
83
+ for (const [address, jobDef] of Object.entries(table ?? {})) {
84
+ offs.push(
85
+ events.on(`job:${address}`, (payload = {}) =>
86
+ runJob(jobDef, { input: payload.input ?? {}, container, user: payload.user })
87
+ )
88
+ );
89
+ }
90
+ }
91
+ return () => {
92
+ for (const off of offs) off();
93
+ };
94
+ }
95
+
96
+ /** The `jobs` container facade behind generated `enqueue` steps. */
97
+ export function createJobs(container) {
98
+ return {
99
+ enqueue: (address, input, user) =>
100
+ container.resolve('events').emit(`job:${address}`, { input, user })
101
+ };
102
+ }
@@ -19,6 +19,10 @@
19
19
  * (0 disables ticks) / `persistEvery` (persist() every N ticks; also runs
20
20
  * once when the last client leaves). Ticks only run while clients are
21
21
  * connected.
22
+ *
23
+ * Presence (R-16): set `presenceMs > 0` and every join/leave broadcasts
24
+ * `{ type: 'presence', clients }` — debounced by that window so a
25
+ * reconnect storm collapses into one frame.
22
26
  */
23
27
 
24
28
  const ENC = new TextEncoder();
@@ -26,6 +30,8 @@ const ENC = new TextEncoder();
26
30
  export class Room {
27
31
  tickMs = 1000;
28
32
  persistEvery = 0;
33
+ presenceMs = 0;
34
+ #presenceTimer = null;
29
35
 
30
36
  constructor(state, env) {
31
37
  this.state = state;
@@ -68,8 +74,18 @@ export class Room {
68
74
  return this.clients;
69
75
  }
70
76
 
77
+ /** Debounced `{ type: 'presence', clients }` broadcast (opt-in via presenceMs). */
78
+ #presence() {
79
+ if (this.presenceMs <= 0 || this.#presenceTimer !== null) return;
80
+ this.#presenceTimer = setTimeout(() => {
81
+ this.#presenceTimer = null;
82
+ this.broadcast({ type: 'presence', clients: this.clients });
83
+ }, this.presenceMs);
84
+ }
85
+
71
86
  #join(set, item) {
72
87
  set.add(item);
88
+ this.#presence();
73
89
  if (this.timer === null && this.tickMs > 0) {
74
90
  this.timer = setInterval(() => this.#tick(), this.tickMs);
75
91
  }
@@ -77,6 +93,7 @@ export class Room {
77
93
 
78
94
  #leave(set, item) {
79
95
  if (!set.delete(item)) return;
96
+ this.#presence();
80
97
  if (this.clients === 0) {
81
98
  if (this.timer !== null) {
82
99
  clearInterval(this.timer);