@human-synthesis/norns 0.0.16 → 0.2.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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Browser side of the live-query bridge (R-11). Generated pages with
3
+ * `live: true` queries call `liveQueries` from an `$effect`; the server
4
+ * counterpart streams refresh signals from `/_norns/live` (see
5
+ * `src/server/live.js`).
6
+ *
7
+ * No SvelteKit import here — the caller passes `invalidate` from
8
+ * `$app/navigation` so this module stays framework-neutral and testable.
9
+ */
10
+
11
+ /** SvelteKit `depends`/`invalidate` key for a query address. */
12
+ export const dependsKey = (address) => `norns:${address}`;
13
+
14
+ /**
15
+ * Subscribe to refresh signals and invalidate the given query addresses
16
+ * when they change. Returns a cleanup function (safe for `$effect`).
17
+ *
18
+ * @param {string[]} addresses query addresses this page depends on
19
+ * @param {(key: string) => *} invalidate `invalidate` from `$app/navigation`
20
+ * @param {{ path?: string, EventSource?: typeof EventSource }} [opts]
21
+ * @returns {() => void}
22
+ */
23
+ export function liveQueries(addresses, invalidate, opts = {}) {
24
+ const ES = opts.EventSource ?? globalThis.EventSource;
25
+ if (typeof ES !== 'function') return () => {};
26
+
27
+ const wanted = new Set(addresses);
28
+ const source = new ES(opts.path ?? '/_norns/live');
29
+ source.onmessage = (e) => {
30
+ let payload;
31
+ try {
32
+ payload = JSON.parse(e.data);
33
+ } catch {
34
+ return;
35
+ }
36
+ for (const address of Array.isArray(payload?.queries) ? payload.queries : []) {
37
+ if (wanted.has(address)) invalidate(dependsKey(address));
38
+ }
39
+ };
40
+ return () => source.close();
41
+ }
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
+
187
+ /**
188
+ * Call a `transport: remote` action endpoint
189
+ * (`module.Action.name` → POST `/api/<module>/<name>`).
190
+ *
191
+ * @param {string} address action address
192
+ * @param {*} [input]
193
+ * @param {{ fetch?: typeof fetch }} [opts]
194
+ */
195
+ export async function remoteCall(address, input, opts = {}) {
196
+ const parts = String(address).split('.');
197
+ if (parts.length !== 3 || parts[1] !== 'Action') {
198
+ throw new Error(`remoteCall: "${address}" is not an Action address`);
199
+ }
200
+ const f = opts.fetch ?? globalThis.fetch;
201
+ const res = await f(`/api/${parts[0]}/${parts[2]}`, {
202
+ method: 'POST',
203
+ headers: { 'content-type': 'application/json' },
204
+ body: JSON.stringify(input ?? {})
205
+ });
206
+ if (!res.ok) {
207
+ let detail = '';
208
+ try {
209
+ detail = (await res.json())?.message ?? '';
210
+ } catch {
211
+ /* body may not be JSON */
212
+ }
213
+ throw new Error(`remoteCall ${address}: ${res.status}${detail ? ` — ${detail}` : ''}`);
214
+ }
215
+ return res.json();
216
+ }
@@ -1,8 +1,13 @@
1
1
  import { sequence } from '@sveltejs/kit/hooks';
2
2
  import { Container } from './container.js';
3
3
  import { contextHandle } from './handle/context.js';
4
+ import { authHandle } from './handle/auth.js';
4
5
  import { errorHandle } from './handle/error.js';
5
6
  import { setSerializer } from './route.js';
7
+ import { createEvents, registerTriggers } from './events.js';
8
+ import { createJobs, registerJobs } from './job.js';
9
+ import { createLive } from './live.js';
10
+ import { scheduledHandler, startCronShim } from './cron.js';
6
11
 
7
12
  /**
8
13
  * Create a fresh root container with no features registered. Useful for tests
@@ -34,16 +39,51 @@ export function createApp() {
34
39
  * Each `module.c` must default-export a function `(app) -> ...` that calls
35
40
  * `app.bind(...)` / `app.single(...)` / `app.migrations(...)`.
36
41
  *
42
+ * Spec-first extras:
43
+ * - `triggers` — generated trigger tables (`lib/<m>/triggers.c` exports),
44
+ * nested arrays are flattened. Event triggers are wired into the bus;
45
+ * cron ones are served by the returned `scheduled` handler.
46
+ * - `queue` — Cloudflare Queues producer binding; `emit` enqueues instead of
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')`.
55
+ * - `cronShim: true` — local minute-timer for cron triggers (`norns dev`).
56
+ * - `auth` — a better-auth-shaped instance (`.handler(request)` +
57
+ * `.api.getSession({ headers })`); requests under `authBasePath`
58
+ * (default `/api/auth`) are handed to it, every other request gets
59
+ * `event.locals.user` / `event.locals.session` and scope bindings.
60
+ * - `room` — the `ROOM` Durable Object namespace binding (Workers prod);
61
+ * live-query publishes and the `/_norns/live` stream go through it. Omit
62
+ * in dev: signals ride the in-process bus.
63
+ * - an `events` singleton is bound automatically unless a feature bound one,
64
+ * and a `live` bridge singleton likewise (actions with `refresh` lists
65
+ * publish through it).
66
+ *
37
67
  * @param {{
38
68
  * features?: Record<string, FeatureModule>,
39
69
  * extraHandle?: import('@sveltejs/kit').Handle | import('@sveltejs/kit').Handle[],
40
70
  * handleError?: import('@sveltejs/kit').HandleServerError,
41
- * serializer?: import('./route.js').Serializer | null
71
+ * serializer?: import('./route.js').Serializer | null,
72
+ * triggers?: *[],
73
+ * jobs?: *[],
74
+ * services?: *[],
75
+ * queue?: { send(body: *): Promise<void> | void },
76
+ * cronShim?: boolean,
77
+ * room?: *,
78
+ * auth?: { handler(request: Request): Promise<Response> | Response, api: { getSession(input: *): Promise<*> } },
79
+ * authBasePath?: string
42
80
  * }} [opts]
43
81
  * @returns {Promise<{
44
82
  * container: Container,
45
83
  * handle: import('@sveltejs/kit').Handle,
46
- * handleError: import('@sveltejs/kit').HandleServerError
84
+ * handleError: import('@sveltejs/kit').HandleServerError,
85
+ * scheduled: (event: *) => Promise<void>,
86
+ * stopCronShim: () => void
47
87
  * }>}
48
88
  */
49
89
  export async function boot(opts = {}) {
@@ -73,8 +113,47 @@ export async function boot(opts = {}) {
73
113
  : [opts.extraHandle]
74
114
  : [];
75
115
 
76
- const handle = sequence(contextHandle(container), ...extras);
116
+ if (!container.has('events')) {
117
+ container.single('events', () => createEvents(opts.queue ? { queue: opts.queue } : {}));
118
+ }
119
+ if (!container.has('live')) {
120
+ container.single('live', () =>
121
+ createLive({ events: container.resolve('events'), room: opts.room })
122
+ );
123
+ }
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
+
137
+ const triggers = (opts.triggers ?? []).flat(Infinity);
138
+ if (triggers.length > 0) {
139
+ registerTriggers(container, triggers.filter((t) => !t.schedule));
140
+ }
141
+ const stopCronShim = opts.cronShim ? startCronShim(container, triggers) : () => {};
142
+
143
+ // contextHandle must come first so authHandle can bind user/session into
144
+ // the per-request scope it creates.
145
+ const handle = sequence(
146
+ contextHandle(container),
147
+ ...(opts.auth ? [authHandle(opts.auth, { basePath: opts.authBasePath })] : []),
148
+ ...extras
149
+ );
77
150
  const handleError = opts.handleError ?? errorHandle();
78
151
 
79
- return { container, handle, handleError };
152
+ return {
153
+ container,
154
+ handle,
155
+ handleError,
156
+ scheduled: scheduledHandler(container, triggers),
157
+ stopCronShim
158
+ };
80
159
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Cron triggers: Workers `scheduled` adapter + local timer shim for
3
+ * `norns dev`. Triggers are the generated tables (`[{ on, action, schedule? }]`).
4
+ */
5
+
6
+ /**
7
+ * Match a 5-field cron expression (min hour dom mon dow) against a Date.
8
+ * Supports `*`, `n`, `a-b`, `*​/n`, `a-b/n`, and comma lists; dow 0/7 = Sunday.
9
+ *
10
+ * @param {string} expr
11
+ * @param {Date} date
12
+ */
13
+ export function cronMatches(expr, date) {
14
+ const fields = expr.trim().split(/\s+/);
15
+ if (fields.length !== 5) return false;
16
+ const values = [
17
+ date.getUTCMinutes(),
18
+ date.getUTCHours(),
19
+ date.getUTCDate(),
20
+ date.getUTCMonth() + 1,
21
+ date.getUTCDay()
22
+ ];
23
+ return fields.every((field, i) => fieldMatches(field, values[i], i === 4));
24
+ }
25
+
26
+ function fieldMatches(field, value, isDow) {
27
+ return field.split(',').some((part) => {
28
+ const [range, stepStr] = part.split('/');
29
+ const step = stepStr === undefined ? 1 : Number(stepStr);
30
+ if (!Number.isInteger(step) || step < 1) return false;
31
+ let lo;
32
+ let hi;
33
+ if (range === '*') {
34
+ lo = 0;
35
+ hi = Infinity;
36
+ } else if (range.includes('-')) {
37
+ [lo, hi] = range.split('-').map(Number);
38
+ } else {
39
+ lo = hi = Number(range);
40
+ if (isDow && lo === 7) lo = hi = 0;
41
+ }
42
+ if (!Number.isInteger(lo) || (hi !== Infinity && !Number.isInteger(hi))) return false;
43
+ return value >= lo && value <= hi && (value - (lo === 0 || range === '*' ? 0 : lo)) % step === 0;
44
+ });
45
+ }
46
+
47
+ /** @param {{ schedule?: string }[]} triggers */
48
+ export function cronTriggers(triggers) {
49
+ return triggers.filter((t) => typeof t.schedule === 'string');
50
+ }
51
+
52
+ async function runTrigger(container, trigger) {
53
+ await trigger.action.run({ input: {}, container });
54
+ }
55
+
56
+ /**
57
+ * Cloudflare Workers `scheduled` handler. Runs every cron trigger whose
58
+ * schedule equals `event.cron` (how Workers routes multi-cron Workers), or —
59
+ * when `event.cron` is absent — whose schedule matches the event time.
60
+ *
61
+ * @param {*} container
62
+ * @param {{ schedule?: string, action: { run(ctx: *): * } }[]} triggers
63
+ */
64
+ export function scheduledHandler(container, triggers) {
65
+ const crons = cronTriggers(triggers);
66
+ return async (event) => {
67
+ const due = event?.cron
68
+ ? crons.filter((t) => t.schedule === event.cron)
69
+ : crons.filter((t) => cronMatches(t.schedule, new Date(event?.scheduledTime ?? Date.now())));
70
+ for (const t of due) await runTrigger(container, t);
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Local dev shim: checks once per minute (aligned to the minute) and runs
76
+ * matching cron triggers. Returns a stop function.
77
+ *
78
+ * @param {*} container
79
+ * @param {{ schedule?: string, action: { run(ctx: *): * } }[]} triggers
80
+ * @param {{ onError?: (err: *, trigger: *) => void }} [opts]
81
+ */
82
+ export function startCronShim(container, triggers, { onError } = {}) {
83
+ const crons = cronTriggers(triggers);
84
+ if (crons.length === 0) return () => {};
85
+ let timer;
86
+ const tick = async () => {
87
+ const now = new Date();
88
+ for (const t of crons.filter((t) => cronMatches(t.schedule, now))) {
89
+ try {
90
+ await runTrigger(container, t);
91
+ } catch (err) {
92
+ onError?.(err, t);
93
+ }
94
+ }
95
+ };
96
+ const arm = () => {
97
+ const msToMinute = 60_000 - (Date.now() % 60_000);
98
+ timer = setTimeout(async () => {
99
+ await tick();
100
+ arm();
101
+ }, msToMinute);
102
+ };
103
+ arm();
104
+ return () => clearTimeout(timer);
105
+ }
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
 
@@ -136,6 +140,67 @@ export async function postgres(url, opts = {}) {
136
140
  return drizzle(pool, opts.drizzle);
137
141
  }
138
142
 
143
+ /**
144
+ * Apply committed SQL migrations to a SQLite-backed Drizzle instance.
145
+ *
146
+ * Walks `dirs` (a root like `migrations/` whose subdirectories are module
147
+ * migration sets, or an explicit list of dirs), applies `*.sql` files in
148
+ * name order, and records each in `_norns_migrations` so re-runs are
149
+ * no-ops. Statements are split on drizzle-kit's `--> statement-breakpoint`
150
+ * marker. Local/dev helper — production D1 migrates via
151
+ * `wrangler d1 migrations apply`.
152
+ *
153
+ * @param {any} db Drizzle SQLite instance
154
+ * @param {string | string[]} dirs
155
+ * @returns {Promise<string[]>} ids of newly applied migration files
156
+ */
157
+ export async function applyMigrations(db, dirs) {
158
+ const [{ sql }, fs, path] = await Promise.all([
159
+ importDynamic('drizzle-orm'),
160
+ importDynamic('node:fs'),
161
+ importDynamic('node:path')
162
+ ]);
163
+ await db.run(sql.raw('CREATE TABLE IF NOT EXISTS "_norns_migrations" ("name" text PRIMARY KEY)'));
164
+ const rows = await db.all(sql.raw('SELECT "name" FROM "_norns_migrations"'));
165
+ const applied = new Set(rows.map((r) => r.name));
166
+
167
+ const byName = (a, b) => a.localeCompare(b);
168
+ const files = [];
169
+ for (const root of Array.isArray(dirs) ? dirs : [dirs]) {
170
+ if (!fs.existsSync(root)) continue;
171
+ const entries = fs.readdirSync(root, { withFileTypes: true });
172
+ for (const entry of entries.sort((a, b) => byName(a.name, b.name))) {
173
+ if (entry.isDirectory()) {
174
+ const sqls = fs
175
+ .readdirSync(path.join(root, entry.name))
176
+ .filter((f) => f.endsWith('.sql'))
177
+ .sort(byName);
178
+ for (const f of sqls) {
179
+ files.push({ id: `${entry.name}/${f}`, file: path.join(root, entry.name, f) });
180
+ }
181
+ } else if (entry.name.endsWith('.sql')) {
182
+ files.push({ id: entry.name, file: path.join(root, entry.name) });
183
+ }
184
+ }
185
+ }
186
+
187
+ const ran = [];
188
+ for (const { id, file } of files) {
189
+ if (applied.has(id)) continue;
190
+ const text = fs.readFileSync(file, 'utf-8');
191
+ const stmts = text
192
+ .split(/-->\s*statement-breakpoint/)
193
+ .map((s) => s.trim())
194
+ .filter(Boolean);
195
+ for (const stmt of stmts) await db.run(sql.raw(stmt));
196
+ await db.run(
197
+ sql.raw(`INSERT INTO "_norns_migrations" ("name") VALUES ('${id.replaceAll("'", "''")}')`)
198
+ );
199
+ ran.push(id);
200
+ }
201
+ return ran;
202
+ }
203
+
139
204
  /**
140
205
  * Run `fn` inside a Drizzle transaction. Uniform across drivers.
141
206
  *
@@ -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
+ }