@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.
- package/bin/norns.js +146 -6
- package/package.json +9 -3
- package/src/auto-import.js +7 -3
- package/src/config.js +17 -3
- package/src/kernel/absorb.js +279 -0
- package/src/kernel/address.js +228 -0
- package/src/kernel/adopt.js +157 -0
- package/src/kernel/emit-machines.js +87 -0
- package/src/kernel/emit-schema.js +199 -0
- package/src/kernel/emit-units.js +855 -0
- package/src/kernel/emit-wrangler.js +134 -0
- package/src/kernel/expr-compile.js +191 -0
- package/src/kernel/expr.js +290 -0
- package/src/kernel/flow.js +444 -0
- package/src/kernel/generate.js +840 -0
- package/src/kernel/graph.js +222 -0
- package/src/kernel/index.js +78 -0
- package/src/kernel/meta.js +381 -0
- package/src/kernel/migrate.js +134 -0
- package/src/kernel/refine.js +277 -0
- package/src/kernel/trace.js +465 -0
- package/src/kernel/validate.js +92 -0
- package/src/live-client.js +216 -0
- package/src/server/boot.js +83 -4
- package/src/server/cron.js +105 -0
- package/src/server/db.js +66 -1
- package/src/server/endpoint.js +142 -0
- package/src/server/events.js +86 -0
- package/src/server/guard.js +48 -0
- package/src/server/handle/auth.js +54 -0
- package/src/server/index.js +15 -1
- package/src/server/job.js +102 -0
- package/src/server/live.js +134 -0
- package/src/server/machine.js +35 -0
- package/src/server/page.js +7 -3
- package/src/server/room.js +179 -0
- package/src/server/service.js +188 -0
- package/src/server/storage.js +97 -0
|
@@ -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
|
+
}
|
package/src/server/boot.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|