@human-synthesis/norns 0.0.6 → 0.0.7

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,18 @@
1
+ /** @typedef {import('@sveltejs/kit').HandleServerError} HandleServerError */
2
+
3
+ /**
4
+ * Default `handleError` implementation: logs the error with request context
5
+ * and returns a safe payload for the client.
6
+ *
7
+ * Apps can wrap this or replace it via `boot({ handleError: custom })`.
8
+ *
9
+ * @param {{ logger?: { error: (msg: string, err: unknown) => void } }} [opts]
10
+ * @returns {HandleServerError}
11
+ */
12
+ export function errorHandle(opts = {}) {
13
+ const log = opts.logger ?? { error: (msg, err) => console.error(msg, err) };
14
+ return ({ error, event, status, message }) => {
15
+ log.error(`[norns] ${event.request.method} ${event.url.pathname} ${status}`, error);
16
+ return { message: message || 'Internal error' };
17
+ };
18
+ }
@@ -0,0 +1,9 @@
1
+ export { Container, createContainer } from './container.js';
2
+ export { withScope, getScope, getContainer } from './scope.js';
3
+ export { boot, createApp } from './boot.js';
4
+ export { contextHandle } from './handle/context.js';
5
+ export { errorHandle } from './handle/error.js';
6
+ export { route } from './route.js';
7
+ export { page } from './page.js';
8
+ export { validate, ValidationError } from './validate.js';
9
+ export { betterSqlite, d1, libsql, postgres, withTransaction } from './db.js';
@@ -0,0 +1,107 @@
1
+ import { fail } from '@sveltejs/kit';
2
+ import { validate, ValidationError } from './validate.js';
3
+
4
+ /** @typedef {import('@sveltejs/kit').ServerLoadEvent} ServerLoadEvent */
5
+ /** @typedef {import('@sveltejs/kit').RequestEvent} RequestEvent */
6
+ /** @typedef {import('./container.js').Container} Container */
7
+
8
+ /**
9
+ * @typedef {Object} LoadContext
10
+ * @property {Container} container
11
+ * @property {ServerLoadEvent} event
12
+ * @property {ServerLoadEvent['params']} params
13
+ * @property {ServerLoadEvent['url']} url
14
+ * @property {any} user
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} ActionContext
19
+ * @property {any} input parsed form data (after validation)
20
+ * @property {Container} container
21
+ * @property {RequestEvent} event
22
+ * @property {any} user
23
+ */
24
+
25
+ /**
26
+ * Wrappers for `+page.server.c` exports — `load` and `actions`. They mirror
27
+ * `route()` but: (a) `load` returns its result as data (no JSON wrapper), and
28
+ * (b) actions return `fail(400, ...)` on validation rather than throwing
29
+ * `error(400)`, which is the SvelteKit-idiomatic shape for forms.
30
+ */
31
+ export const page = {
32
+ /**
33
+ * Wrap a SvelteKit `load`.
34
+ *
35
+ * @param {{ handler: (ctx: LoadContext) => any | Promise<any> }} opts
36
+ * @returns {(event: ServerLoadEvent) => Promise<any>}
37
+ */
38
+ load(opts) {
39
+ if (typeof opts?.handler !== 'function') {
40
+ throw new Error('page.load(): `handler` is required');
41
+ }
42
+ return async (event) => {
43
+ return opts.handler({
44
+ container: event.locals.container,
45
+ event,
46
+ params: event.params,
47
+ url: event.url,
48
+ user: event.locals.user
49
+ });
50
+ };
51
+ },
52
+
53
+ /**
54
+ * Wrap a SvelteKit `actions` object. Each action takes `{ input?, run }`
55
+ * — `input` is a schema, `run` is the handler.
56
+ *
57
+ * @param {Record<string, { input?: any, run: (ctx: ActionContext) => any | Promise<any> }>} spec
58
+ * @returns {Record<string, (event: RequestEvent) => Promise<any>>}
59
+ */
60
+ actions(spec) {
61
+ /** @type {Record<string, (event: RequestEvent) => Promise<any>>} */
62
+ const out = {};
63
+ for (const [name, def] of Object.entries(spec)) {
64
+ if (typeof def?.run !== 'function') {
65
+ throw new Error(`page.actions(): action "${name}" missing \`run\` function`);
66
+ }
67
+ out[name] = async (event) => {
68
+ let raw = null;
69
+ let input;
70
+ if (def.input !== undefined) {
71
+ raw = await readForm(event.request);
72
+ try {
73
+ input = validate(def.input, raw);
74
+ } catch (e) {
75
+ if (e instanceof ValidationError) {
76
+ return fail(400, { errors: e.issues, values: raw });
77
+ }
78
+ throw e;
79
+ }
80
+ }
81
+ return def.run({
82
+ input,
83
+ container: event.locals.container,
84
+ event,
85
+ user: event.locals.user
86
+ });
87
+ };
88
+ }
89
+ return out;
90
+ }
91
+ };
92
+
93
+ /**
94
+ * Read form-encoded body into a plain object. Designed for `actions` —
95
+ * SvelteKit only invokes them via form POST.
96
+ *
97
+ * @param {Request} request
98
+ * @returns {Promise<any>}
99
+ */
100
+ async function readForm(request) {
101
+ try {
102
+ const data = await request.formData();
103
+ return Object.fromEntries(data);
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
@@ -0,0 +1,114 @@
1
+ import { json, error } from '@sveltejs/kit';
2
+ import { validate, ValidationError } from './validate.js';
3
+
4
+ /** @typedef {import('@sveltejs/kit').RequestEvent} RequestEvent */
5
+ /** @typedef {import('./container.js').Container} Container */
6
+
7
+ /**
8
+ * @typedef {Object} RouteContext
9
+ * @property {any} input parsed body (after validation)
10
+ * @property {any} query parsed query (after validation)
11
+ * @property {Container} container request-scoped container
12
+ * @property {RequestEvent} event raw SvelteKit event
13
+ * @property {any} user shortcut for `event.locals.user`
14
+ */
15
+
16
+ /**
17
+ * @typedef {Object} RouteOptions
18
+ * @property {any} [input] body schema (Standard Schema or function)
19
+ * @property {any} [query] query schema (Standard Schema or function)
20
+ * @property {(ctx: RouteContext) => any | Promise<any>} handler
21
+ */
22
+
23
+ /**
24
+ * Wrap a `+server.c` handler. Bakes in:
25
+ * 1. body parsing (JSON / urlencoded / multipart) + validation
26
+ * 2. query validation
27
+ * 3. container resolution from `event.locals.container`
28
+ * 4. JSON serialization of the return value (or pass-through if it's a Response)
29
+ * 5. 400 errors on validation failure (via SvelteKit `error()`)
30
+ *
31
+ * Use `throw error(...)` / `throw redirect(...)` from inside the handler for
32
+ * non-success outcomes; SvelteKit will surface them.
33
+ *
34
+ * @param {RouteOptions} opts
35
+ * @returns {(event: RequestEvent) => Promise<Response>}
36
+ */
37
+ export function route(opts) {
38
+ const { input: inputSchema, query: querySchema, handler } = opts;
39
+ if (typeof handler !== 'function') {
40
+ throw new Error('route(): `handler` is required');
41
+ }
42
+
43
+ return async (event) => {
44
+ const container = event.locals.container;
45
+
46
+ let input;
47
+ if (inputSchema !== undefined) {
48
+ const raw = await readBody(event.request);
49
+ try {
50
+ input = validate(inputSchema, raw);
51
+ } catch (e) {
52
+ if (e instanceof ValidationError) {
53
+ throw error(400, { message: e.message, issues: e.issues });
54
+ }
55
+ throw e;
56
+ }
57
+ }
58
+
59
+ let query;
60
+ if (querySchema !== undefined) {
61
+ const raw = Object.fromEntries(event.url.searchParams);
62
+ try {
63
+ query = validate(querySchema, raw);
64
+ } catch (e) {
65
+ if (e instanceof ValidationError) {
66
+ throw error(400, { message: e.message, issues: e.issues });
67
+ }
68
+ throw e;
69
+ }
70
+ }
71
+
72
+ const result = await handler({
73
+ input,
74
+ query,
75
+ container,
76
+ event,
77
+ user: event.locals.user
78
+ });
79
+
80
+ if (result instanceof Response) return result;
81
+ return json(result ?? null);
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Read and decode the request body based on its content-type. Returns `null`
87
+ * for empty bodies or unsupported types — the schema is then free to reject
88
+ * (or accept `null`).
89
+ *
90
+ * @param {Request} request
91
+ * @returns {Promise<any>}
92
+ */
93
+ async function readBody(request) {
94
+ const contentType = request.headers.get('content-type')?.split(';', 1)[0]?.trim() ?? '';
95
+ if (contentType === 'application/json') {
96
+ try {
97
+ return await request.json();
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+ if (
103
+ contentType === 'application/x-www-form-urlencoded' ||
104
+ contentType === 'multipart/form-data'
105
+ ) {
106
+ try {
107
+ const data = await request.formData();
108
+ return Object.fromEntries(data);
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+ return null;
114
+ }
@@ -0,0 +1,50 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ /**
4
+ * Per-request async context. Used to make `event.locals.container` (and the
5
+ * scoped `db`, `user`) implicitly available to callees that don't get the
6
+ * SvelteKit `event` passed in.
7
+ *
8
+ * On Cloudflare Workers, requires the `nodejs_als` + `nodejs_compat` compat
9
+ * flags in `wrangler.toml`.
10
+ *
11
+ * @template T
12
+ */
13
+
14
+ /** @typedef {{ container: import('./container.js').Container, [key: string]: any }} RequestScope */
15
+
16
+ /** @type {AsyncLocalStorage<RequestScope>} */
17
+ const storage = new AsyncLocalStorage();
18
+
19
+ /**
20
+ * Run `fn` with `scope` as the current request scope.
21
+ *
22
+ * @template T
23
+ * @param {RequestScope} scope
24
+ * @param {() => T | Promise<T>} fn
25
+ * @returns {T | Promise<T>}
26
+ */
27
+ export function withScope(scope, fn) {
28
+ return storage.run(scope, fn);
29
+ }
30
+
31
+ /**
32
+ * Get the current request scope. Returns `undefined` outside a request.
33
+ *
34
+ * @returns {RequestScope | undefined}
35
+ */
36
+ export function getScope() {
37
+ return storage.getStore();
38
+ }
39
+
40
+ /**
41
+ * Get the current request-scoped container, or throw if called outside a
42
+ * request.
43
+ *
44
+ * @returns {import('./container.js').Container}
45
+ */
46
+ export function getContainer() {
47
+ const scope = storage.getStore();
48
+ if (!scope) throw new Error('getContainer() called outside a request scope');
49
+ return scope.container;
50
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Validation glue. Norns doesn't bundle a schema library — it speaks the
3
+ * Standard Schema interface (https://github.com/standard-schema/standard-schema)
4
+ * supported by Valibot, Zod 3.24+, ArkType, etc. A plain function (`input -> parsed`)
5
+ * also works, for ad-hoc cases or simple Coffee parsers.
6
+ */
7
+
8
+ /** @typedef {{ '~standard': { validate: (input: unknown) => any } }} StandardSchema */
9
+ /** @typedef {{ kind: 'validation', path?: any[], message: string }} Issue */
10
+
11
+ export class ValidationError extends Error {
12
+ /**
13
+ * @param {Array<Issue>} issues
14
+ */
15
+ constructor(issues) {
16
+ const summary = issues
17
+ .map((i) => `${formatPath(i.path)}: ${i.message}`)
18
+ .join(', ');
19
+ super(`Validation failed: ${summary}`);
20
+ this.name = 'ValidationError';
21
+ this.issues = issues;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Validate `input` against `schema`. Returns the parsed value or throws
27
+ * `ValidationError`.
28
+ *
29
+ * Accepts:
30
+ * - Standard Schema instance (Valibot, Zod, ArkType, …)
31
+ * - a plain function `(input) => parsed` that throws on invalid input
32
+ * - `undefined` / `null` → passthrough (no validation)
33
+ *
34
+ * @template T
35
+ * @param {StandardSchema | ((input: unknown) => T) | undefined | null} schema
36
+ * @param {unknown} input
37
+ * @returns {T}
38
+ */
39
+ export function validate(schema, input) {
40
+ if (schema == null) return /** @type {T} */ (input);
41
+ if (typeof schema === 'function') return schema(input);
42
+ if (schema['~standard'] && typeof schema['~standard'].validate === 'function') {
43
+ const result = schema['~standard'].validate(input);
44
+ if (result instanceof Promise) {
45
+ throw new Error('Async schema validation is not supported in route()/page.actions()');
46
+ }
47
+ if (result.issues) throw new ValidationError(result.issues);
48
+ return result.value;
49
+ }
50
+ throw new Error('validate(): schema must implement Standard Schema or be a function');
51
+ }
52
+
53
+ /**
54
+ * @param {any[] | undefined} path
55
+ * @returns {string}
56
+ */
57
+ function formatPath(path) {
58
+ if (!path || path.length === 0) return '$';
59
+ return path.map((p) => (typeof p === 'object' ? p.key : p)).join('.');
60
+ }
package/src/vite.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { readFile, stat, realpath } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
- import CoffeeScript from 'coffeescript';
4
+ import { compile as compileCivet } from '@danielx/civet';
5
5
 
6
6
  const DEFAULT_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'];
7
- const NORNS_EXTENSIONS = ['.svelte', '.n', '.c'];
7
+ const NORNS_EXTENSIONS = ['.svelte', '.n', '.civet', '.c'];
8
8
  const RESOLVE_EXTENSIONS = [...NORNS_EXTENSIONS, '.ts', '.js'];
9
9
  const FRAMEWORK_PKGS = ['@human-synthesis/norns-core', '@human-synthesis/norns'];
10
10
 
@@ -49,16 +49,16 @@ async function resolveWorkspaceFrameworkSrcs(root) {
49
49
 
50
50
  /**
51
51
  * Vite plugin that:
52
- * - compiles `.c` files (CoffeeScript) on the fly, so SvelteKit special
53
- * files like `+page.c`, `+page.server.c`, `hooks.server.c`, and
54
- * `+server.c` work the same as their `.js` / `.ts` counterparts;
55
- * - registers `.svelte`, `.n`, and `.c` with Vite's resolver so bare
52
+ * - compiles `.civet` and `.c` files via @danielx/civet, so SvelteKit
53
+ * special files like `+page.civet`, `+page.server.c`, `hooks.server.civet`,
54
+ * and `+server.c` work the same as their `.js` / `.ts` counterparts;
55
+ * - registers `.svelte`, `.n`, `.civet`, `.c` with Vite's resolver so bare
56
56
  * imports (`import X from './Foo'`) try those extensions in priority
57
57
  * order, on top of Vite's defaults;
58
- * - resolves bare-name imports (`import X from 'Foo'`, no `./` prefix) to
59
- * a sibling file when one exists, in the same priority order. Real
60
- * package imports (`'svelte/store'`, `'@scope/pkg'`) are unaffected
61
- * because they contain a slash or scope marker;
58
+ * - resolves bare-name imports (`import X from 'Foo'`, no `./` prefix) to a
59
+ * sibling file when one exists, in the same priority order. Real package
60
+ * imports (`'svelte/store'`, `'@scope/pkg'`) are unaffected because they
61
+ * contain a slash or scope marker;
62
62
  * - in workspace-linked dev (sibling repos symlinked into node_modules),
63
63
  * excludes the framework packages from `optimizeDeps` pre-bundling and
64
64
  * lifts them out of the default `**\/node_modules\/**` watch ignore so
@@ -67,13 +67,16 @@ async function resolveWorkspaceFrameworkSrcs(root) {
67
67
  * process-level respawn when framework source changes — needed because
68
68
  * Node's ESM module cache survives `server.restart()`.
69
69
  *
70
+ * `.c` is recognised as an alias for `.civet` — both compile through Civet.
71
+ * CoffeeScript is no longer supported.
72
+ *
70
73
  * @returns {import('vite').Plugin}
71
74
  */
72
- export function nornsCoffeePlugin() {
75
+ export function nornsCivetPlugin() {
73
76
  /** @type {string[]} */
74
77
  let watchSrcs = [];
75
78
  return {
76
- name: 'norns:coffee',
79
+ name: 'norns:civet',
77
80
  enforce: 'pre',
78
81
  async config(_userConfig, { command }) {
79
82
  if (command === 'serve') {
@@ -110,6 +113,11 @@ export function nornsCoffeePlugin() {
110
113
  if (source.startsWith('.') || source.startsWith('/')) return null;
111
114
  // Scoped package or package-with-subpath — treat as a bare module.
112
115
  if (source.startsWith('@') || source.includes('/')) return null;
116
+ // Skip imports from inside node_modules — library code uses proper
117
+ // module resolution; we'd otherwise hijack legitimate package imports
118
+ // (e.g. `import { parse } from 'cookie'`) when a sibling file with
119
+ // the same name happens to exist in the same dir.
120
+ if (importer.includes(`${join('/', 'node_modules', '/')}`)) return null;
113
121
 
114
122
  // Single bare name — try resolving as a sibling file first, falling
115
123
  // back to the default resolver (node_modules) if nothing matches.
@@ -122,15 +130,14 @@ export function nornsCoffeePlugin() {
122
130
  },
123
131
  async load(id) {
124
132
  const [path] = id.split('?');
125
- if (!path.endsWith('.c')) return null;
133
+ if (!path.endsWith('.civet') && !path.endsWith('.c')) return null;
126
134
  const source = await readFile(path, 'utf8');
127
- const { js, sourceMap } = CoffeeScript.compile(source, {
128
- bare: true,
135
+ const result = await compileCivet(source, {
136
+ js: true,
129
137
  sourceMap: true,
130
- inlineMap: false,
131
138
  filename: path
132
139
  });
133
- return { code: js, map: sourceMap?.generate?.() ?? null };
140
+ return { code: result.code, map: result.sourceMap?.json?.(path) ?? null };
134
141
  }
135
142
  };
136
143
  }