@webjsdev/server 0.8.59 → 0.8.60

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/src/js-scan.js CHANGED
@@ -632,28 +632,46 @@ export function matchClosingParenthesis(s, start) {
632
632
  * `}` inside `'…'`, `"…"`, or backtick templates don't decrement depth.
633
633
  * Returns -1 if no balanced brace is found.
634
634
  *
635
+ * A template hole is a CODE context nested inside a template, not a brace in
636
+ * the enclosing block, so it gets its own frame on the stack: `${` pushes,
637
+ * and the `}` that returns that frame to depth zero pops back into the
638
+ * template rather than counting toward the block being matched. An earlier
639
+ * version incremented the outer depth at `${` and then never decremented it
640
+ * (the closing `}` arrived while still in template state), so depth could
641
+ * never return to zero and a class body holding `` html`…${x}…` `` was
642
+ * unmatchable. Every caller passes a masked source in which holes are already
643
+ * blanked, so the bug is invisible until one passes raw source.
644
+ *
635
645
  * @param {string} s
636
646
  * @param {number} start
637
647
  */
638
648
  export function matchClosingBrace(s, start) {
639
- let depth = 1;
649
+ // Innermost first. `tpl` frames are template literals (no brace counting);
650
+ // `!tpl` frames are code, each with its own depth.
651
+ /** @type {Array<{ tpl: boolean, depth: number }>} */
652
+ const stack = [{ tpl: false, depth: 1 }];
640
653
  let i = start;
641
- let str = ''; // '', "'", '"', or backtick
642
654
  while (i < s.length) {
655
+ const top = stack[stack.length - 1];
643
656
  const c = s[i];
644
- if (str) {
657
+ if (top.tpl) {
645
658
  if (c === '\\') { i += 2; continue; }
646
- if (c === str) str = '';
647
- else if (str === '`' && c === '$' && s[i + 1] === '{') {
648
- // template hole, count its closing `}` toward our brace depth.
649
- depth++;
650
- i += 2;
651
- continue;
652
- }
659
+ if (c === '`') { stack.pop(); i++; continue; }
660
+ if (c === '$' && s[i + 1] === '{') { stack.push({ tpl: false, depth: 1 }); i += 2; continue; }
653
661
  i++;
654
662
  continue;
655
663
  }
656
- if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
664
+ if (c === "'" || c === '"') {
665
+ i++;
666
+ while (i < s.length) {
667
+ if (s[i] === '\\') { i += 2; continue; }
668
+ const d = s[i];
669
+ i++;
670
+ if (d === c || d === '\n') break; // closed, or unterminated at EOL
671
+ }
672
+ continue;
673
+ }
674
+ if (c === '`') { stack.push({ tpl: true, depth: 0 }); i++; continue; }
657
675
  if (c === '/' && s[i + 1] === '/') { // line comment
658
676
  while (i < s.length && s[i] !== '\n') i++;
659
677
  continue;
@@ -664,8 +682,18 @@ export function matchClosingBrace(s, start) {
664
682
  i += 2;
665
683
  continue;
666
684
  }
667
- if (c === '{') depth++;
668
- else if (c === '}') { depth--; if (depth === 0) return i; }
685
+ if (c === '{') { top.depth++; i++; continue; }
686
+ if (c === '}') {
687
+ top.depth--;
688
+ // Depth zero closes this frame: the outermost one is the answer, an inner
689
+ // one is a template hole ending and hands control back to its template.
690
+ if (top.depth === 0) {
691
+ if (stack.length === 1) return i;
692
+ stack.pop();
693
+ }
694
+ i++;
695
+ continue;
696
+ }
669
697
  i++;
670
698
  }
671
699
  return -1;
package/src/ssr.js CHANGED
@@ -6,7 +6,7 @@ import { withBasePath } from './base-path.js';
6
6
  import { withAssetHash } from './asset-hash.js';
7
7
  import { jsonForScriptTag } from './script-tag-json.js';
8
8
  import { transitiveDeps, bareImports } from './module-graph.js';
9
- import { seedingEnabled, collectSeeds, buildSeedScript } from './action-seed.js';
9
+ import { seedingEnabled, collectSeeds, buildSeedScript, SEED_DROP_BLOCK } from './action-seed.js';
10
10
  import { BUFFERED_MARKER, STREAM_MARKER } from './conditional-get.js';
11
11
  import {
12
12
  readRevalidate,
@@ -57,7 +57,15 @@ export async function ssrPage(route, params, url, opts) {
57
57
  revalidateSeconds = readRevalidate(pageMod);
58
58
  if (revalidateSeconds !== null) {
59
59
  const hit = await readHtmlCache(url);
60
- if (hit) return cachedHtmlResponse(hit, opts.req, url);
60
+ if (hit) {
61
+ const cached = cachedHtmlResponse(hit, opts.req, url);
62
+ // A cache hit returns before any seed work runs, so it would otherwise
63
+ // report nothing at all, which reads as "seeding is broken" when it is
64
+ // really the cache answering (#1309). The seed block rides INSIDE the
65
+ // cached bytes, so the seeds are exactly as fresh as the HTML.
66
+ if (opts.dev) cached.headers.set('X-Webjs-Seed', 'html-cache');
67
+ return cached;
68
+ }
61
69
  }
62
70
  } catch {
63
71
  // A load / store failure falls through to a normal fresh render: the
@@ -288,9 +296,33 @@ export async function ssrPage(route, params, url, opts) {
288
296
  // block (those slow regions keep the stale-while-revalidate refetch). An
289
297
  // empty collector yields '' so the output stays byte-identical.
290
298
  let outBody = streamBody;
291
- if (seedCollector && suspenseCtx.pending.length === 0) {
292
- const seedScript = await buildSeedScript(seedCollector);
299
+ // Dev-only seeding diagnostics (#1309). `off` (seeding disabled) is kept
300
+ // DISTINCT from `collected=0` on purpose: the counting lives with the
301
+ // collector rather than behind the seed gate, so a seeding-DISABLED app
302
+ // never looks like a seeding-BROKEN one.
303
+ let seedHeader = 'off';
304
+ const streamed = suspenseCtx.pending.length > 0;
305
+ if (seedCollector && streamed) {
306
+ // A streamed render's deferred boundaries resolve AFTER the first flush,
307
+ // so their results cannot ride this block and none is emitted in prod. In
308
+ // DEV emit the marker alone, so the client reports the CAUSE instead of
309
+ // leaving the developer to guess why every action call went to the network.
310
+ seedHeader = `collected=${seedCollector.size}, emitted=0, streamed`;
311
+ if (opts.dev) {
312
+ const marker = await buildSeedScript(null, { dev: true, reason: 'streamed' });
313
+ if (marker) outBody = streamBody + marker;
314
+ }
315
+ } else if (seedCollector) {
316
+ const seedScript = await buildSeedScript(seedCollector, { dev: opts.dev });
293
317
  if (seedScript) outBody = streamBody + seedScript;
318
+ // `emitted` differs from `collected` exactly when the serializer threw and
319
+ // dropped the whole block, which is otherwise a completely invisible
320
+ // failure. In DEV that throw still emits a marker-only block, so the
321
+ // browser can name the cause; it carries no seeds, so it must NOT count as
322
+ // emitted or the header would report a total success on the very failure
323
+ // it exists to expose.
324
+ const dropped = seedScript === SEED_DROP_BLOCK;
325
+ seedHeader = `collected=${seedCollector.size}, emitted=${seedScript && !dropped ? seedCollector.size : 0}`;
294
326
  }
295
327
  const res = streamingHtmlResponse(
296
328
  prefix,
@@ -307,6 +339,11 @@ export async function ssrPage(route, params, url, opts) {
307
339
  nonce,
308
340
  opts.dev,
309
341
  );
342
+ // Dev-only seeding diagnostics (#1309). A miss is indistinguishable from a
343
+ // hit from the outside, so an app whose seeding silently broke looks exactly
344
+ // like one where it works. Dev only: a production header would publish how
345
+ // many server calls a page made, for no benefit.
346
+ if (opts.dev) res.headers.set('X-Webjs-Seed', seedHeader);
310
347
  // REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the
311
348
  // outer-layout chrome, so these bytes are only valid for a request that
312
349
  // sent a matching `have`. Left shared-cacheable, a CDN edge could store the
@@ -2072,7 +2109,7 @@ function reachedVendorSpecifiers(graph, entryFiles, componentUrls, appDir, elida
2072
2109
  * @param {string} prefix
2073
2110
  * @param {string} bodyHtml
2074
2111
  * @param {string} closer
2075
- * @param {{ pending: {id: string, promise: Promise<unknown>, formScope?: 'none'|'unbound'|'bound'|'unknown'}[], nextId: number }} ctx
2112
+ * @param {{ pending: {id: string, promise: Promise<unknown>}[], nextId: number }} ctx
2076
2113
  * @param {number} status
2077
2114
  * @param {Request | undefined} req
2078
2115
  * @param {URL | undefined} url
@@ -2128,15 +2165,14 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
2128
2165
  try {
2129
2166
  const resolved = await p.promise;
2130
2167
  const sub = { pending: [], nextId: ctx.nextId, dev: ctx.dev };
2131
- // Carry the boundary's form scope (#1207). This is a fresh scan
2132
- // that cannot see the shell the boundary sits in, so without it
2133
- // a `<button formaction=${fn}>` inside a bound form's boundary
2134
- // reads as form-less and is refused, and the catch below turns
2135
- // that into an empty boundary on a 200 in production. 'unknown'
2136
- // when the entry predates the field: defer rather than refuse.
2137
- const html = await renderToString(resolved, {
2138
- ssr: true, suspenseCtx: sub, formScope: p.formScope || 'unknown',
2139
- });
2168
+ // A fresh scan that cannot see the shell the boundary sits in.
2169
+ // That used to require carrying the boundary's form scope
2170
+ // (#1207), or a `<button formaction=${fn}>` inside a bound
2171
+ // form's boundary read as form-less, was refused, and the catch
2172
+ // below turned it into an empty boundary on a 200 in
2173
+ // production. #1307 made a bound submitter self-sufficient, so
2174
+ // there is nothing left to carry.
2175
+ const html = await renderToString(resolved, { ssr: true, suspenseCtx: sub });
2140
2176
  ctx.nextId = sub.nextId;
2141
2177
  for (const n of sub.pending) ctx.pending.push(n);
2142
2178
  return { id: p.id, html };