@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,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
|
+
}
|
package/src/server/index.js
CHANGED
|
@@ -3,7 +3,21 @@ 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 { authHandle, normalizeUser } from './handle/auth.js';
|
|
6
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 {
|
|
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
|
+
export { serviceClient, ServiceError } from './service.js';
|
|
19
|
+
export { job, runJob, registerJobs, createJobs, backoffMs } from './job.js';
|
|
20
|
+
export { endpoint } from './endpoint.js';
|
|
21
|
+
// Runtime-safe re-export for generated policies.c — the kernel entry itself
|
|
22
|
+
// pulls CLI-only node builtins and must stay out of worker bundles.
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/server/page.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,179 @@
|
|
|
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
|
+
* 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.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const ENC = new TextEncoder();
|
|
29
|
+
|
|
30
|
+
export class Room {
|
|
31
|
+
tickMs = 1000;
|
|
32
|
+
persistEvery = 0;
|
|
33
|
+
presenceMs = 0;
|
|
34
|
+
#presenceTimer = null;
|
|
35
|
+
|
|
36
|
+
constructor(state, env) {
|
|
37
|
+
this.state = state;
|
|
38
|
+
this.env = env;
|
|
39
|
+
this.sockets = new Set();
|
|
40
|
+
this.streams = new Set();
|
|
41
|
+
this.ticks = 0;
|
|
42
|
+
this.timer = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* -- subclass hooks ------------------------------------------------ */
|
|
46
|
+
|
|
47
|
+
async onMessage(_data, _ws) {}
|
|
48
|
+
async onTick() {}
|
|
49
|
+
async persist() {}
|
|
50
|
+
snapshot() {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* -- connections --------------------------------------------------- */
|
|
55
|
+
|
|
56
|
+
get clients() {
|
|
57
|
+
return this.sockets.size + this.streams.size;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Send to every connected client (WS + SSE). Returns the client count. */
|
|
61
|
+
broadcast(message) {
|
|
62
|
+
const payload = typeof message === 'string' ? message : JSON.stringify(message);
|
|
63
|
+
for (const ws of this.sockets) {
|
|
64
|
+
try {
|
|
65
|
+
ws.send(payload);
|
|
66
|
+
} catch {
|
|
67
|
+
this.#leave(this.sockets, ws);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const frame = ENC.encode(`data: ${payload}\n\n`);
|
|
71
|
+
for (const writer of this.streams) {
|
|
72
|
+
writer.write(frame).catch(() => this.#leave(this.streams, writer));
|
|
73
|
+
}
|
|
74
|
+
return this.clients;
|
|
75
|
+
}
|
|
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
|
+
|
|
86
|
+
#join(set, item) {
|
|
87
|
+
set.add(item);
|
|
88
|
+
this.#presence();
|
|
89
|
+
if (this.timer === null && this.tickMs > 0) {
|
|
90
|
+
this.timer = setInterval(() => this.#tick(), this.tickMs);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#leave(set, item) {
|
|
95
|
+
if (!set.delete(item)) return;
|
|
96
|
+
this.#presence();
|
|
97
|
+
if (this.clients === 0) {
|
|
98
|
+
if (this.timer !== null) {
|
|
99
|
+
clearInterval(this.timer);
|
|
100
|
+
this.timer = null;
|
|
101
|
+
}
|
|
102
|
+
Promise.resolve(this.persist()).catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async #tick() {
|
|
107
|
+
this.ticks += 1;
|
|
108
|
+
await this.onTick();
|
|
109
|
+
if (this.persistEvery > 0 && this.ticks % this.persistEvery === 0) {
|
|
110
|
+
await this.persist();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* -- request surface ------------------------------------------------ */
|
|
115
|
+
|
|
116
|
+
async fetch(request) {
|
|
117
|
+
const url = new URL(request.url);
|
|
118
|
+
|
|
119
|
+
if (url.pathname === '/ws') {
|
|
120
|
+
if (typeof WebSocketPair === 'undefined') {
|
|
121
|
+
return new Response('WebSocket unsupported on this runtime', { status: 501 });
|
|
122
|
+
}
|
|
123
|
+
const pair = new WebSocketPair();
|
|
124
|
+
const [client, server] = Object.values(pair);
|
|
125
|
+
server.accept();
|
|
126
|
+
this.#join(this.sockets, server);
|
|
127
|
+
server.addEventListener('message', (e) => {
|
|
128
|
+
Promise.resolve(this.onMessage(e.data, server)).catch(() => {});
|
|
129
|
+
});
|
|
130
|
+
server.addEventListener('close', () => this.#leave(this.sockets, server));
|
|
131
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (url.pathname === '/sse') {
|
|
135
|
+
const { readable, writable } = new TransformStream();
|
|
136
|
+
const writer = writable.getWriter();
|
|
137
|
+
writer.write(ENC.encode(': connected\n\n')).catch(() => {});
|
|
138
|
+
const first = this.snapshot();
|
|
139
|
+
if (first !== null && first !== undefined) {
|
|
140
|
+
writer.write(ENC.encode(`data: ${JSON.stringify(first)}\n\n`)).catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
this.#join(this.streams, writer);
|
|
143
|
+
request.signal?.addEventListener('abort', () => {
|
|
144
|
+
this.#leave(this.streams, writer);
|
|
145
|
+
writer.close().catch(() => {});
|
|
146
|
+
});
|
|
147
|
+
return new Response(readable, {
|
|
148
|
+
headers: {
|
|
149
|
+
'content-type': 'text/event-stream',
|
|
150
|
+
'cache-control': 'no-cache',
|
|
151
|
+
connection: 'keep-alive'
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (url.pathname === '/publish' && request.method === 'POST') {
|
|
157
|
+
let body = null;
|
|
158
|
+
try {
|
|
159
|
+
body = await request.json();
|
|
160
|
+
} catch {
|
|
161
|
+
return Response.json({ ok: false, error: 'body must be JSON' }, { status: 400 });
|
|
162
|
+
}
|
|
163
|
+
const clients = this.broadcast(body ?? {});
|
|
164
|
+
return Response.json({ ok: true, clients });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return Response.json({ clients: this.clients, ticks: this.ticks });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve the DO stub for a named room on a namespace binding (`env.ROOM`).
|
|
173
|
+
*
|
|
174
|
+
* @param {{ idFromName(name: string): *, get(id: *): * }} namespace
|
|
175
|
+
* @param {string} [name]
|
|
176
|
+
*/
|
|
177
|
+
export function roomStub(namespace, name = 'default') {
|
|
178
|
+
return namespace.get(namespace.idFromName(name));
|
|
179
|
+
}
|