@webjsdev/server 0.8.81 → 0.8.83

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": "@webjsdev/server",
3
- "version": "0.8.81",
3
+ "version": "0.8.83",
4
4
  "type": "module",
5
5
  "description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
6
6
  "main": "index.js",
@@ -285,6 +285,118 @@ const PURE_DATA_CONSTRUCTORS = new Set([
285
285
  'BigInt64Array', 'BigUint64Array',
286
286
  ]);
287
287
 
288
+ /**
289
+ * Marks where `hasModuleScopeSideEffect` dropped a top-level `{...}` body from
290
+ * its depth-0 frame, so an arrow followed by a block body is not mistaken for
291
+ * an expression body that runs on into the next statement. Removed again
292
+ * before the call scan.
293
+ */
294
+ const BLOCK = '\x01';
295
+
296
+ /** `function`, `function*`, `async function name` up to its parameter list's `(`. */
297
+ const FUNCTION_PARAMS_RE = /(?<![\w$.])function\s*\*?\s*(?:[A-Za-z_$][\w$]*)?\s*\(/g;
298
+
299
+ /**
300
+ * The text before an arrow's `=>` when that arrow is a declarator initializer
301
+ * (`const usd = (m) =>`, `let f = async x =>`, `, g = (a) =>`). The parameter
302
+ * list must hold no parens, so a call in a parameter default
303
+ * (`(a = init()) =>`) keeps its arrow unrecognised and still ships.
304
+ */
305
+ const ARROW_DECLARATOR_RE =
306
+ /(?:(?<![\w$.])(?:const|let|var)\s+|,\s*)[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?(?:\([^()]*\)|[A-Za-z_$][\w$]*)\s*$/;
307
+
308
+ /**
309
+ * An expression body that ends this way cannot stop at the newline (a binary
310
+ * operator, `?`, `:`, or a curried `=>`). Postfix `++` / `--` and a regex
311
+ * literal's closing `/` end a statement, so neither is listed.
312
+ */
313
+ const BODY_CONTINUES_RE = /(?:=>|&&|\|\||\?\?|[?:=*%|&^<>]|(?<![+])\+|(?<![-])-)\s*$/;
314
+
315
+ /** A line starting this way continues the previous one: no statement starts with it. */
316
+ const LINE_CONTINUES_RE = /^\s*(?:\?|:|&&|\|\||\.(?![.\d])|\*|%|=(?!>)|\|)/;
317
+
318
+ /**
319
+ * Removes the two depth-0 shapes that look like calls and run nothing at module
320
+ * load: a `function` declaration's parameter list (a default such as
321
+ * `now = Date.now()` runs per call) and an arrow's expression body when the
322
+ * arrow initializes a declarator (`const usd = (m) => Math.round(m) / MICROS`).
323
+ *
324
+ * The arrow body ends at a `,` or `;` at its own paren/bracket depth 0, at a
325
+ * `)` / `]` closing something outside it, or at a newline once the body has
326
+ * content, unless `BODY_CONTINUES_RE` / `LINE_CONTINUES_RE` prove the
327
+ * expression carries on. Ending early is always safe, because whatever is left
328
+ * stays in the frame and is still scanned; ending late could swallow a real
329
+ * statement, so every rule errs towards ending.
330
+ *
331
+ * @param {string} frame depth-0 text, with `BLOCK` where a `{...}` was dropped
332
+ * @returns {string | null} the frame without those parts, or null when a
333
+ * parameter list does not close (the caller then ships)
334
+ */
335
+ function blankDeclarationOnlyCode(frame) {
336
+ let out = '';
337
+ let last = 0;
338
+ for (const m of frame.matchAll(FUNCTION_PARAMS_RE)) {
339
+ if (m.index < last) continue;
340
+ const open = m.index + m[0].length - 1;
341
+ let depth = 0;
342
+ let close = -1;
343
+ for (let j = open; j < frame.length; j++) {
344
+ if (frame[j] === '(') depth++;
345
+ else if (frame[j] === ')' && --depth === 0) { close = j; break; }
346
+ }
347
+ if (close < 0) return null;
348
+ out += frame.slice(last, open + 1);
349
+ last = close;
350
+ }
351
+ frame = out + frame.slice(last);
352
+
353
+ out = '';
354
+ let depth = 0;
355
+ for (let i = 0; i < frame.length; i++) {
356
+ const c = frame[i];
357
+ if (c === '(' || c === '[') depth++;
358
+ else if ((c === ')' || c === ']') && depth > 0) depth--;
359
+ if (depth === 0 && c === '=' && frame[i + 1] === '>' && ARROW_DECLARATOR_RE.test(out)) {
360
+ out += '=>';
361
+ i = arrowBodyEnd(frame, i + 2) - 1;
362
+ continue;
363
+ }
364
+ out += c;
365
+ }
366
+ return out;
367
+ }
368
+
369
+ /**
370
+ * Index where an arrow's expression body starting at `start` ends (see
371
+ * `blankDeclarationOnlyCode`).
372
+ *
373
+ * @param {string} frame
374
+ * @param {number} start index just past the `=>`
375
+ * @returns {number}
376
+ */
377
+ function arrowBodyEnd(frame, start) {
378
+ let depth = 0;
379
+ let i = start;
380
+ for (; i < frame.length; i++) {
381
+ const c = frame[i];
382
+ if (c === '(' || c === '[') { depth++; continue; }
383
+ if (c === ')' || c === ']') {
384
+ if (depth === 0) return i;
385
+ depth--;
386
+ continue;
387
+ }
388
+ if (depth > 0) continue;
389
+ if (c === ',' || c === ';') return i;
390
+ if (c === '\n') {
391
+ const body = frame.slice(start, i);
392
+ if (!body.trim() || BODY_CONTINUES_RE.test(body)) continue;
393
+ if (LINE_CONTINUES_RE.test(frame.slice(i + 1))) continue;
394
+ return i;
395
+ }
396
+ }
397
+ return i;
398
+ }
399
+
288
400
  /**
289
401
  * Module-scope client work, detected by an ALLOWLIST of safe top-level forms
290
402
  * rather than a denylist of browser globals. A module that runs ANY code when
@@ -320,8 +432,13 @@ const PURE_DATA_CONSTRUCTORS = new Set([
320
432
  * request path is covered; a DIRECT caller handing it `.ts` source as authored
321
433
  * is opting into that false positive.
322
434
  *
323
- * Over-detection is safe (a top-level arrow whose body calls something, or a
324
- * pure top-level helper call, only ships). The accepted residual misses, all
435
+ * A `function` declaration's parameter list and the expression body of an
436
+ * arrow that initializes a `const` / `let` / `var` declarator are dropped from
437
+ * the frame first (`blankDeclarationOnlyCode`): `now = Date.now()` and
438
+ * `(m) => Math.round(m) / MICROS` declare code, they do not run it.
439
+ *
440
+ * Over-detection is safe (a pure top-level helper call, or an arrow outside a
441
+ * declarator whose body calls something, only ships). The accepted residual misses, all
325
442
  * contrived and structural (so they do not rot), are a call buried inside a
326
443
  * top-level object / array initializer or a destructuring default, and a
327
444
  * side-effecting tagged-template hole evaluated at module scope.
@@ -361,7 +478,7 @@ export function hasModuleScopeSideEffect(src, literals) {
361
478
  if (depth === 0) frame += "''";
362
479
  continue;
363
480
  }
364
- if (c === '{') { depth++; continue; }
481
+ if (c === '{') { if (depth === 0) frame += BLOCK; depth++; continue; }
365
482
  if (c === '}') { if (depth > 0) depth--; continue; }
366
483
  if (depth === 0) frame += c;
367
484
  }
@@ -369,6 +486,11 @@ export function hasModuleScopeSideEffect(src, literals) {
369
486
  // literal with a stray brace is the common case). Ship rather than risk a
370
487
  // hidden top-level statement.
371
488
  if (depth !== 0) return true;
489
+ // Parameter lists and declarator arrow bodies are declarations, not code
490
+ // that runs at load.
491
+ const declared = blankDeclarationOnlyCode(frame);
492
+ if (declared === null) return true;
493
+ frame = declared.replaceAll(BLOCK, '');
372
494
  // Optional-chaining call/index: `foo?.()`, `x?.[i]()` (the `?.` defeats the
373
495
  // identifier-before-paren match below).
374
496
  if (/\?\.\s*[([]/.test(frame)) return true;
@@ -385,6 +385,28 @@ function textValuesOf(formData) {
385
385
  return out;
386
386
  }
387
387
 
388
+ /**
389
+ * A failure result with `values` completed from the submission (#1581).
390
+ *
391
+ * Every submitted text field is there, so a page refills a field with
392
+ * `value=${actionData?.values?.x}` without the action having to echo it, which
393
+ * is the one step generated and hand-written actions both keep forgetting. The
394
+ * action's own `values` win, so an action that normalizes or deliberately
395
+ * blanks a field (a password) still decides. A repeated name (a checkbox group)
396
+ * keeps its last value, as `textValuesOf` does; read the FormData for the full
397
+ * list. Files are left out. The author's object is copied, never mutated.
398
+ *
399
+ * @param {unknown} result
400
+ * @param {FormData} formData
401
+ * @returns {unknown}
402
+ */
403
+ function withSubmittedValues(result, formData) {
404
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return result;
405
+ const own = /** @type any */ (result).values;
406
+ const ownValues = own && typeof own === 'object' && !Array.isArray(own) ? own : {};
407
+ return { ...result, values: { ...textValuesOf(formData), ...ownValues } };
408
+ }
409
+
388
410
  /**
389
411
  * A page path exists but only renders: the method is the problem, not the url.
390
412
  * @returns {Response}
@@ -523,8 +545,9 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
523
545
  return await formActionErrorResponse(v.thrown, ssrOpts.dev);
524
546
  }
525
547
  // A structured failure envelope is a NORMAL result the page renders, so
526
- // it takes the 422 re-render path exactly as a failing action would.
527
- return ssrPage(route, params, url, { ...ssrOpts, req, actionData: v.result, status: 422 });
548
+ // it takes the 422 re-render path exactly as a failing action would,
549
+ // submitted values included (#1581).
550
+ return ssrPage(route, params, url, { ...ssrOpts, req, actionData: withSubmittedValues(v.result, formData), status: 422 });
528
551
  }
529
552
  args = [v.value];
530
553
  }
@@ -634,10 +657,11 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
634
657
  }
635
658
 
636
659
  // FAILURE: re-render the SAME page with the action result available on
637
- // ctx.actionData, status 422. Repopulation is the page author's job (native
638
- // `value=${actionData.values?.field}`).
660
+ // ctx.actionData, status 422. `actionData.values` carries every submitted
661
+ // text field (#1581), so `value=${actionData?.values?.field}` refills a field
662
+ // whether or not the action echoed it.
639
663
  const status = typeof result.status === 'number' && result.status >= 400 ? result.status : 422;
640
- const page = await ssrPage(route, params, url, { ...ssrOpts, req, actionData: result, status });
664
+ const page = await ssrPage(route, params, url, { ...ssrOpts, req, actionData: withSubmittedValues(result, formData), status });
641
665
  // A failure result can still have mutated (a partial write, then a rejected
642
666
  // second step), so the eviction above applies here too and this response, not
643
667
  // being a redirect, is one the router can actually read the header off.