@zubra/actions 5.7.0 → 5.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/actions",
3
- "version": "5.7.0",
3
+ "version": "5.8.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",
@@ -30,8 +30,8 @@
30
30
  "test": "node --test \"test/*.test.js\""
31
31
  },
32
32
  "peerDependencies": {
33
- "@zubra/compiler": "^5.7.0",
34
- "@zubra/server": "^5.7.0"
33
+ "@zubra/compiler": "^5.8.0",
34
+ "@zubra/server": "^5.8.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@zubra/compiler": "file:../compiler",
package/src/core.js CHANGED
@@ -5,12 +5,41 @@ export const MOUNT = '/_zb/action/';
5
5
  export const SCRIPT_PATH = '/_zb/actions.js';
6
6
  export const COOKIE = '__zb_result';
7
7
  export const CLEAR = `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
8
+ /** The largest post read by default: 10 MB, files and fields together. */
9
+ export const MAX_BYTES = 10 * 1024 * 1024;
10
+ /** What a function's caller gets for a post over the limit; the function doesn't run. */
11
+ export const TOO_LARGE = Object.freeze({ error: 'Too large' });
8
12
 
9
13
  /**
10
14
  * `routes`: a Map of URL → function. `client`: the enhancing script's source, served at
11
15
  * SCRIPT_PATH, or null when the host serves it as a static file.
12
16
  */
13
- export function createActionsCore({ routes, client = null, origins = [], onError = (error) => console.error(error), context = () => ({}) }) {
17
+ export function createActionsCore({ routes, client = null, origins = [], onError = (error) => console.error(error), context = () => ({}), maxBytes = MAX_BYTES }) {
18
+ if (!(maxBytes > 0)) throw new TypeError('@zubra/actions: `maxBytes` is the largest post to read, in bytes (Infinity for no limit)');
19
+ const over = Symbol('too large');
20
+ /** The post's fields and files, or `over` when it's bigger than maxBytes (counted as it's read). */
21
+ async function readForm(request) {
22
+ if (Number(request.headers.get('content-length')) > maxBytes) return over;
23
+ if (maxBytes === Infinity || !request.body) return request.formData();
24
+ let seen = 0;
25
+ let tooLarge = false;
26
+ const counted = request.body.pipeThrough(
27
+ new TransformStream({
28
+ transform(chunk, controller) {
29
+ seen += chunk.byteLength;
30
+ if (seen <= maxBytes) return controller.enqueue(chunk);
31
+ tooLarge = true;
32
+ controller.error(new RangeError('too large'));
33
+ },
34
+ }),
35
+ );
36
+ try {
37
+ return await new Response(counted, { headers: { 'content-type': request.headers.get('content-type') ?? '' } }).formData();
38
+ } catch (error) {
39
+ if (tooLarge) return over;
40
+ throw error;
41
+ }
42
+ }
14
43
  const allowed = (request) => {
15
44
  const site = request.headers.get('sec-fetch-site');
16
45
  if (site) return site === 'same-origin' || site === 'none';
@@ -41,13 +70,19 @@ export function createActionsCore({ routes, client = null, origins = [], onError
41
70
  if (!fn) return text(404, 'Not Found');
42
71
  const wantsJson = (request.headers.get('accept') ?? '').includes('application/json');
43
72
  let value;
44
- let failed = false;
73
+ let status = 200;
45
74
  try {
46
- const more = await context({ request, ...extra });
47
- value = await fn(await request.formData(), { ...more, request });
75
+ const form = await readForm(request);
76
+ if (form === over) {
77
+ status = 413;
78
+ value = TOO_LARGE;
79
+ } else {
80
+ const more = await context({ request, ...extra });
81
+ value = await fn(form, { ...more, request });
82
+ }
48
83
  } catch (error) {
49
84
  onError(error, request);
50
- failed = true;
85
+ status = 500;
51
86
  value = { error: 'Something went wrong' };
52
87
  }
53
88
  if (value instanceof Response) {
@@ -63,7 +98,7 @@ export function createActionsCore({ routes, client = null, origins = [], onError
63
98
  }
64
99
  if (wantsJson) {
65
100
  return new Response(JSON.stringify(value ?? null), {
66
- status: failed ? 500 : 200,
101
+ status,
67
102
  headers: { 'content-type': 'application/json' },
68
103
  });
69
104
  }
package/src/edge.d.ts CHANGED
@@ -16,6 +16,11 @@ export interface EdgeActionsOptions<Extra extends object = object> {
16
16
  origins?: string[];
17
17
  /** Called when a server function throws. The client gets a generic error either way. */
18
18
  onError?: (error: unknown, request: Request) => void;
19
+ /**
20
+ * The largest post read, files and fields together, in bytes: 10 MB by default, `Infinity` for
21
+ * no limit. A bigger one gets `{ error: 'Too large' }` (status 413) and the function doesn't run.
22
+ */
23
+ maxBytes?: number;
19
24
  /** What else a server function gets beside `request`; `extra` is fetch's second argument (`{ env, ctx }`). */
20
25
  context?: (from: { request: Request } & Extra) => object | Promise<object>;
21
26
  }
package/src/edge.js CHANGED
@@ -18,7 +18,7 @@ export { MOUNT, SCRIPT_PATH, COOKIE } from './core.js';
18
18
  * `{ request, ...extra }`, where `extra` is fetch's second argument (`{ env, ctx }` in a Worker).
19
19
  * `origins`, `onError` as the Node entry's.
20
20
  */
21
- export function createActions({ actions, client = null, origins = [], onError = (error) => console.error(error), context = () => ({}) } = {}) {
21
+ export function createActions({ actions, client = null, origins = [], onError = (error) => console.error(error), context = () => ({}), maxBytes } = {}) {
22
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 });
23
+ return createActionsCore({ routes: routesFrom(actions), client, origins, onError, context, maxBytes });
24
24
  }
package/src/index.d.ts CHANGED
@@ -11,6 +11,11 @@ export interface ActionsOptions {
11
11
  origins?: string[];
12
12
  /** Called when a server function throws. The client gets a generic error either way. */
13
13
  onError?: (error: unknown, request: Request) => void;
14
+ /**
15
+ * The largest post read, files and fields together, in bytes: 10 MB by default, `Infinity` for
16
+ * no limit. A bigger one gets `{ error: 'Too large' }` (status 413) and the function doesn't run.
17
+ */
18
+ maxBytes?: number;
14
19
  /**
15
20
  * What else a server function gets beside `request`, per request: the signed-in user, a
16
21
  * database handle. `req` is Node's request (Node, Express, Fastify); `c` is Hono's context.
package/src/index.js CHANGED
@@ -14,7 +14,8 @@
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, readFileSync, writeFileSync } from 'node:fs';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
18
19
  import { dirname, join, relative, resolve } from 'node:path';
19
20
  import { pathToFileURL } from 'node:url';
20
21
  import { createActionsCore, routesFrom, MOUNT, SCRIPT_PATH, COOKIE } from './core.js';
@@ -33,6 +34,8 @@ const CLIENT = readFileSync(new URL('./client.js', import.meta.url), 'utf8');
33
34
  * (bare imports resolve from there, so keep it inside the project). `origins`: other origins
34
35
  * allowed to post (behind a proxy that rewrites Origin, say). `onError(error, request)`: called
35
36
  * when a function throws; the client gets a generic error, never the message or stack.
37
+ * `maxBytes`: the largest post read, files and fields together (10 MB by default; `Infinity`
38
+ * for none). A bigger one gets `{ error: 'Too large' }` (413) and the function doesn't run.
36
39
  */
37
40
  export async function createActions({
38
41
  components = 'components',
@@ -40,6 +43,7 @@ export async function createActions({
40
43
  origins = [],
41
44
  onError = (error) => console.error(error),
42
45
  context = () => ({}),
46
+ maxBytes,
43
47
  } = {}) {
44
48
  const root = resolve(components);
45
49
  if (!existsSync(root)) throw new Error(`@zubra/actions: no components folder at ${root}`);
@@ -50,10 +54,14 @@ export async function createActions({
50
54
  for (const { path, output } of await serverHalves(root)) {
51
55
  const module = join(cacheDir, `${flatName(relative(root, path))}.server.mjs`);
52
56
  // 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));
57
+ // Written beside it and renamed into place, so another process starting at the same moment
58
+ // (a second worker, a parallel test) never imports a half-written file and finds no actions.
59
+ const writing = `${module}.${process.pid}.${randomUUID()}.tmp`;
60
+ writeFileSync(writing, rewriteImports(output, (specifier) => pathToFileURL(resolve(dirname(path), specifier)).href));
61
+ renameSync(writing, module);
54
62
  maps.push((await import(`${pathToFileURL(module).href}?t=${Date.now()}`)).actions);
55
63
  }
56
- const core = createActionsCore({ routes: routesFrom(...maps), client: CLIENT, origins, onError, context });
64
+ const core = createActionsCore({ routes: routesFrom(...maps), client: CLIENT, origins, onError, context, maxBytes });
57
65
 
58
66
  /** Node's http: answers ours and returns true, or returns false without reading the body. */
59
67
  async function handle(req, res) {
@@ -76,10 +84,39 @@ export function toRequest(req) {
76
84
  if (Array.isArray(value)) value.forEach((each) => headers.append(name, each));
77
85
  else if (value !== undefined) headers.set(name, value);
78
86
  }
79
- const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : req;
87
+ const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : bodyOf(req);
80
88
  return new Request(`${protocol}://${host}${req.url}`, { method: req.method, headers, body, duplex: 'half' });
81
89
  }
82
90
 
91
+ /**
92
+ * A Node request's body as a web stream. Cancelling it (a post over maxBytes) discards the rest
93
+ * rather than destroying the request, so the connection survives to carry the answer: a browser
94
+ * still sending gets it instead of a reset. Node discards what's left once the response ends.
95
+ */
96
+ function bodyOf(req) {
97
+ let data;
98
+ return new ReadableStream({
99
+ start(controller) {
100
+ data = (chunk) => controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
101
+ req.on('data', data);
102
+ req.once('end', () => {
103
+ try {
104
+ controller.close();
105
+ } catch {}
106
+ });
107
+ req.once('error', (error) => {
108
+ try {
109
+ controller.error(error);
110
+ } catch {}
111
+ });
112
+ },
113
+ cancel() {
114
+ req.off('data', data);
115
+ req.resume();
116
+ },
117
+ });
118
+ }
119
+
83
120
  /** Write a fetch Response to a Node response. */
84
121
  export async function send(response, res) {
85
122
  res.statusCode = response.status;