Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 43 additions & 16 deletions packages/core/src/render-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,14 @@ import { cspNonce } from './csp-nonce.js';
*/
export async function renderToString(value, opts = { ssr: true }) {
const ctx = opts && opts.suspenseCtx;
// The server `dev` flag drives prod-silence of SSR error states (#483).
// `opts.dev` wins; else inherit a `dev` already stamped on the ctx (so a
// streamed sub-context inherits it); back-fill the ctx so downstream renders
// sharing it see the same flag. Undefined stays undefined (NODE_ENV fallback).
const dev = opts && opts.dev !== undefined ? opts.dev : ctx && ctx.dev;
if (ctx && ctx.dev === undefined && dev !== undefined) ctx.dev = dev;
const html = await render(value, ctx);
return opts && opts.ssr === false ? html : await injectDSD(html, ctx);
return opts && opts.ssr === false ? html : await injectDSD(html, ctx, [], dev);
}

/**
Expand Down Expand Up @@ -377,12 +383,21 @@ function isProd() {
* Dev surfaces the tag + message loudly so the failure is obvious; prod
* renders an empty (silent, isolated) element so no internal detail leaks.
*
* The prod-silence signal is the SERVER's `dev` flag, threaded through the SSR
* render context (#483). webjs keys prod on the CLI `dev` flag, not `NODE_ENV`,
* and `webjs start` does not export `NODE_ENV=production`, so a bare prod launch
* would otherwise leak the message. When `dev` is undefined (a context-free
* `renderToString` with no server signal, e.g. a bare unit test) it falls back
* to `isProd()` / `NODE_ENV`, preserving the prior behaviour for that path.
*
* @param {string} tag
* @param {Error} err
* @param {boolean} [dev] server dev flag; undefined falls back to NODE_ENV
* @returns {unknown} a TemplateResult (dev) or '' (prod)
*/
function defaultSSRErrorTemplate(tag, err) {
if (isProd()) return '';
function defaultSSRErrorTemplate(tag, err, dev) {
const surface = dev === undefined ? !isProd() : !!dev;
if (!surface) return '';
const msg = err && err.message ? err.message : String(err);
return html`<div data-webjs-error="${tag}" style="border:1px solid #f5c2c7;background:#f8d7da;color:#842029;padding:8px 12px;border-radius:6px;font:13px/1.4 system-ui,sans-serif">
<strong>&lt;${tag}&gt; failed to render</strong>
Expand All @@ -397,15 +412,18 @@ function defaultSSRErrorTemplate(tag, err) {
*
* @param {string} html
* @param {SuspenseCtx} [ctx]
* @param {any[]} [ancestors]
* @param {boolean} [dev] server dev flag, threaded to the per-component error
* template for prod-silence (#483); undefined falls back to NODE_ENV
* @returns {Promise<string>}
*/
async function injectDSD(html, ctx, ancestors = []) {
async function injectDSD(html, ctx, ancestors = [], dev) {
Comment thread
vivek7405 marked this conversation as resolved.
// Resolve <webjs-suspense> boundaries first (#471): in a streaming context
// each becomes a fallback placeholder now, with its children pushed for
// out-of-order streaming; without a streaming context the children render
// inline (blocking). Run before the custom-element walk so a streamed
// boundary's children leave the main flow and are not double-processed.
html = await processSuspenseElements(html, ctx, ancestors);
html = await processSuspenseElements(html, ctx, ancestors, dev);
const tags = allTags();
if (!tags.length) return html;
// Sort longest tag name first so the regex alternation tries the most
Expand Down Expand Up @@ -487,7 +505,7 @@ async function injectDSD(html, ctx, ancestors = []) {
// Shadow DOM: native <slot> stays as-is in the DSD template. The
// browser handles projection from the host's light-DOM children
// into the shadow tree natively. No framework substitution here.
const innerProcessed = await injectDSD(rawInner, ctx, [...ancestors, instance]);
const innerProcessed = await injectDSD(rawInner, ctx, [...ancestors, instance], dev);
const rawStyles = /** @type any */ (Cls).styles;
const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
const styleStr = stylesToString(styleList);
Expand Down Expand Up @@ -550,7 +568,7 @@ async function injectDSD(html, ctx, ancestors = []) {
}
const partitioned = partitionAuthoredBySlot(authoredInner);
const innerWithSlots = substituteSlotsInRender(rawInner, partitioned);
const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance]);
const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance], dev);
edits.push({
start: m.index,
end: closeEnd,
Expand Down Expand Up @@ -582,10 +600,10 @@ async function injectDSD(html, ctx, ancestors = []) {
if (instance && typeof instance.renderError === 'function') {
errTpl = instance.renderError(err);
}
if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err);
if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err, dev);
errorInner = await render(errTpl, ctx);
if (errorInner.trim()) {
errorInner = await injectDSD(errorInner, ctx, instance ? [...ancestors, instance] : ancestors);
errorInner = await injectDSD(errorInner, ctx, instance ? [...ancestors, instance] : ancestors, dev);
}
} catch (renderErrorThrew) {
console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
Expand Down Expand Up @@ -691,9 +709,11 @@ function isVoidElement(tag) {
* @param {string} html
* @param {SuspenseCtx} [ctx]
* @param {any[]} [ancestors]
* @param {boolean} [dev] server dev flag for prod-silence of a throwing
* component in an inline (ctx-absent) boundary (#483)
* @returns {Promise<string>}
*/
async function processSuspenseElements(html, ctx, ancestors = []) {
async function processSuspenseElements(html, ctx, ancestors = [], dev) {
Comment thread
vivek7405 marked this conversation as resolved.
if (html.indexOf('<webjs-suspense') === -1) return html;
const OPEN = /<webjs-suspense((?:"[^"]*"|'[^']*'|[^>])*?)>/i;
let result = '';
Expand Down Expand Up @@ -732,7 +752,7 @@ async function processSuspenseElements(html, ctx, ancestors = []) {
ctx.pending.push({ id, promise: Promise.resolve(unsafeHTML(inner)) });
result += `<webjs-suspense id="${id}">${fallbackHtml}</webjs-suspense>`;
} else {
const innerProcessed = await injectDSD(inner, ctx, ancestors);
const innerProcessed = await injectDSD(inner, ctx, ancestors, dev);
result += `<webjs-suspense>${innerProcessed}</webjs-suspense>`;
}
rest = afterClose;
Expand Down Expand Up @@ -1130,6 +1150,11 @@ function consumePropAttrs(attrs) {
*/
export function renderToStream(value, opts = { ssr: true }) {
const ctx = opts && opts.suspenseCtx;
// Server dev flag for prod-silence of SSR error states (#483), same sourcing
// as renderToString: opts.dev wins, else inherit from the ctx, else undefined
// (NODE_ENV fallback). Back-fill the ctx so the streamed sub-renders share it.
const dev = opts && opts.dev !== undefined ? opts.dev : ctx && ctx.dev;
if (ctx && ctx.dev === undefined && dev !== undefined) ctx.dev = dev;
return new ReadableStream({
async start(controller) {
try {
Expand All @@ -1141,13 +1166,13 @@ export function renderToStream(value, opts = { ssr: true }) {
// the full HTML), then enqueue the result. This matches the
// semantics of renderToString but still gives us a stream.
const html = await render(value, ctx);
const full = await injectDSD(html, ctx);
const full = await injectDSD(html, ctx, [], dev);
controller.enqueue(full);
}

// Stream resolved Suspense boundaries after the main content.
if (ctx && ctx.pending.length) {
await streamSuspenseBoundaries(ctx, controller);
await streamSuspenseBoundaries(ctx, controller, dev);
}
controller.close();
} catch (err) {
Expand Down Expand Up @@ -1404,8 +1429,10 @@ async function streamTemplate(tr, ctx, controller) {
*
* @param {SuspenseCtx} ctx
* @param {ReadableStreamDefaultController<string>} controller
* @param {boolean} [dev] server dev flag for prod-silence of a rejected
* streamed boundary (#483); undefined falls back to NODE_ENV
*/
async function streamSuspenseBoundaries(ctx, controller) {
async function streamSuspenseBoundaries(ctx, controller, dev) {
Comment thread
vivek7405 marked this conversation as resolved.
// Resolve the per-request nonce once per call. The provider in
// @webjsdev/server sources it from AsyncLocalStorage; outside a
// request scope (or in the browser) the helper returns '' and we
Expand All @@ -1421,7 +1448,7 @@ async function streamSuspenseBoundaries(ctx, controller) {
try {
const resolved = await promise;
const html = await render(resolved, ctx);
const full = await injectDSD(html, ctx);
const full = await injectDSD(html, ctx, [], dev);
controller.enqueue(
`<template data-webjs-resolve="${id}">${full}</template>` +
`<script${nonceAttr}>` +
Expand All @@ -1441,7 +1468,7 @@ async function streamSuspenseBoundaries(ctx, controller) {
// error render itself throwing) leaves the fallback in place.
try {
const e = err instanceof Error ? err : new Error(String(err));
const errHtml = await injectDSD(await render(defaultSSRErrorTemplate('webjs-suspense', e), ctx), ctx);
const errHtml = await injectDSD(await render(defaultSSRErrorTemplate('webjs-suspense', e, dev), ctx), ctx, [], dev);
controller.enqueue(
`<template data-webjs-resolve="${id}">${errHtml}</template>` +
`<script${nonceAttr}>` +
Expand Down
10 changes: 5 additions & 5 deletions packages/server/src/ssr.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export async function ssrPage(route, params, url, opts) {
const metadata = await collectMetadata(route, ctx, opts.dev);

try {
const suspenseCtx = { pending: [], nextId: 1, usedComponents: new Set() };
const suspenseCtx = { pending: [], nextId: 1, usedComponents: new Set(), dev: opts.dev };
// Parse the partial-nav "have" header from the client. The header
// lists comma-separated marker paths the client already has rendered
// in its DOM. The server walks the target route's layout chain
Expand Down Expand Up @@ -234,7 +234,7 @@ export async function ssrPage(route, params, url, opts) {
const mod = await loadModule(route.errors[i], opts.dev);
if (!mod.default) continue;
const tree = await mod.default({ ...ctx, error: err });
const body = await renderToString(tree);
const body = await renderToString(tree, { ssr: true, dev: opts.dev });
const moduleUrls = [route.file, ...route.layouts].map((f) => toUrlPath(f, opts.appDir));
const html = wrapInDocument(body, { metadata, moduleUrls, dev: opts.dev, nonce: errNonce });
return htmlResponse(html, 500, opts.req, url);
Expand Down Expand Up @@ -334,7 +334,7 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
if (notFoundFile) {
try {
const mod = await loadModule(notFoundFile, opts.dev);
if (mod.default) body = await renderToString(await mod.default({}));
if (mod.default) body = await renderToString(await mod.default({}), { ssr: true, dev: opts.dev });
} catch (e) {
body = `<h1>404: Not found</h1><pre>${escapeHtml(String(e))}</pre>`;
}
Expand Down Expand Up @@ -431,7 +431,7 @@ async function loadingTemplates(route, ctx, dev) {
const mod = await loadModule(file, dev);
if (!mod.default) continue;
const tree = await mod.default(ctx);
const html = await renderToString(tree, { ssr: true });
const html = await renderToString(tree, { ssr: true, dev });
const segmentPath = loadingSegmentPath(file);
parts.push(`<template id="wj-loading:${segmentPath}">${html}</template>`);
} catch { /* skip broken loading file */ }
Expand Down Expand Up @@ -1503,7 +1503,7 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
batch.map(async (p) => {
try {
const resolved = await p.promise;
const sub = { pending: [], nextId: ctx.nextId };
const sub = { pending: [], nextId: ctx.nextId, dev: ctx.dev };
const html = await renderToString(resolved, { ssr: true, suspenseCtx: sub });
ctx.nextId = sub.nextId;
for (const n of sub.pending) ctx.pending.push(n);
Expand Down
121 changes: 121 additions & 0 deletions packages/server/test/dev/ssr-error-prod-silence.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* SSR error prod-silence keys on the server `dev` flag, not NODE_ENV (#483).
*
* A component whose `render()` throws during SSR is isolated to a
* component-scoped error state (`defaultSSRErrorTemplate`). Dev surfaces the
* message; prod stays silent. The prod signal is the server `dev` flag threaded
* through the SSR render context, NOT `process.env.NODE_ENV` (which `webjs
* start` does not export, so a bare prod launch would otherwise leak). A
* context-free `renderToString` with no dev signal falls back to NODE_ENV.
*/
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

import { createRequestHandler } from '../../src/dev.js';
import { renderToString } from '../../../core/src/render-server.js';
import { html } from '../../../core/src/html.js';
import { WebComponent } from '../../../core/src/component.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const CORE_INDEX = pathToFileURL(resolve(__dirname, '../../../core/index.js')).toString();

const SECRET = 'SECRET-DB-ERROR-9c2f1';

// A component whose render throws, so the SSR walker isolates it to a
// component-scoped error state via defaultSSRErrorTemplate.
const THROW_CARD =
`import { WebComponent, html } from ${JSON.stringify(CORE_INDEX)};\n` +
`export class ThrowCard extends WebComponent {\n` +
` render() { throw new Error('${SECRET}'); }\n` +
`}\n` +
`ThrowCard.register('throw-card');\n`;

const PAGE =
`import { html } from ${JSON.stringify(CORE_INDEX)};\n` +
`import './throw-card.ts';\n` +
`export default function P() {\n` +
` return html\`<main><h1>page</h1><throw-card></throw-card></main>\`;\n` +
`}\n`;

let tmpRoot;
let savedNodeEnv;
before(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'webjs-ssr-err-'));
// Prove the signal is `dev`, not NODE_ENV, by running with NODE_ENV unset.
savedNodeEnv = process.env.NODE_ENV;
delete process.env.NODE_ENV;
});
after(() => {
rmSync(tmpRoot, { recursive: true, force: true });
if (savedNodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = savedNodeEnv;
});

function makeApp() {
const appDir = mkdtempSync(join(tmpRoot, 'app-'));
mkdirSync(join(appDir, 'app'), { recursive: true });
writeFileSync(join(appDir, 'app', 'page.ts'), PAGE);
writeFileSync(join(appDir, 'app', 'throw-card.ts'), THROW_CARD);
return appDir;
}

test('PROD (dev:false, NODE_ENV unset): a thrown component render does NOT leak the message', async () => {
const app = await createRequestHandler({ appDir: makeApp(), dev: false });
const res = await app.handle(new Request('http://x/'));
const body = await res.text();
assert.ok(body.includes('page'), 'sibling content rendered (the throw is isolated)');
assert.ok(
!body.includes(SECRET),
`prod must NOT leak the component error message even with NODE_ENV unset; body:\n${body}`,
);
});

test('DEV (dev:true): a thrown component render surfaces the message', async () => {
const app = await createRequestHandler({ appDir: makeApp(), dev: true });
const res = await app.handle(new Request('http://x/'));
const body = await res.text();
assert.ok(body.includes(SECRET), `dev must surface the component error message; body:\n${body}`);
});

test('CORE: renderToString gates on suspenseCtx.dev, independent of NODE_ENV', async () => {
Comment thread
vivek7405 marked this conversation as resolved.
// This is the reliable counterfactual for the leak (#483). It drives a
// throwing component straight through renderToString with the suspenseCtx
// that ssr.js stamps, NODE_ENV deleted, so NO env signal masks the result:
// dev:false MUST stay silent (reverting the dev-gating to isProd() would leak
// here because isProd() is false with NODE_ENV unset), dev:true MUST surface.
class SuspenseThrow extends WebComponent {
render() { throw new Error('SUSPCTX-SECRET'); }
}
SuspenseThrow.register('suspctx-throw');
const tpl = html`<div><suspctx-throw></suspctx-throw></div>`;
delete process.env.NODE_ENV;

const prod = await renderToString(tpl, { ssr: true, suspenseCtx: { pending: [], nextId: 1, dev: false } });
assert.ok(!prod.includes('SUSPCTX-SECRET'), 'dev:false stays silent with NODE_ENV unset (the leak-prevention)');

const dev = await renderToString(tpl, { ssr: true, suspenseCtx: { pending: [], nextId: 1, dev: true } });
assert.ok(dev.includes('SUSPCTX-SECRET'), 'dev:true surfaces the message');
});

test('context-free renderToString falls back to NODE_ENV', async () => {
class CtxFreeThrow extends WebComponent {
render() { throw new Error('CTXFREE-SECRET'); }
}
CtxFreeThrow.register('ctxfree-throw');
const tpl = html`<div><ctxfree-throw></ctxfree-throw></div>`;

// No dev signal + NODE_ENV unset (not 'production') means not prod, so surface.
delete process.env.NODE_ENV;
const dev = await renderToString(tpl);
assert.ok(dev.includes('CTXFREE-SECRET'), 'no dev signal + non-prod NODE_ENV surfaces the message');

// No dev signal + NODE_ENV=production means prod, so stay silent.
process.env.NODE_ENV = 'production';
const prod = await renderToString(tpl);
assert.ok(!prod.includes('CTXFREE-SECRET'), 'no dev signal + NODE_ENV=production stays silent');
delete process.env.NODE_ENV;
});
Loading