@zubra/actions 0.0.0-stage → 5.3.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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026, Mikey Nichols
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # @zubra/actions
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Serve Zubra components' `'use server'` functions over plain form posts. The same form works without JavaScript (a post and a redirect back) and with it (a `fetch`, no navigation, an `action` event on the form).
4
+
5
+ ```js
6
+ import { createActions } from '@zubra/actions';
7
+ const actions = await createActions({ components: 'components' });
8
+
9
+ if (await actions.handle(req, res)) return; // Node's http
10
+ const response = await actions.fetch(request); // fetch-style servers; null if not ours
11
+ ```
12
+
13
+ Adapters: `@zubra/actions/hono`, `@zubra/actions/express`, `@zubra/actions/fastify`. Put `actions.script()` on pages with action forms.
14
+
15
+ 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).
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@zubra/actions",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "5.3.0",
4
+ "description": "Serve Zubra components' 'use server' functions over plain form posts, enhanced to fetch when JavaScript loads.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./client": {
14
+ "types": "./src/client.d.ts",
15
+ "default": "./src/client.js"
16
+ },
17
+ "./express": "./src/express.js",
18
+ "./fastify": "./src/fastify.js",
19
+ "./hono": "./src/hono.js"
20
+ },
21
+ "scripts": {
22
+ "test": "node --test \"test/*.test.js\""
23
+ },
24
+ "peerDependencies": {
25
+ "@zubra/compiler": "^5.3.0",
26
+ "@zubra/server": "^5.3.0"
27
+ },
28
+ "devDependencies": {
29
+ "@zubra/compiler": "file:../compiler",
30
+ "@zubra/runtime": "file:../runtime",
31
+ "@zubra/server": "file:../server",
32
+ "express": "5.3.0",
33
+ "fastify": "5.12.8",
34
+ "hono": "4.13.17",
35
+ "playwright": "1.63.0"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md"
40
+ ],
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "license": "ISC",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/journey-to-code-org/zubra.git",
48
+ "directory": "actions"
49
+ }
50
+ }
@@ -0,0 +1,11 @@
1
+ /** Enhance the action forms under `root`. The document is enhanced when the module loads. */
2
+ export function enhance(root: Document | ShadowRoot | Element): void;
3
+
4
+ declare global {
5
+ interface HTMLElementEventMap {
6
+ /** Before an action form's request; cancel it with `preventDefault()`. */
7
+ beforeaction: CustomEvent<{ formData: FormData }>;
8
+ /** After an action form's request. */
9
+ action: CustomEvent<{ value: unknown } | { error: string; status: number }>;
10
+ }
11
+ }
package/src/client.js ADDED
@@ -0,0 +1,70 @@
1
+ // @zubra/actions in the browser: a form that posts to a 'use server' function is sent with fetch
2
+ // instead, so the page stays put. Without this script (or without JavaScript), the same form posts
3
+ // normally and the server redirects back. Everything here is the platform's: a `submit` listener,
4
+ // FormData, fetch, `aria-busy`, and two DOM events on the form.
5
+ //
6
+ // beforeaction cancelable; before the request (detail: { formData })
7
+ // action after it (detail: { value } or { error, status })
8
+ //
9
+ // <form action={save} onaction={(event) => (saved = event.detail.value)}>
10
+ //
11
+ // A form opts out with `data-zb-native`. Forms inside a shadow root: call `enhance(shadowRoot)`.
12
+ const MOUNT = '/_zb/action/';
13
+
14
+ function target(form, submitter) {
15
+ const action = submitter?.getAttribute('formaction') ?? form.getAttribute('action') ?? '';
16
+ const method = (submitter?.getAttribute('formmethod') ?? form.getAttribute('method') ?? 'get').toLowerCase();
17
+ return method === 'post' && action.startsWith(MOUNT) ? action : null;
18
+ }
19
+
20
+ async function submit(event) {
21
+ const form = event.target;
22
+ if (!(form instanceof HTMLFormElement) || event.defaultPrevented || form.hasAttribute('data-zb-native')) return;
23
+ const action = target(form, event.submitter);
24
+ if (!action) return;
25
+ event.preventDefault();
26
+ const formData = new FormData(form, event.submitter ?? undefined);
27
+ const before = new CustomEvent('beforeaction', { bubbles: true, cancelable: true, detail: { formData } });
28
+ if (!form.dispatchEvent(before)) return;
29
+ form.setAttribute('aria-busy', 'true');
30
+ let detail;
31
+ try {
32
+ const response = await fetch(action, { method: 'POST', body: formData, headers: { accept: 'application/json' } });
33
+ const next = response.headers.get('zb-location');
34
+ if (next) {
35
+ go(next);
36
+ return;
37
+ }
38
+ if (response.redirected) {
39
+ location.assign(response.url);
40
+ return;
41
+ }
42
+ const type = response.headers.get('content-type') ?? '';
43
+ const value = type.includes('application/json') ? await response.json() : null;
44
+ detail = response.ok ? { value } : { error: value?.error ?? response.statusText, status: response.status };
45
+ } catch (error) {
46
+ detail = { error: error.message, status: 0 };
47
+ } finally {
48
+ form.removeAttribute('aria-busy');
49
+ }
50
+ form.dispatchEvent(new CustomEvent('action', { bubbles: true, detail }));
51
+ }
52
+
53
+ /**
54
+ * Navigate after a redirect. Back to this same page with only a different #fragment (the usual
55
+ * "post, then show the page again") is a hash change to the browser, which doesn't reload: reload,
56
+ * so the page shows what the post changed.
57
+ */
58
+ function go(next) {
59
+ const url = new URL(next, location.href);
60
+ const same = url.origin === location.origin && url.pathname === location.pathname && url.search === location.search;
61
+ location.assign(url);
62
+ if (same) location.reload();
63
+ }
64
+
65
+ /** Enhance the forms under `root` (the document already is; pass a shadow root for its forms). */
66
+ export function enhance(root) {
67
+ root.addEventListener('submit', submit);
68
+ }
69
+
70
+ enhance(document);
package/src/express.js ADDED
@@ -0,0 +1,23 @@
1
+ // Express middleware: answers the action endpoints and the client script, and sets the result of
2
+ // a no-JavaScript post as `res.locals.zubraAction` (null otherwise). Mount it before any body
3
+ // parser (express.urlencoded, multer): the action reads the body itself.
4
+ //
5
+ // import { actions } from '@zubra/actions/express';
6
+ // app.use(await actions({ components: 'components' }));
7
+ import { createActions } from './index.js';
8
+
9
+ export async function actions(options) {
10
+ const instance = await createActions(options);
11
+ const middleware = (req, res, next) => {
12
+ instance
13
+ .handle(req, res)
14
+ .then((answered) => {
15
+ if (answered) return;
16
+ res.locals.zubraAction = instance.result(req, (name, value) => res.append(name, value));
17
+ next();
18
+ })
19
+ .catch(next);
20
+ };
21
+ middleware.actions = instance;
22
+ return middleware;
23
+ }
package/src/fastify.js ADDED
@@ -0,0 +1,16 @@
1
+ // Fastify plugin: answers the action endpoints (before Fastify parses the body) and the client
2
+ // script, and sets the result of a no-JavaScript post as `request.zubraAction` (null otherwise).
3
+ //
4
+ // import { actions } from '@zubra/actions/fastify';
5
+ // await app.register(actions, { components: 'components' });
6
+ import { createActions } from './index.js';
7
+
8
+ export async function actions(app, options) {
9
+ const instance = await createActions(options);
10
+ app.decorateRequest('zubraAction', null);
11
+ app.addHook('onRequest', async (request, reply) => {
12
+ if (await instance.handle(request.raw, reply.raw)) return reply.hijack();
13
+ request.zubraAction = instance.result(request.raw, (name, value) => reply.header(name, value));
14
+ });
15
+ }
16
+ actions[Symbol.for('skip-override')] = true;
package/src/hono.js ADDED
@@ -0,0 +1,18 @@
1
+ // Hono middleware: answers the action endpoints and the client script, and sets the result of a
2
+ // no-JavaScript post as `c.get('zubraAction')` (null otherwise) for the page to render.
3
+ //
4
+ // import { actions } from '@zubra/actions/hono';
5
+ // app.use(await actions({ components: 'components' }));
6
+ import { createActions } from './index.js';
7
+
8
+ export async function actions(options) {
9
+ const instance = await createActions(options);
10
+ const middleware = async (c, next) => {
11
+ const response = await instance.fetch(c.req.raw, { c });
12
+ if (response) return response;
13
+ c.set('zubraAction', instance.result(c.req.raw, (name, value) => c.header(name, value, { append: true })));
14
+ await next();
15
+ };
16
+ middleware.actions = instance;
17
+ return middleware;
18
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ export const MOUNT: '/_zb/action/';
2
+ export const SCRIPT_PATH: '/_zb/actions.js';
3
+ export const COOKIE: '__zb_result';
4
+
5
+ export interface ActionsOptions {
6
+ /** The folder of `.zb` files. Default: `components`. */
7
+ components?: string;
8
+ /** Where the components' server halves are written. Default: `node_modules/.cache/zubra-actions`. */
9
+ cacheDir?: string;
10
+ /** Other origins allowed to post, beyond the request's own. */
11
+ origins?: string[];
12
+ /** Called when a server function throws. The client gets a generic error either way. */
13
+ onError?: (error: unknown, request: Request) => void;
14
+ /**
15
+ * What else a server function gets beside `request`, per request: the signed-in user, a
16
+ * database handle. `req` is Node's request (Node, Express, Fastify); `c` is Hono's context.
17
+ */
18
+ context?: (from: { request: Request; req?: import('node:http').IncomingMessage; c?: unknown }) => object | Promise<object>;
19
+ }
20
+
21
+ export interface ActionResult<T = unknown> {
22
+ /** The action's URL, `/_zb/action/<tag>/<name>`. */
23
+ action: string;
24
+ value: T;
25
+ }
26
+
27
+ export interface Actions {
28
+ /** A Response for an action or the client script; null for any other request. */
29
+ fetch(request: Request, extra?: { req?: import('node:http').IncomingMessage; c?: unknown }): Promise<Response | null>;
30
+ /** Node's http: answers ours and resolves true, else false without reading the body. */
31
+ handle(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse): Promise<boolean>;
32
+ /** After a no-JavaScript post, its result, once; `setHeader` gets the header that clears it. */
33
+ result<T = unknown>(
34
+ request: Request | import('node:http').IncomingMessage,
35
+ setHeader?: (name: string, value: string) => void,
36
+ ): ActionResult<T> | null;
37
+ /** Every action's URL. */
38
+ routes(): string[];
39
+ /** The `<script type="module">` that enhances action forms. */
40
+ script(): string;
41
+ /** A `Set-Cookie` value that clears the result cookie. */
42
+ clearCookie: string;
43
+ }
44
+
45
+ /** What a server function receives besides its FormData. */
46
+ export interface ActionContext {
47
+ request: Request;
48
+ /** And whatever `context()` returned. */
49
+ [key: string]: unknown;
50
+ }
51
+
52
+ export function createActions(options?: ActionsOptions): Promise<Actions>;
53
+ export function toRequest(req: import('node:http').IncomingMessage): Request;
54
+ export function send(response: Response, res: import('node:http').ServerResponse): Promise<void>;
package/src/index.js ADDED
@@ -0,0 +1,212 @@
1
+ // @zubra/actions: serve a component's 'use server' functions, over plain form posts.
2
+ //
3
+ // import { createActions } from '@zubra/actions';
4
+ // const actions = await createActions({ components: 'components' });
5
+ //
6
+ // actions.fetch(request) // fetch-style servers (Hono, Bun, Deno): a Response, or null if not ours
7
+ // actions.handle(req, res) // Node's http: true if it answered
8
+ // actions.result(request) // after a no-JavaScript post: { action, value } once, else null
9
+ // actions.script() // <script> for the page: enhances those forms into fetch requests
10
+ //
11
+ // The contract (plan/actions-design.md):
12
+ // - A form posts to /_zb/action/<tag>/<name> with its own encoding; the function gets the FormData.
13
+ // - `Accept: application/json` (the enhanced path) gets the return value as JSON.
14
+ // - Anything else (the browser's own post) gets 303 See Other back to the page it came from, with
15
+ // the return value in a one-use cookie, so the page can render it (`result()`).
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';
19
+ import { pathToFileURL } from 'node:url';
20
+ import { findCompiler } from '@zubra/server';
21
+
22
+ findCompiler();
23
+ const compiler = await import('@zubra/compiler');
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
+ const CLIENT = readFileSync(new URL('./client.js', import.meta.url), 'utf8');
30
+
31
+ /**
32
+ * `context({ request, req, c })`: what else a server function gets beside `request`, per request
33
+ * (the signed-in user, a database handle). `req` is Node's request (Node, Express, Fastify), `c`
34
+ * Hono's context. It may be async; what it returns is spread into the function's second argument.
35
+ *
36
+ * `components`: the folder of `.zb` files. `cacheDir`: where the server halves are written
37
+ * (bare imports resolve from there, so keep it inside the project). `origins`: other origins
38
+ * allowed to post (behind a proxy that rewrites Origin, say). `onError(error, request)`: called
39
+ * when a function throws; the client gets a generic error, never the message or stack.
40
+ */
41
+ export async function createActions({
42
+ components = 'components',
43
+ cacheDir = join(process.cwd(), 'node_modules', '.cache', 'zubra-actions'),
44
+ origins = [],
45
+ onError = (error) => console.error(error),
46
+ context = () => ({}),
47
+ } = {}) {
48
+ const root = resolve(components);
49
+ if (!existsSync(root)) throw new Error(`@zubra/actions: no components folder at ${root}`);
50
+ mkdirSync(cacheDir, { recursive: true });
51
+
52
+ // 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
+ }
146
+ }
147
+
148
+ /** Node's http: answers ours and returns true, or returns false without reading the body. */
149
+ async function handle(req, res) {
150
+ if (!req.url.startsWith(MOUNT) && !req.url.startsWith(SCRIPT_PATH)) return false;
151
+ const response = await fetch(toRequest(req), { req });
152
+ if (!response) return false;
153
+ await send(response, res);
154
+ return true;
155
+ }
156
+
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}`);
187
+ }
188
+
189
+ /** A Node request as a fetch Request (the body streams; nothing is read until it's ours). */
190
+ export function toRequest(req) {
191
+ const host = req.headers.host ?? 'localhost';
192
+ const protocol = req.socket?.encrypted ? 'https' : 'http';
193
+ const headers = new Headers();
194
+ for (const [name, value] of Object.entries(req.headers)) {
195
+ if (Array.isArray(value)) value.forEach((each) => headers.append(name, each));
196
+ else if (value !== undefined) headers.set(name, value);
197
+ }
198
+ const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : req;
199
+ return new Request(`${protocol}://${host}${req.url}`, { method: req.method, headers, body, duplex: 'half' });
200
+ }
201
+
202
+ /** Write a fetch Response to a Node response. */
203
+ export async function send(response, res) {
204
+ res.statusCode = response.status;
205
+ for (const [name, value] of response.headers) {
206
+ if (name === 'set-cookie') continue;
207
+ res.setHeader(name, value);
208
+ }
209
+ const cookies = response.headers.getSetCookie?.() ?? [];
210
+ if (cookies.length) res.setHeader('set-cookie', cookies);
211
+ res.end(response.body ? Buffer.from(await response.arrayBuffer()) : undefined);
212
+ }