@zubra/actions 5.5.0 → 5.6.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/README.md CHANGED
@@ -12,4 +12,6 @@ const response = await actions.fetch(request); // fetch-style servers; null if
12
12
 
13
13
  Adapters: `@zubra/actions/hono`, `@zubra/actions/express`, `@zubra/actions/fastify`. Put `actions.script()` on pages with action forms.
14
14
 
15
+ On Cloudflare Workers (no disk to compile from): `npx zubra-actions build components --out src/actions`, then `createActions({ actions })` from `@zubra/actions/edge`. See `docs/actions.md`.
16
+
15
17
  The contract, the security rules and the no-JavaScript path are in [docs/actions.md](https://github.com/journey-to-code-org/zubra/blob/main/docs/actions.md).
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // zubra-actions build <components> --out <folder>
3
+ import { buildActions } from '../src/build.js';
4
+
5
+ const [command, ...rest] = process.argv.slice(2);
6
+ const usage = 'Usage: zubra-actions build <components folder> --out <folder>';
7
+ if (command !== 'build') {
8
+ console.error(usage);
9
+ process.exit(command === '--help' || command === '-h' ? 0 : 2);
10
+ }
11
+ let components = 'components';
12
+ let out;
13
+ for (let i = 0; i < rest.length; i++) {
14
+ if (rest[i] === '--out' || rest[i] === '-o') out = rest[++i];
15
+ else if (rest[i].startsWith('--out=')) out = rest[i].slice(6);
16
+ else components = rest[i];
17
+ }
18
+ if (!out) {
19
+ console.error(usage);
20
+ process.exit(2);
21
+ }
22
+ try {
23
+ const routes = await buildActions({ components, out });
24
+ console.log(`${routes.length} action${routes.length === 1 ? '' : 's'} → ${out}/index.js`);
25
+ for (const route of routes) console.log(` ${route}`);
26
+ } catch (error) {
27
+ console.error(`zubra-actions: ${error.message}`);
28
+ process.exit(1);
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/actions",
3
- "version": "5.5.0",
3
+ "version": "5.6.0",
4
4
  "description": "Serve Zubra components' 'use server' functions over plain form posts, enhanced to fetch when JavaScript loads.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -14,6 +14,14 @@
14
14
  "types": "./src/client.d.ts",
15
15
  "default": "./src/client.js"
16
16
  },
17
+ "./edge": {
18
+ "types": "./src/edge.d.ts",
19
+ "default": "./src/edge.js"
20
+ },
21
+ "./build": {
22
+ "types": "./src/build.d.ts",
23
+ "default": "./src/build.js"
24
+ },
17
25
  "./express": "./src/express.js",
18
26
  "./fastify": "./src/fastify.js",
19
27
  "./hono": "./src/hono.js"
@@ -22,8 +30,8 @@
22
30
  "test": "node --test \"test/*.test.js\""
23
31
  },
24
32
  "peerDependencies": {
25
- "@zubra/compiler": "^5.5.0",
26
- "@zubra/server": "^5.5.0"
33
+ "@zubra/compiler": "^5.6.0",
34
+ "@zubra/server": "^5.6.0"
27
35
  },
28
36
  "devDependencies": {
29
37
  "@zubra/compiler": "file:../compiler",
@@ -34,8 +42,12 @@
34
42
  "hono": "4.13.17",
35
43
  "playwright": "1.63.0"
36
44
  },
45
+ "bin": {
46
+ "zubra-actions": "./bin/zubra-actions.js"
47
+ },
37
48
  "files": [
38
49
  "src",
50
+ "bin",
39
51
  "README.md"
40
52
  ],
41
53
  "engines": {
package/src/build.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Write each component's server functions to `<out>/<component>.server.js` and `<out>/index.js`
3
+ * exporting `actions`, for `@zubra/actions/edge`. Resolves to every route.
4
+ */
5
+ export function buildActions(options: { components?: string; out: string }): Promise<string[]>;
package/src/build.js ADDED
@@ -0,0 +1,71 @@
1
+ // `zubra-actions build`: a components folder's server functions as plain modules, for hosts that
2
+ // can't compile at start-up (a Cloudflare Worker has no disk and can't import code it generates).
3
+ // Your bundler (Wrangler, esbuild, Vite) then bundles them like any other import:
4
+ //
5
+ // npx zubra-actions build components --out src/actions # writes src/actions/index.js
6
+ // import { actions } from './actions/index.js';
7
+ // import { createActions } from '@zubra/actions/edge';
8
+ // const forms = createActions({ actions });
9
+ //
10
+ // Rebuild when a component's server functions change; the files say they're generated.
11
+ import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join, relative, resolve, sep } from 'node:path';
13
+ import { flatName, rewriteImports, serverHalves } from './compile.js';
14
+
15
+ const HEADER = '// Generated by `zubra-actions build` from';
16
+ /** A path from `from` to `to`, with "/" between its parts, as imports have. */
17
+ const posix = (from, to) => relative(from, to).split(sep).join('/');
18
+
19
+ /**
20
+ * Writes `<out>/<component>.server.js` for each component with server functions, and
21
+ * `<out>/index.js` exporting `actions`: every route. Resolves to the routes. Files it wrote before
22
+ * that no longer have a component are removed; nothing else in `out` is touched.
23
+ */
24
+ export async function buildActions({ components = 'components', out }) {
25
+ if (!out) throw new TypeError('buildActions({ components, out }): `out` is the folder to write');
26
+ const root = resolve(components);
27
+ const dir = resolve(out);
28
+ mkdirSync(dir, { recursive: true });
29
+ const halves = await serverHalves(root);
30
+ const written = new Set(['index.js']);
31
+ // The same check the Node entry makes: two components may not define one URL.
32
+ const urls = [];
33
+ for (const { output } of halves) {
34
+ for (const url of urlsOf(output)) {
35
+ if (urls.includes(url)) throw new Error(`@zubra/actions: two components define ${url}`);
36
+ urls.push(url);
37
+ }
38
+ }
39
+ const imports = [];
40
+ const maps = [];
41
+ for (const [n, { path, output }] of halves.entries()) {
42
+ const file = `${flatName(relative(root, path))}.server.js`;
43
+ const source = posix(dir, path);
44
+ // Relative imports still reach the component's neighbours, from where the file now is.
45
+ const code = rewriteImports(output, (specifier) => {
46
+ const target = posix(dir, resolve(dirname(path), specifier));
47
+ return target.startsWith('.') ? target : `./${target}`;
48
+ });
49
+ writeFileSync(join(dir, file), `${HEADER} ${source}. Don't edit it.\n${code}`);
50
+ written.add(file);
51
+ imports.push(`import { actions as a${n} } from './${file}';`);
52
+ maps.push(`a${n}`);
53
+ }
54
+ writeFileSync(
55
+ join(dir, 'index.js'),
56
+ `${HEADER} ${posix(dir, root) || '.'}. Don't edit it.\n` +
57
+ `// Every component's 'use server' functions: { '/_zb/action/<tag>/<name>': fn }.\n` +
58
+ `${imports.join('\n')}${imports.length ? '\n' : ''}\nexport const actions = { ${maps.map((m) => `...${m}`).join(', ')} };\n`,
59
+ );
60
+ // Leftovers from components that are gone (only files this command writes).
61
+ for (const entry of readdirSync(dir)) {
62
+ if (entry.endsWith('.server.js') && !written.has(entry)) rmSync(join(dir, entry));
63
+ }
64
+ return urls;
65
+ }
66
+
67
+ /** The URLs a server half's `actions` map names, read from the compiler's output (not run: it may import what only the host has). */
68
+ function urlsOf(output) {
69
+ const map = /export const actions = \{([^]*?)\};/.exec(output)?.[1] ?? '';
70
+ return [...map.matchAll(/"(\/_zb\/action\/[^"]+)"\s*:/g)].map(([, url]) => url);
71
+ }
package/src/compile.js ADDED
@@ -0,0 +1,47 @@
1
+ // The server halves of a components folder: each component's 'use server' functions, compiled.
2
+ // Node only (reads the folder, runs the compiler); used when the Node entry starts and by
3
+ // `zubra-actions build`, so both see the same functions.
4
+ import { readdirSync, statSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ import { findCompiler } from '@zubra/server';
7
+
8
+ const SOURCE = /\.zb(?:\.[jt]sx)?$/;
9
+
10
+ let compiler;
11
+ async function load() {
12
+ if (!compiler) {
13
+ findCompiler();
14
+ compiler = await import('@zubra/compiler');
15
+ }
16
+ return compiler;
17
+ }
18
+
19
+ /** `[{ path, output }]` for every component under `root` that has server functions, in path order. */
20
+ export async function serverHalves(root) {
21
+ const { request, sources: read } = await load();
22
+ const halves = [];
23
+ for (const path of sources(resolve(root))) {
24
+ const response = request({ emit: 'server', path, files: read(path) });
25
+ if (!response.ok) throw new Error(response.error);
26
+ if (/^export (async )?function /m.test(response.output)) halves.push({ path, output: response.output });
27
+ }
28
+ return halves;
29
+ }
30
+
31
+ /** A component's path without its extension, flattened into one file name: `forms/login.zb` → `forms__login`. */
32
+ export const flatName = (relativePath) => relativePath.split(/[\\/]/).join('__').replace(SOURCE, '');
33
+
34
+ /** Rewrite each relative import (`'./x'`, `'../x'`) with `to(specifier)`: static, re-exports, and `import('./x')`. */
35
+ export function rewriteImports(code, to) {
36
+ return code.replace(/(\bfrom\s*|\bimport\s*\(?\s*)(['"])(\.{1,2}\/[^'"]+)\2/g, (_, keyword, quote, specifier) => `${keyword}${quote}${to(specifier)}${quote}`);
37
+ }
38
+
39
+ function sources(dir, out = []) {
40
+ for (const entry of readdirSync(dir).sort()) {
41
+ const path = join(dir, entry);
42
+ if (statSync(path).isDirectory()) {
43
+ if (entry !== 'node_modules' && !entry.startsWith('.')) sources(path, out);
44
+ } else if (SOURCE.test(entry)) out.push(path);
45
+ }
46
+ return out;
47
+ }
package/src/core.js ADDED
@@ -0,0 +1,119 @@
1
+ // What @zubra/actions does with a request, given its routes: no file system, no Node.js. The Node
2
+ // entry (index.js) compiles a components folder into routes when it starts; the edge entry
3
+ // (edge.js) is given routes built ahead of time (`zubra-actions build`). Both answer through this.
4
+ export const MOUNT = '/_zb/action/';
5
+ export const SCRIPT_PATH = '/_zb/actions.js';
6
+ export const COOKIE = '__zb_result';
7
+ export const CLEAR = `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
8
+
9
+ /**
10
+ * `routes`: a Map of URL → function. `client`: the enhancing script's source, served at
11
+ * SCRIPT_PATH, or null when the host serves it as a static file.
12
+ */
13
+ export function createActionsCore({ routes, client = null, origins = [], onError = (error) => console.error(error), context = () => ({}) }) {
14
+ const allowed = (request) => {
15
+ const site = request.headers.get('sec-fetch-site');
16
+ if (site) return site === 'same-origin' || site === 'none';
17
+ const origin = request.headers.get('origin');
18
+ if (!origin) return true; // not a browser (curl, a test): CSRF needs a browser
19
+ return origin === new URL(request.url).origin || origins.includes(origin);
20
+ };
21
+
22
+ /** The page to go back to: the referring page on this origin, else the site root. */
23
+ const back = (request) => {
24
+ const referer = request.headers.get('referer');
25
+ try {
26
+ const url = new URL(referer, request.url);
27
+ if (url.origin === new URL(request.url).origin) return url.pathname + url.search;
28
+ } catch {}
29
+ return '/';
30
+ };
31
+
32
+ async function fetch(request, extra = {}) {
33
+ const url = new URL(request.url);
34
+ if (request.method === 'GET' && url.pathname === SCRIPT_PATH) {
35
+ return client === null ? null : new Response(client, { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
36
+ }
37
+ if (!url.pathname.startsWith(MOUNT)) return null;
38
+ if (request.method !== 'POST') return text(405, 'Method Not Allowed', { allow: 'POST' });
39
+ if (!allowed(request)) return text(403, 'Cross-site form posts are refused');
40
+ const fn = routes.get(url.pathname);
41
+ if (!fn) return text(404, 'Not Found');
42
+ const wantsJson = (request.headers.get('accept') ?? '').includes('application/json');
43
+ let value;
44
+ let failed = false;
45
+ try {
46
+ const more = await context({ request, ...extra });
47
+ value = await fn(await request.formData(), { ...more, request });
48
+ } catch (error) {
49
+ onError(error, request);
50
+ failed = true;
51
+ value = { error: 'Something went wrong' };
52
+ }
53
+ if (value instanceof Response) {
54
+ // A redirect after success: the enhanced path is told where to go rather than following it,
55
+ // so the page navigates once, with the location's #fragment kept (fetch drops it).
56
+ const location = value.headers.get('location');
57
+ if (wantsJson && location && value.status >= 300 && value.status < 400) {
58
+ const headers = new Headers({ 'zb-location': location });
59
+ for (const cookie of value.headers.getSetCookie()) headers.append('set-cookie', cookie);
60
+ return new Response(null, { status: 204, headers });
61
+ }
62
+ return value;
63
+ }
64
+ if (wantsJson) {
65
+ return new Response(JSON.stringify(value ?? null), {
66
+ status: failed ? 500 : 200,
67
+ headers: { 'content-type': 'application/json' },
68
+ });
69
+ }
70
+ const headers = new Headers({ location: back(request) });
71
+ const cookie = encodeURIComponent(JSON.stringify({ action: url.pathname, value: value ?? null }));
72
+ // A cookie holds about 4 KB; a bigger result reaches only the enhanced path.
73
+ if (cookie.length < 3800) {
74
+ headers.append('set-cookie', `${COOKIE}=${cookie}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60`);
75
+ }
76
+ return new Response(null, { status: 303, headers });
77
+ }
78
+
79
+ /**
80
+ * After a no-JavaScript post: `{ action, value }` once, else null. `request` is a fetch Request
81
+ * or a Node request; `setHeader(name, value)` is given the header that clears the cookie.
82
+ */
83
+ function result(request, setHeader) {
84
+ const header = (typeof request.headers.get === 'function' ? request.headers.get('cookie') : request.headers.cookie) ?? '';
85
+ const match = header.split(/;\s*/).find((part) => part.startsWith(`${COOKIE}=`));
86
+ if (!match) return null;
87
+ setHeader?.('set-cookie', CLEAR);
88
+ try {
89
+ return JSON.parse(decodeURIComponent(match.slice(COOKIE.length + 1)));
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ return {
96
+ fetch,
97
+ result,
98
+ routes: () => [...routes.keys()],
99
+ script: () => `<script type="module" src="${SCRIPT_PATH}"></script>`,
100
+ clearCookie: CLEAR,
101
+ };
102
+ }
103
+
104
+ /** `{ url: fn }` (one component's `actions`, or several merged) as routes, refusing a URL twice. */
105
+ export function routesFrom(...maps) {
106
+ const routes = new Map();
107
+ for (const map of maps) {
108
+ for (const [url, fn] of Object.entries(map ?? {})) {
109
+ if (!url.startsWith(MOUNT) || typeof fn !== 'function') throw new TypeError(`@zubra/actions: ${url} is not an action route`);
110
+ if (routes.has(url)) throw new Error(`@zubra/actions: two components define ${url}`);
111
+ routes.set(url, fn);
112
+ }
113
+ }
114
+ return routes;
115
+ }
116
+
117
+ function text(status, body, headers = {}) {
118
+ return new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8', ...headers } });
119
+ }
package/src/edge.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { ActionResult } from './index.js';
2
+
3
+ export const MOUNT: '/_zb/action/';
4
+ export const SCRIPT_PATH: '/_zb/actions.js';
5
+ export const COOKIE: '__zb_result';
6
+
7
+ /** A server function: the form's data, and `request` with whatever `context()` returned. */
8
+ export type ActionFunction = (formData: FormData, context: { request: Request; [key: string]: unknown }) => unknown;
9
+
10
+ export interface EdgeActionsOptions<Extra extends object = object> {
11
+ /** `{ '/_zb/action/<tag>/<name>': fn }`: `actions` from `zubra-actions build`'s index.js. */
12
+ actions: Record<string, ActionFunction>;
13
+ /** The enhancing script's source, to serve at `/_zb/actions.js`. Default: serve it from static assets. */
14
+ client?: string | null;
15
+ /** Other origins allowed to post, beyond the request's own. */
16
+ origins?: string[];
17
+ /** Called when a server function throws. The client gets a generic error either way. */
18
+ onError?: (error: unknown, request: Request) => void;
19
+ /** What else a server function gets beside `request`; `extra` is fetch's second argument (`{ env, ctx }`). */
20
+ context?: (from: { request: Request } & Extra) => object | Promise<object>;
21
+ }
22
+
23
+ export interface EdgeActions<Extra extends object = object> {
24
+ /** A Response for an action (or the client script, when `client` was given); null for any other request. */
25
+ fetch(request: Request, extra?: Extra): Promise<Response | null>;
26
+ /** After a no-JavaScript post, its result, once; `setHeader` gets the header that clears it. */
27
+ result<T = unknown>(request: Request, setHeader?: (name: string, value: string) => void): ActionResult<T> | null;
28
+ routes(): string[];
29
+ script(): string;
30
+ clearCookie: string;
31
+ }
32
+
33
+ export function createActions<Extra extends object = object>(options: EdgeActionsOptions<Extra>): EdgeActions<Extra>;
package/src/edge.js ADDED
@@ -0,0 +1,24 @@
1
+ // @zubra/actions without a disk: for Cloudflare Workers, Deno and anything else that can't compile
2
+ // a components folder when it starts. The server functions come from `zubra-actions build`; the
3
+ // requests are answered exactly as the Node entry answers them (core.js).
4
+ //
5
+ // import { actions } from './actions/index.js'; // npx zubra-actions build components --out src/actions
6
+ // import { createActions } from '@zubra/actions/edge';
7
+ // const forms = createActions({ actions, context: ({ request, env }) => ({ user: who(request, env) }) });
8
+ // export default { fetch: async (request, env) => (await forms.fetch(request, { env })) ?? page(request) };
9
+ //
10
+ // The enhancing script (`@zubra/actions/client`) is served from your static assets at
11
+ // /_zb/actions.js, as the runtime is, unless you pass its source as `client`.
12
+ import { createActionsCore, routesFrom } from './core.js';
13
+
14
+ export { MOUNT, SCRIPT_PATH, COOKIE } from './core.js';
15
+
16
+ /**
17
+ * `actions`: `{ url: fn }`, the build's `actions` (or several merged). `context(…)` gets
18
+ * `{ request, ...extra }`, where `extra` is fetch's second argument (`{ env, ctx }` in a Worker).
19
+ * `origins`, `onError` as the Node entry's.
20
+ */
21
+ export function createActions({ actions, client = null, origins = [], onError = (error) => console.error(error), context = () => ({}) } = {}) {
22
+ if (!actions || typeof actions !== 'object') throw new TypeError("createActions({ actions }): pass `actions` from `zubra-actions build`'s index.js");
23
+ return createActionsCore({ routes: routesFrom(actions), client, origins, onError, context });
24
+ }
package/src/index.js CHANGED
@@ -14,18 +14,14 @@
14
14
  // - Anything else (the browser's own post) gets 303 See Other back to the page it came from, with
15
15
  // the return value in a one-use cookie, so the page can render it (`result()`).
16
16
  // - A cross-site post is refused (Origin / Sec-Fetch-Site), so forms on other sites can't call it.
17
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
18
- import { dirname, join, relative, resolve, sep } from 'node:path';
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { dirname, join, relative, resolve } from 'node:path';
19
19
  import { pathToFileURL } from 'node:url';
20
- import { findCompiler } from '@zubra/server';
20
+ import { createActionsCore, routesFrom, MOUNT, SCRIPT_PATH, COOKIE } from './core.js';
21
+ import { flatName, rewriteImports, serverHalves } from './compile.js';
21
22
 
22
- findCompiler();
23
- const compiler = await import('@zubra/compiler');
23
+ export { MOUNT, SCRIPT_PATH, COOKIE };
24
24
 
25
- export const MOUNT = '/_zb/action/';
26
- export const SCRIPT_PATH = '/_zb/actions.js';
27
- export const COOKIE = '__zb_result';
28
- const SOURCE = /\.zb(?:\.[jt]sx)?$/;
29
25
  const CLIENT = readFileSync(new URL('./client.js', import.meta.url), 'utf8');
30
26
 
31
27
  /**
@@ -50,140 +46,25 @@ export async function createActions({
50
46
  mkdirSync(cacheDir, { recursive: true });
51
47
 
52
48
  // URL → function, from every component's server half.
53
- const routes = new Map();
54
- for (const path of sources(root)) {
55
- const response = compiler.request({ emit: 'server', path, files: compiler.sources(path) });
56
- if (!response.ok) throw new Error(response.error);
57
- if (!/^export (async )?function /m.test(response.output)) continue;
58
- const module = join(cacheDir, `${relative(root, path).split(sep).join('__').replace(SOURCE, '')}.server.mjs`);
59
- writeFileSync(module, absoluteImports(response.output, dirname(path)));
60
- const { actions } = await import(`${pathToFileURL(module).href}?t=${Date.now()}`);
61
- for (const [url, fn] of Object.entries(actions)) {
62
- if (routes.has(url)) throw new Error(`@zubra/actions: two components define ${url}`);
63
- routes.set(url, fn);
64
- }
65
- }
66
-
67
- const allowed = (request) => {
68
- const site = request.headers.get('sec-fetch-site');
69
- if (site) return site === 'same-origin' || site === 'none';
70
- const origin = request.headers.get('origin');
71
- if (!origin) return true; // not a browser (curl, a test): CSRF needs a browser
72
- return origin === new URL(request.url).origin || origins.includes(origin);
73
- };
74
-
75
- /** The page to go back to: the referring page on this origin, else the site root. */
76
- const back = (request) => {
77
- const referer = request.headers.get('referer');
78
- try {
79
- const url = new URL(referer, request.url);
80
- if (url.origin === new URL(request.url).origin) return url.pathname + url.search;
81
- } catch {}
82
- return '/';
83
- };
84
-
85
- async function fetch(request, extra = {}) {
86
- const url = new URL(request.url);
87
- if (request.method === 'GET' && url.pathname === SCRIPT_PATH) {
88
- return new Response(CLIENT, { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
89
- }
90
- if (!url.pathname.startsWith(MOUNT)) return null;
91
- if (request.method !== 'POST') return text(405, 'Method Not Allowed', { allow: 'POST' });
92
- if (!allowed(request)) return text(403, 'Cross-site form posts are refused');
93
- const fn = routes.get(url.pathname);
94
- if (!fn) return text(404, 'Not Found');
95
- const wantsJson = (request.headers.get('accept') ?? '').includes('application/json');
96
- let value;
97
- let failed = false;
98
- try {
99
- const more = await context({ request, ...extra });
100
- value = await fn(await request.formData(), { ...more, request });
101
- } catch (error) {
102
- onError(error, request);
103
- failed = true;
104
- value = { error: 'Something went wrong' };
105
- }
106
- if (value instanceof Response) {
107
- // A redirect after success: the enhanced path is told where to go rather than following it,
108
- // so the page navigates once, with the location's #fragment kept (fetch drops it).
109
- const location = value.headers.get('location');
110
- if (wantsJson && location && value.status >= 300 && value.status < 400) {
111
- const headers = new Headers({ 'zb-location': location });
112
- for (const cookie of value.headers.getSetCookie()) headers.append('set-cookie', cookie);
113
- return new Response(null, { status: 204, headers });
114
- }
115
- return value;
116
- }
117
- if (wantsJson) {
118
- return new Response(JSON.stringify(value ?? null), {
119
- status: failed ? 500 : 200,
120
- headers: { 'content-type': 'application/json' },
121
- });
122
- }
123
- const headers = new Headers({ location: back(request) });
124
- const cookie = encodeURIComponent(JSON.stringify({ action: url.pathname, value: value ?? null }));
125
- // A cookie holds about 4 KB; a bigger result reaches only the enhanced path.
126
- if (cookie.length < 3800) {
127
- headers.append('set-cookie', `${COOKIE}=${cookie}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60`);
128
- }
129
- return new Response(null, { status: 303, headers });
130
- }
131
-
132
- /**
133
- * After a no-JavaScript post: `{ action, value }` once, else null. `request` is a fetch Request
134
- * or a Node request; `setHeader(name, value)` is given the header that clears the cookie.
135
- */
136
- function result(request, setHeader) {
137
- const header = (typeof request.headers.get === 'function' ? request.headers.get('cookie') : request.headers.cookie) ?? '';
138
- const match = header.split(/;\s*/).find((part) => part.startsWith(`${COOKIE}=`));
139
- if (!match) return null;
140
- setHeader?.('set-cookie', CLEAR);
141
- try {
142
- return JSON.parse(decodeURIComponent(match.slice(COOKIE.length + 1)));
143
- } catch {
144
- return null;
145
- }
49
+ const maps = [];
50
+ for (const { path, output } of await serverHalves(root)) {
51
+ const module = join(cacheDir, `${flatName(relative(root, path))}.server.mjs`);
52
+ // The server half lives in the cache folder, so its relative imports point back at the source.
53
+ writeFileSync(module, rewriteImports(output, (specifier) => pathToFileURL(resolve(dirname(path), specifier)).href));
54
+ maps.push((await import(`${pathToFileURL(module).href}?t=${Date.now()}`)).actions);
146
55
  }
56
+ const core = createActionsCore({ routes: routesFrom(...maps), client: CLIENT, origins, onError, context });
147
57
 
148
58
  /** Node's http: answers ours and returns true, or returns false without reading the body. */
149
59
  async function handle(req, res) {
150
60
  if (!req.url.startsWith(MOUNT) && !req.url.startsWith(SCRIPT_PATH)) return false;
151
- const response = await fetch(toRequest(req), { req });
61
+ const response = await core.fetch(toRequest(req), { req });
152
62
  if (!response) return false;
153
63
  await send(response, res);
154
64
  return true;
155
65
  }
156
66
 
157
- return {
158
- fetch,
159
- handle,
160
- result,
161
- routes: () => [...routes.keys()],
162
- script: () => `<script type="module" src="${SCRIPT_PATH}"></script>`,
163
- clearCookie: CLEAR,
164
- };
165
- }
166
-
167
- const CLEAR = `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
168
-
169
- function text(status, body, headers = {}) {
170
- return new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8', ...headers } });
171
- }
172
-
173
- function sources(dir, out = []) {
174
- for (const entry of readdirSync(dir).sort()) {
175
- const path = join(dir, entry);
176
- if (statSync(path).isDirectory()) {
177
- if (entry !== 'node_modules' && !entry.startsWith('.')) sources(path, out);
178
- } else if (SOURCE.test(entry)) out.push(path);
179
- }
180
- return out;
181
- }
182
-
183
- /** The server half lives in the cache folder, so its relative imports point back at the source. */
184
- function absoluteImports(code, from) {
185
- return code.replace(/(\bfrom\s*|\bimport\s*)(['"])(\.{1,2}\/[^'"]+)\2/g, (_, keyword, quote, specifier) =>
186
- `${keyword}${quote}${pathToFileURL(resolve(from, specifier)).href}${quote}`);
67
+ return { ...core, handle };
187
68
  }
188
69
 
189
70
  /** A Node request as a fetch Request (the body streams; nothing is read until it's ours). */