@human-synthesis/norns 0.0.5 → 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,29 +1,143 @@
1
- import { readFile } from 'node:fs/promises';
2
- import CoffeeScript from 'coffeescript';
1
+ import { readFile, stat, realpath } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { compile as compileCivet } from '@danielx/civet';
5
+
6
+ const DEFAULT_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'];
7
+ const NORNS_EXTENSIONS = ['.svelte', '.n', '.civet', '.c'];
8
+ const RESOLVE_EXTENSIONS = [...NORNS_EXTENSIONS, '.ts', '.js'];
9
+ const FRAMEWORK_PKGS = ['@human-synthesis/norns-core', '@human-synthesis/norns'];
10
+
11
+ async function fileExists(path) {
12
+ try {
13
+ const s = await stat(path);
14
+ return s.isFile();
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
3
19
 
4
20
  /**
5
- * Vite plugin that compiles .coffee files to JavaScript.
6
- * Used so SvelteKit special files like +page.coffee, +page.server.coffee,
7
- * hooks.server.coffee, and +server.coffee work the same as their .js / .ts
8
- * counterparts.
21
+ * Resolve framework package src dirs that live OUTSIDE the consuming app's
22
+ * node_modules (i.e. workspace symlinks pointing at sibling repos). Returns
23
+ * real on-disk paths to watch. Empty array when packages are installed
24
+ * normally — published consumers never enter this branch.
25
+ *
26
+ * @param {string} root
27
+ * @returns {Promise<string[]>}
28
+ */
29
+ async function resolveWorkspaceFrameworkSrcs(root) {
30
+ const require = createRequire(join(root, 'package.json'));
31
+ const out = [];
32
+ for (const pkg of FRAMEWORK_PKGS) {
33
+ try {
34
+ const pkgJsonPath = require.resolve(`${pkg}/package.json`);
35
+ const real = await realpath(pkgJsonPath);
36
+ const pkgDir = dirname(real);
37
+ // Only watch when the real path resolves outside any node_modules —
38
+ // that's the workspace-symlink case. A normal install resolves to
39
+ // a real path inside node_modules and we leave it alone.
40
+ if (!pkgDir.includes(`${join('/', 'node_modules', '/')}`)) {
41
+ out.push(join(pkgDir, 'src'));
42
+ }
43
+ } catch {
44
+ // Package not installed (e.g. only norns-core present) — skip.
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * Vite plugin that:
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
+ * imports (`import X from './Foo'`) try those extensions in priority
57
+ * order, on top of Vite's defaults;
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
+ * - in workspace-linked dev (sibling repos symlinked into node_modules),
63
+ * excludes the framework packages from `optimizeDeps` pre-bundling and
64
+ * lifts them out of the default `**\/node_modules\/**` watch ignore so
65
+ * Vite reads source on each request and HMR fires. No-op for normal
66
+ * (published) installs. The companion `norns dev` CLI handles
67
+ * process-level respawn when framework source changes — needed because
68
+ * Node's ESM module cache survives `server.restart()`.
69
+ *
70
+ * `.c` is recognised as an alias for `.civet` — both compile through Civet.
71
+ * CoffeeScript is no longer supported.
9
72
  *
10
73
  * @returns {import('vite').Plugin}
11
74
  */
12
- export function nornsCoffeePlugin() {
75
+ export function nornsCivetPlugin() {
76
+ /** @type {string[]} */
77
+ let watchSrcs = [];
13
78
  return {
14
- name: 'norns:coffee',
79
+ name: 'norns:civet',
15
80
  enforce: 'pre',
81
+ async config(_userConfig, { command }) {
82
+ if (command === 'serve') {
83
+ watchSrcs = await resolveWorkspaceFrameworkSrcs(process.cwd());
84
+ }
85
+ /** @type {import('vite').UserConfig} */
86
+ const cfg = {
87
+ resolve: {
88
+ extensions: [...DEFAULT_EXTENSIONS, ...NORNS_EXTENSIONS]
89
+ },
90
+ optimizeDeps: {
91
+ exclude: [...FRAMEWORK_PKGS]
92
+ }
93
+ };
94
+ if (watchSrcs.length > 0) {
95
+ cfg.server = {
96
+ fs: { allow: watchSrcs.map((p) => dirname(p)) },
97
+ watch: {
98
+ ignored: [
99
+ '**/.git/**',
100
+ (path) => {
101
+ if (watchSrcs.some((src) => path.startsWith(src))) return false;
102
+ return path.includes(`${join('/', 'node_modules', '/')}`);
103
+ }
104
+ ]
105
+ }
106
+ };
107
+ }
108
+ return cfg;
109
+ },
110
+ async resolveId(source, importer) {
111
+ if (!importer) return null;
112
+ // Already explicit relative or absolute — let the default resolver run.
113
+ if (source.startsWith('.') || source.startsWith('/')) return null;
114
+ // Scoped package or package-with-subpath — treat as a bare module.
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;
121
+
122
+ // Single bare name — try resolving as a sibling file first, falling
123
+ // back to the default resolver (node_modules) if nothing matches.
124
+ const dir = dirname(importer);
125
+ for (const ext of RESOLVE_EXTENSIONS) {
126
+ const candidate = join(dir, source + ext);
127
+ if (await fileExists(candidate)) return candidate;
128
+ }
129
+ return null;
130
+ },
16
131
  async load(id) {
17
132
  const [path] = id.split('?');
18
- if (!path.endsWith('.coffee')) return null;
133
+ if (!path.endsWith('.civet') && !path.endsWith('.c')) return null;
19
134
  const source = await readFile(path, 'utf8');
20
- const { js, sourceMap } = CoffeeScript.compile(source, {
21
- bare: true,
135
+ const result = await compileCivet(source, {
136
+ js: true,
22
137
  sourceMap: true,
23
- inlineMap: false,
24
138
  filename: path
25
139
  });
26
- return { code: js, map: sourceMap?.generate?.() ?? null };
140
+ return { code: result.code, map: result.sourceMap?.json?.(path) ?? null };
27
141
  }
28
142
  };
29
143
  }
package/src/uno.js DELETED
@@ -1 +0,0 @@
1
- export { nornsUno } from '@human-synthesis/norns-core/uno';