diff --git a/.github/skills/pre-push/SKILL.md b/.github/skills/pre-push/SKILL.md index 8b5de4472..32159e0f3 100644 --- a/.github/skills/pre-push/SKILL.md +++ b/.github/skills/pre-push/SKILL.md @@ -18,7 +18,7 @@ Run these steps in order before pushing. - Run `yarn test:all` to verify typechecks (`typecheck:all`), web standards (`check:all`), unit tests (`unit:all`), E2E tests (`e2e:all`), and Lighthouse audits (`docs:lighthouse`) across pkg, docs, create, extensions/vscode-bascik, adapters/cloudflare, lsp, and skills-tests (the public skills' tests). `e2e:all` must run static build, live dev server, production HTTP/1.1, and production HTTP/2 configurations, plus the docs and Cloudflare adapter suites. The exec lanes are not part of `e2e:all`; when a change touches `pipeline.exec`, exec scripts, or build/dev phase ordering, also run `yarn pkg:e2e:dev:exec`, `yarn pkg:e2e:exec:policy`, and `yarn pkg:e2e:exec:build` (CI runs all three). Playwright E2E tests are configured with concise `--reporter=line` output. - Lighthouse CLI (`docs:lighthouse`) runs the same underlying engine (axe-core / Chrome Lighthouse) as DevTools. Review Lighthouse output and reports in `.lighthouseci/`. - Parse test results efficiently: check pass/fail status first. If any audit or test fails or drops below threshold, inspect and fix all flagged issues before pushing—including ARIA role/children attributes, color contrast ratios, console errors, broken schema scripts, and performance bottlenecks. -5. `yarn coverage:all` +5. `yarn coverage:all`. When the code on the branch has already passed CI and only the `test-coverage.json` files changed, commit them on their own with `[skip ci]` in the commit message, so GitHub does not rerun every suite for numbers alone. Merge the pull request with a merge commit, not a squash: a squash message that repeats `[skip ci]` would skip the deploy on `main`. GitHub looks for the marker anywhere in the message of the newest commit in a push, so never write it out in another commit's message, even to describe it: that push runs no workflow at all. 6. Review changes in `docs/content/` and `pkg/src/` on this branch against the public agent skills in `skills/` (each `SKILL.md` and its scripts). Update the matching skill to reflect any new, changed, or removed APIs, defaults, and patterns. Keep entries concise and practical - only change what the review reveals needs changing. If a skill script changed, run it against `pkg/e2e/` or `docs/`, and run `yarn skills:test` (its tests live in `skills-tests/`, not `docs/`). Run `codespell skills/` since `yarn check:spelling` does not cover it. Confirm which skills (and sections) were added, changed, or removed, whether coverage files were updated, and that codespell, webhint, and static analysis checks passed cleanly. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b3f55e6c..f5e9a28f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,14 @@ a time. This candidate ships `@bascik/bascik`, `create-bascik` and returned generator into a `ScriptChunkSource`; and `scriptChunkOutput`, `scriptChunkIterator` and `collectScriptJobOutput` build on it. A `ScriptJobRunner` that returns strings keeps working unchanged. +- `@bascik/bascik/runtime` (for adapter authors): `scriptJobOutput` and + `settleScriptOutput` turn a script's settled result into its output the way the + built-in server and both official adapters now do, through one function: printed, + stripped of ANSI escapes, scoped to its component, refused when it is not markup, + and reported under `scripts.onServerScriptError`. An adapter passes a + `ScriptOutputPolicy` with its own log and failure description instead of + re-implementing those rules. See + [the custom adapter tutorial](https://bascik.dev/deployment/custom-adapters#step-4-write-the-request-handler). ### Changed diff --git a/adapters/cloudflare/src/runtime.test.ts b/adapters/cloudflare/src/runtime.test.ts index b3b296da5..9bc1a03f0 100644 --- a/adapters/cloudflare/src/runtime.test.ts +++ b/adapters/cloudflare/src/runtime.test.ts @@ -1177,3 +1177,104 @@ describe("createCloudflareWorker: a script that yields", () => { expect(logged.join("\n")).toContain("server row failed"); }); }); + +describe("createCloudflareWorker: the exact bytes of a report (shared output policy)", () => { + const OWNER = "stream script in src/components/book-card/book-card.html:12"; + const failingPage = (handler: unknown, owner?: string): GraphPage => { + const scripted = page("/account", handler); + if (owner) scripted.jobs.server_script_a!.owner = owner; + return scripted; + }; + + it('a failed script under "warn" logs the route line, the stack, then its owner, and nothing else', async () => { + const logged: string[] = []; + const boom = new Error("boom"); + const res = await worker( + graphOf({ + onServerScriptError: "warn", + pages: { + "/account": failingPage(() => { + throw boom; + }, OWNER), + }, + }), + logged, + ).fetch(get("/account"), {}, ctx()); + expect(await res.text()).toBe("
"); + expect(logged).toEqual([ + `[bascik] server script error at "/account":\n${boom.stack}\n in the ${OWNER}`, + ]); + }); + + it("a page script has no owner, so its report ends with the stack", async () => { + const logged: string[] = []; + const boom = new Error("boom"); + await worker( + graphOf({ + onServerScriptError: "warn", + pages: { + "/account": failingPage(() => { + throw boom; + }), + }, + }), + logged, + ).fetch(get("/account"), {}, ctx()); + expect(logged).toEqual([ + `[bascik] server script error at "/account":\n${boom.stack}`, + ]); + }); + + it('a failed script under "error" logs that same report once and answers with the 500 page', async () => { + const logged: string[] = []; + const boom = new Error("boom"); + const res = await worker( + graphOf({ + pages: { + "/account": failingPage(() => { + throw boom; + }, OWNER), + }, + }), + logged, + ).fetch(get("/account"), {}, ctx()); + expect(res.status).toBe(500); + expect(logged).toEqual([ + `[bascik] server script error at "/account":\n${boom.stack}\n in the ${OWNER}`, + ]); + }); + + it("a refused value's warning is the shared production message, word for word", async () => { + const logged: string[] = []; + await worker( + graphOf({ + pages: { "/account": failingPage(() => ({ not: "markup" }), OWNER) }, + }), + logged, + ).fetch(get("/account"), {}, ctx()); + expect(logged).toEqual([ + `[bascik] server script error at "/account":\n` + + `The ${OWNER} returned an object ([object Object]), which is not markup. ` + + "A server or stream script returns a string, an html`...` value, or an array of them (joined with no separator). " + + "Return markup built from its fields, for example html`

${item.name}

`.\n" + + "The slot is empty. This warning is logged once per script until the server restarts.", + ]); + }); + + it("terminal color codes are stripped from a script's output unless the graph says to keep them", async () => { + const colored = () => "\u001b[31mred\u001b[0m"; + const stripped = await worker( + graphOf({ pages: { "/account": failingPage(colored) } }), + ).fetch(get("/account"), {}, ctx()); + expect(await stripped.text()).toBe("
red
"); + const kept = await worker( + graphOf({ + stripAnsi: false, + pages: { "/account": failingPage(colored) }, + }), + ).fetch(get("/account"), {}, ctx()); + expect(await kept.text()).toBe( + "
\u001b[31mred\u001b[0m
", + ); + }); +}); diff --git a/adapters/cloudflare/src/runtime.ts b/adapters/cloudflare/src/runtime.ts index 5b3aa49d6..6477f5be4 100644 --- a/adapters/cloudflare/src/runtime.ts +++ b/adapters/cloudflare/src/runtime.ts @@ -33,13 +33,11 @@ import { componentExecution } from "./component-host.js"; import { - applyOutputScope, collectScriptJobOutput, - decodeOutputScope, composeBufferedResponse, dispatchApiHandler, invokeLoadedWithDeadline, - scriptChunkOutput, + scriptJobOutput, scriptTimeoutError, streamComposedResponse, type ApiHandlerContext, @@ -47,7 +45,6 @@ import { type ExecutionPlan, type ExecutionSegment, type PlatformContext, - type InvokeResult, type RenderHost, type ScriptJobOutput, type ScriptOutputRunner, @@ -62,9 +59,6 @@ import { InvalidApiPathError, matchApiRoute, pageLookupCandidates, - stripAnsiEscapeCodes, - serverScriptOutput, - serverScriptOutputErrorMessage, contentSecurityPolicyHeader, DEFAULT_RESPONSE_SECURITY, type ApiRouteDefinition, @@ -74,19 +68,6 @@ import { type ResponseSecurity, } from "@bascik/bascik/runtime"; -/** - * A job's output with its component's element classes applied, when the job - * carries an output scope, as `runServerScriptJob` does on Node. It lives - * here, not in component-host.ts, because page jobs carry a scope too (that - * of a component placed statically on the page) whether or not the manifest - * has anything renderable: a Worker built with the component-host stub must - * still scope them. Both helpers are portable (no `node:crypto`). - */ -const scopeJobOutput = (job: GraphScriptJob, output: string): string => { - const scope = decodeOutputScope(job.outputScope); - return scope ? applyOutputScope(output, scope) : output; -}; - // ─── Site graph contract (emitted by serverless-artifacts.ts) ──────────────── /** A request-time script job inside a dynamic page template. */ @@ -321,72 +302,31 @@ const runGraphJobOutput = async ( // Node's message, so a log reads the same on both hosts. { timeoutMs, signal, timeoutError: scriptTimeoutError, chunks: true }, ); - if (invoked.ok && invoked.source) { - return scriptChunkOutput( - invoked.source, - (value) => - settle({ ok: true, value, timedOut: false, aborted: false }, true), - (failed) => settle(failed, true), - ); - } - return settle(invoked, false); - - /** The output of one settled call or pull, by the policy above. */ - function settle(result: InvokeResult, yielded: boolean): string { - // The rule Node applies (F060): an array is joined with no separator; an - // object, a Response, an unawaited Promise in an array, a function or a - // symbol is refused. A Worker is production, so a refused return never - // fails the page: the slot is empty and, unless the policy is "ignore", - // the warning is logged once per script for the isolate's life. A throw - // from the author's own toString is the script failing, as on Node. - let printed: ReturnType | undefined; - let failure: Error | undefined; - if (result.ok) { - try { - printed = serverScriptOutput(result.value); - } catch (thrown) { - failure = thrown instanceof Error ? thrown : new Error(String(thrown)); - } - } - if (printed) { - if (printed.error) { - if (graph.onServerScriptError !== "ignore" && !warnedRefused.has(job)) { - warnedRefused.add(job); - log( - serverScriptOutputErrorMessage( - printed.error, - request.url, - // A page job carries no owner in the graph (only component jobs - // do); the route in the message names its page. - job.owner ?? `${job.mode} script`, - { production: true, yielded }, - ), - ); - } - return ""; - } - // scripts.stripAnsi off: the codes reach the page as printed. - return scopeJobOutput( - job, - graph.stripAnsi === false - ? printed.text - : stripAnsiEscapeCodes(printed.text), - ); - } - if (!failure && !result.ok && result.aborted && !result.timedOut) return ""; - const error = - failure ?? - (!result.ok ? result.error : undefined) ?? - new Error("Server script failed"); - // Node maps the stack to the authored file; here it names only the bundle, - // so a component job (a timeout, a throw or a missing default export deep - // inside a rendered tag) is placed by the owner the build recorded. - const where = job.owner ? `\n in the ${job.owner}` : ""; - const message = `[bascik] server script error at "${new URL(request.url).pathname}":\n${error.stack ?? error.message}${where}`; - if (graph.onServerScriptError === "error") throw new Error(message); - if (graph.onServerScriptError === "warn") log(message); - return ""; - } + // Printing, refusing, stripping, scoping and the onServerScriptError + // policy are the runtime's, shared with the Node server and the Netlify + // function, so every host sends the same bytes and words a mistake the + // same way. What is this host's own: a Worker is production, logs through + // `log`, remembers a script's first refused value for the isolate's life, + // and places a failure by the owner the build recorded (Node maps the + // stack to the authored file; here the stack names only the bundle). + return scriptJobOutput(invoked, { + onServerScriptError: graph.onServerScriptError, + production: true, + requestUrl: request.url, + // A page job carries no owner in the graph (only component jobs do); + // the route in the message names its page. + owner: job.owner ?? `${job.mode} script`, + stripAnsi: graph.stripAnsi, + outputScope: job.outputScope, + warn: log, + firstRefusal: () => { + if (warnedRefused.has(job)) return false; + warnedRefused.add(job); + return true; + }, + describeFailure: (error) => + `${error.stack ?? error.message}${job.owner ? `\n in the ${job.owner}` : ""}`, + }); }; /** `runGraphJobOutput` with a yielding script's values joined into one string. */ diff --git a/adapters/netlify/src/runtime.test.ts b/adapters/netlify/src/runtime.test.ts index 5cd1b53b8..c14542ed1 100644 --- a/adapters/netlify/src/runtime.test.ts +++ b/adapters/netlify/src/runtime.test.ts @@ -525,3 +525,105 @@ describe("createNetlifyHandler: a script that yields", () => { expect(await res.text()).not.toContain("
  • 1
  • "); }); }); + +describe("createNetlifyHandler: the exact bytes of a report (shared output policy)", () => { + const OWNER = "stream script in src/components/book-card/book-card.html:12"; + const scripted = (fn: () => unknown, owner?: string): GraphPage => { + const p = page({ jobs: { s1: job("s1", "server", fn) } }); + if (owner) (p.jobs.s1 as { owner?: string }).owner = owner; + return p; + }; + /** The `error` of every script.error line the handler logged. */ + const scriptErrors = (lines: string[]): string[] => + lines + .map((line) => { + try { + return JSON.parse(line) as { event?: string; error?: string }; + } catch { + return {}; + } + }) + .filter((entry) => entry.event === "script.error") + .map((entry) => String(entry.error)); + + it('a failed script under "warn" logs the route line, the stack, then its owner', async () => { + const lines: string[] = []; + const boom = new Error("boom"); + const res = await createNetlifyHandler( + graph( + scripted(() => { + throw boom; + }, OWNER), + ), + { + log: (line) => lines.push(line), + logEvent: () => {}, + logRequest: () => {}, + }, + )(get("/dashboard")); + expect(await res.text()).toBe("

    "); + expect(scriptErrors(lines)).toEqual([ + `[bascik] server script error at "/dashboard":\n${boom.stack}\n in the ${OWNER}`, + ]); + }); + + it("a page script has no owner, so its report ends with the stack", async () => { + const lines: string[] = []; + const boom = new Error("boom"); + await createNetlifyHandler( + graph( + scripted(() => { + throw boom; + }), + ), + { + log: (line) => lines.push(line), + logEvent: () => {}, + logRequest: () => {}, + }, + )(get("/dashboard")); + expect(scriptErrors(lines)).toEqual([ + `[bascik] server script error at "/dashboard":\n${boom.stack}`, + ]); + }); + + it("a refused value's warning is the shared production message, word for word, the same as the Worker's", async () => { + const lines: string[] = []; + await createNetlifyHandler( + graph(scripted(() => ({ not: "markup" }), OWNER)), + { + log: (line) => lines.push(line), + logEvent: () => {}, + logRequest: () => {}, + }, + )(get("/dashboard")); + expect(scriptErrors(lines)).toEqual([ + `[bascik] server script error at "/dashboard":\n` + + `The ${OWNER} returned an object ([object Object]), which is not markup. ` + + "A server or stream script returns a string, an html`...` value, or an array of them (joined with no separator). " + + "Return markup built from its fields, for example html`

    ${item.name}

    `.\n" + + "The slot is empty. This warning is logged once per script until the server restarts.", + ]); + }); + + it("terminal color codes are stripped from a script's output unless the graph says to keep them", async () => { + const colored = () => "\u001b[31mred\u001b[0m"; + const quiet = { log: () => {}, logEvent: () => {}, logRequest: () => {} }; + expect( + await ( + await createNetlifyHandler( + graph(scripted(colored)), + quiet, + )(get("/dashboard")) + ).text(), + ).toBe("

    red

    "); + expect( + await ( + await createNetlifyHandler( + graph(scripted(colored), { stripAnsi: false }), + quiet, + )(get("/dashboard")) + ).text(), + ).toBe("

    \u001b[31mred\u001b[0m

    "); + }); +}); diff --git a/adapters/netlify/src/runtime.ts b/adapters/netlify/src/runtime.ts index 096961b04..d1023a64c 100644 --- a/adapters/netlify/src/runtime.ts +++ b/adapters/netlify/src/runtime.ts @@ -29,11 +29,9 @@ import { componentExecution } from "./component-host.js"; import { - applyOutputScope, collectScriptJobOutput, composeBufferedResponse, contentSecurityPolicyHeader, - decodeOutputScope, dispatchApiHandler, dynamicPageHeaders, errorResponse, @@ -45,12 +43,9 @@ import { matchApiRoute, mergeHandlerHeaders, pageLookupCandidates, - scriptChunkOutput, + scriptJobOutput, scriptTimeoutError, - serverScriptOutput, - serverScriptOutputErrorMessage, streamComposedResponse, - stripAnsiEscapeCodes, stripRepresentationHeaders, type ApiHandlerContext, type ApiHandlerModule, @@ -59,7 +54,6 @@ import { type CspRouteHashes, type ExecutionPlan, type ExecutionSegment, - type InvokeResult, type PlatformContext, type RenderHost, type RequestRenderLimits, @@ -293,61 +287,26 @@ const runGraphJobOutput = async ( chunks: true, }, ); - if (invoked.ok && invoked.source) { - return scriptChunkOutput( - invoked.source, - (value) => - settle({ ok: true, value, timedOut: false, aborted: false }, true), - (failed) => settle(failed, true), - ); - } - return settle(invoked, false); - - /** The output of one settled call or pull, by the policy above. */ - function settle(result: InvokeResult, yielded: boolean): string { - let printed: ReturnType | undefined; - let failure: Error | undefined; - if (result.ok) { - try { - printed = serverScriptOutput(result.value); - } catch (thrown) { - failure = thrown instanceof Error ? thrown : new Error(String(thrown)); - } - } - if (printed) { - if (printed.error) { - if (graph.onServerScriptError !== "ignore" && !warnedRefused.has(job)) { - warnedRefused.add(job); - log( - serverScriptOutputErrorMessage( - printed.error, - request.url, - job.owner ?? `${job.mode} script`, - { production: true, yielded }, - ), - ); - } - return ""; - } - const text = - graph.stripAnsi === false - ? printed.text - : stripAnsiEscapeCodes(printed.text); - const scope = decodeOutputScope(job.outputScope); - return scope ? applyOutputScope(text, scope) : text; - } - if (!failure && !result.ok && result.aborted && !result.timedOut) return ""; - const error = - failure ?? - (!result.ok ? result.error : undefined) ?? - new Error("Server script failed"); - const err = error instanceof Error ? error : new Error(String(error)); - const where = job.owner ? `\n in the ${job.owner}` : ""; - const message = `[bascik] server script error at "${new URL(request.url).pathname}":\n${err.stack ?? err.message}${where}`; - if (graph.onServerScriptError === "error") throw new Error(message); - if (graph.onServerScriptError === "warn") log(message); - return ""; - } + // The runtime's shared policy, as the Node server and the Cloudflare + // Worker use it. What is this host's own: a function is production, logs + // through `log`, remembers a script's first refused value for the + // instance's life, and places a failure by the owner the build recorded. + return scriptJobOutput(invoked, { + onServerScriptError: graph.onServerScriptError, + production: true, + requestUrl: request.url, + owner: job.owner ?? `${job.mode} script`, + stripAnsi: graph.stripAnsi, + outputScope: job.outputScope, + warn: log, + firstRefusal: () => { + if (warnedRefused.has(job)) return false; + warnedRefused.add(job); + return true; + }, + describeFailure: (error) => + `${error.stack ?? error.message}${job.owner ? `\n in the ${job.owner}` : ""}`, + }); }; /** `runGraphJobOutput` with a yielding script's values joined into one string. */ diff --git a/docs/content/deployment/custom-adapters.md b/docs/content/deployment/custom-adapters.md index 6d0d3c5ce..a69a2e678 100644 --- a/docs/content/deployment/custom-adapters.md +++ b/docs/content/deployment/custom-adapters.md @@ -189,11 +189,9 @@ import { extname, join } from "node:path"; import { Readable } from "node:stream"; import { InvalidApiPathError, - applyOutputScope, collectScriptJobOutput, composeBufferedResponse, createNameHasher, - decodeOutputScope, dispatchApiHandler, dynamicPageHeaders, errorResponse, @@ -205,12 +203,9 @@ import { pageLookupCandidates, renderFailureMessage, requestRoutePath, - scriptChunkOutput, + scriptJobOutput, scriptTimeoutError, - serverScriptOutput, - serverScriptOutputErrorMessage, streamComposedResponse, - stripAnsiEscapeCodes, wrapRunner, } from "@bascik/bascik/runtime"; import { apiModules, componentJobs, graph, pageJobs } from "./site.mjs"; @@ -236,61 +231,30 @@ const runJobOutput = async (job, load, request, context, signal, timeoutMs) => { // deadline (which counts only the script's own time) and signal. { timeoutMs, signal, timeoutError: scriptTimeoutError, chunks: true }, ); - - /** What one settled call, or one pull from a script that yields, prints. */ - const settle = (result, yielded) => { - let printed; - let failure = result.error; - if (result.ok) { - try { - // What the value prints, by the rule every host uses: an array joined - // with no separator; an object, a Response or an array of Promises refused. - printed = serverScriptOutput(result.value); - } catch (error) { - // The author's own toString threw while printing: the script failed. - failure = error; - } - } - if (printed) { - if (printed.error) { - // Production: an empty slot (or one skipped value) and one warning per script. - if (graph.onServerScriptError !== "ignore" && !warnedRefused.has(job)) { - warnedRefused.add(job); - console.warn( - serverScriptOutputErrorMessage( - printed.error, - request.url, - job.owner ?? `${job.mode} script`, - { production: true, yielded }, - ), - ); - } - return ""; - } - const output = stripAnsiEscapeCodes(printed.text); - // A component's script gets the component's scoped class names. - const scope = decodeOutputScope(job.outputScope); - return scope ? applyOutputScope(output, scope) : output; - } - // The client went away: nothing is delivered, nothing to report. - if (result.aborted && !result.timedOut) return ""; - const message = - `[bascik] server script error at "${new URL(request.url).pathname}":\n` + - `${failure?.stack ?? failure}\n in the ${job.owner}`; - if (graph.onServerScriptError === "error") throw new Error(message); - if (graph.onServerScriptError === "warn") console.warn(message); - return ""; - }; - - // A script that yields: each value is settled like a returned one, and a - // failed pull (a throw, the deadline, the client leaving) like a failed call. - if (invoked.ok && invoked.source) - return scriptChunkOutput( - invoked.source, - (value) => settle({ ok: true, value }, true), - (failed) => settle(failed, true), - ); - return settle(invoked, false); + // What a value prints, what a value that is not markup becomes, and what a + // failure becomes are the runtime's: the built-in server and the official + // adapters settle through this same function, so every host sends the same + // bytes. The policy names only what is this host's own. + return scriptJobOutput(invoked, { + onServerScriptError: graph.onServerScriptError, + // Production: a value that is not markup never fails the page. The + // output is empty and one warning is logged per script. + production: true, + requestUrl: request.url, + owner: job.owner ?? `${job.mode} script`, + stripAnsi: graph.stripAnsi, + // A component's script gets the component's scoped class names. + outputScope: job.outputScope, + warn: (message) => console.warn(message), + firstRefusal: () => { + if (warnedRefused.has(job)) return false; + warnedRefused.add(job); + return true; + }, + // The bundle's stack names no authored file, so the owner places it. + describeFailure: (error) => + `${error.stack ?? error.message}${job.owner ? `\n in the ${job.owner}` : ""}`, + }); }; /** The same job as one string: a yielding script's values joined. */ @@ -504,7 +468,7 @@ What each part does: - **Paths.** The decoded pathname is refused when it is unsafe (`isUnsafePathname`) or names a dotfile (`hasHiddenSegment`), as on every Bascik host. This handler assumes the site's `base` is `/`; under another base, strip `graph.base` from the pathname before page lookup, as the Cloudflare adapter does. - **API routes.** `matchApiRoute` finds the route and its params; `dispatchApiHandler` applies the method rules (405 with `Allow`, derived `HEAD`, automatic `OPTIONS`), the `graph.apiTimeoutMs` deadline and the generic 500. - **Pages.** `pageLookupCandidates` tries every alias of a page path. A page with no stream script, or a `HEAD` request, is composed in full with `composeBufferedResponse`. Otherwise `streamComposedResponse` waits for the server scripts, so a failure there is still a 500 page, and then streams the rest. Only `graph.custom500` or the generic page reaches the client on failure; the message goes to the log. -- **Scripts.** `invokeLoadedWithDeadline` runs a script under `graph.scriptTimeoutMs` and reports the same timeout message as `bascik --server` (`scriptTimeoutError`). The output is stripped of ANSI escapes, and a component's script gets its class names through `applyOutputScope`. A failure follows `graph.onServerScriptError`: `"error"` throws (a 500 before the status is sent, an empty slot after), `"warn"` logs and leaves the slot empty, `"ignore"` leaves it empty. +- **Scripts.** `invokeLoadedWithDeadline` runs a script under `graph.scriptTimeoutMs` and reports the same timeout message as `bascik --server` (`scriptTimeoutError`). `scriptJobOutput` then settles the result the way every Bascik host does: the output is printed by the shared rule, stripped of ANSI escapes, and given its component's class names, and a failure follows `graph.onServerScriptError`: `"error"` throws (a 500 before the status is sent, an empty slot after), `"warn"` logs and leaves the slot empty, `"ignore"` leaves it empty. The handler passes only what is its own: where a warning goes, how a failure is described, and how it remembers a script's first refused value. - **Components.** `wrapRunner` renders component tags in script output with the build's manifest. Script segments are keyed by their index in the page, as on the other hosts, because the key is part of every request-time instance ID; with the same keys, this server sends the same bytes as `bascik --server`. See [Rendering component tags](#rendering-component-tags-sitegraphcomponents) for each field. - **Static files.** `public/` is served from disk so the server runs on its own. In production, serve it from a CDN or object storage instead. @@ -793,7 +757,7 @@ export interface SiteGraphContentSecurityPolicy { - **`responseSecurity`**: `{ headers, hsts, privateDynamicPages }`, already resolved (`hsts` is the header value or `null`). Pass it as the `security` option of `dynamicPageHeaders`, `mergeHandlerHeaders`, `errorResponse` and `internalErrorPage`. Absent, they send the defaults. - **`hideDotfiles`**: pass it as the third argument of `isPrivatePathname(pathname, graph.base, graph.hideDotfiles !== false)` before routing, and answer 404 when it returns `true`. With it off, `publicFiles` already includes dotfiles, never `.bascik/`. -- **`stripAnsi`**: when not `false`, run a script's output through `stripAnsiEscapeCodes` as `runServerScriptJob` does. +- **`stripAnsi`**: pass it to `scriptJobOutput` as the policy's `stripAnsi`; unless it is `false`, the output has its ANSI escapes stripped, as `runServerScriptJob` does. - **`renderLimits`**: pass it as `limits` on the `RenderHost` you give `wrapRunner`. Every field is optional so a graph from an older build still works; absent means the default. The binding settings travel in `components.manifest.security` and need nothing from the adapter. @@ -833,7 +797,7 @@ export interface SiteGraphJob { } ``` -A job with `outputScope` is a script inside a component. Apply it to the job's output with `applyOutputScope(output, decodeOutputScope(job.outputScope))` from `@bascik/bascik/runtime`, so the returned markup gets the component's scoped class names, as on Node. +A job with `outputScope` is a script inside a component. Pass it to `scriptJobOutput` as the policy's `outputScope`, so the returned markup gets the component's scoped class names, as on Node. (`applyOutputScope(output, decodeOutputScope(job.outputScope))` from `@bascik/bascik/runtime` is what that does.) ### Rendering component tags (`SiteGraph.components`) @@ -877,16 +841,16 @@ export interface SiteGraphApiRoute { Adapters bundle `@bascik/bascik/runtime` into their function entry points. It uses only Web APIs (`Request`, `Response`, `ReadableStream`, `TextEncoder`, `AbortSignal`), so it runs on Node, Cloudflare Workers, Deno and other Web-standard runtimes. It does not route a request by itself: the host decides which page or API route a request is for and calls these parts, as the [tutorial handler](#step-4-write-the-request-handler) and the [Cloudflare adapter source](https://github.com/bascikdev/bascik/tree/main/adapters/cloudflare/src) do. -| Area | Exports | -| :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Page composition | `composeBufferedResponse(plan, run, signal)`, `streamComposedResponse(plan, run, writer, options)`, `collectScriptJobOutput(output)`, and the `ExecutionPlan`, `ExecutionSegment`, `ScriptJobRunner`, `ScriptOutputRunner` and `ScriptJobOutput` types. A runner resolves one script job to its output: a string, or for a script that yields, an async iterable of chunks (`ScriptOutputRunner`). Throwing fails the job. | -| Deadlines | `invokeLoadedWithDeadline(load, call, options)`, `invokeWithDeadline(call, options)`, `scriptTimeoutError(ms)`, `createAbortGate(signal)`. A fired deadline or client abort wins over a late result. With the chunks option a returned generator becomes the result's source, and `scriptChunkOutput(source, print, fail)` turns it into a job's chunks. | -| API routes | `matchApiRoute(routes, pathname, rawPathname)`, `dispatchApiHandler(mod, request, context, options)`, `allowHeaderFor(mod)`, `InvalidApiPathError`, and the `ApiHandlerContext` and `PlatformContext` types. | -| Paths | `pageLookupCandidates(pathname)`, `normalizePagePathname(pathname)`, `isUnsafePathname(pathname)`, `hasHiddenSegment(pathname, base)`, `isPrivatePathname(pathname, base, hideDotfiles)`, `GENERATED_CONTROL_PATHS`. | -| Responses | `dynamicPageHeaders(options)`, `errorResponse(status, body, options)`, `internalErrorPage(custom500, options)`, `mergeHandlerHeaders(headers, options)`, `stripRepresentationHeaders(headers)`, `SECURITY_HEADERS`, `resolveResponseSecurity(security)`, `DEFAULT_RESPONSE_SECURITY`. | -| Component rendering | `wrapRunner(run, plan, host)`, `RenderHost`, `requestRoutePath(url)`, `renderFailureMessage(error, url)`, `RequestRenderError`, `REQUEST_RENDER_LIMITS`, `createNameHasher(digest)`. | -| Script output | `serverScriptOutput(value)` (the text a returned value prints, or why it is refused), `serverScriptOutputErrorMessage(error, url, owner, options)`, `applyOutputScope(output, scope)`, `decodeOutputScope(encoded)`, `stripAnsiEscapeCodes(text)`. | -| Content policy | `contentSecurityPolicyHeader(policy, hashes)`, which returns the header name and value for one HTML response, from the graph's content security policy and the route's hashes. | +| Area | Exports | +| :------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Page composition | `composeBufferedResponse(plan, run, signal)`, `streamComposedResponse(plan, run, writer, options)`, `collectScriptJobOutput(output)`, and the `ExecutionPlan`, `ExecutionSegment`, `ScriptJobRunner`, `ScriptOutputRunner` and `ScriptJobOutput` types. A runner resolves one script job to its output: a string, or for a script that yields, an async iterable of chunks (`ScriptOutputRunner`). Throwing fails the job. | +| Deadlines | `invokeLoadedWithDeadline(load, call, options)`, `invokeWithDeadline(call, options)`, `scriptTimeoutError(ms)`, `createAbortGate(signal)`. A fired deadline or client abort wins over a late result. With the chunks option a returned generator becomes the result's source, and `scriptChunkOutput(source, print, fail)` turns it into a job's chunks. | +| API routes | `matchApiRoute(routes, pathname, rawPathname)`, `dispatchApiHandler(mod, request, context, options)`, `allowHeaderFor(mod)`, `InvalidApiPathError`, and the `ApiHandlerContext` and `PlatformContext` types. | +| Paths | `pageLookupCandidates(pathname)`, `normalizePagePathname(pathname)`, `isUnsafePathname(pathname)`, `hasHiddenSegment(pathname, base)`, `isPrivatePathname(pathname, base, hideDotfiles)`, `GENERATED_CONTROL_PATHS`. | +| Responses | `dynamicPageHeaders(options)`, `errorResponse(status, body, options)`, `internalErrorPage(custom500, options)`, `mergeHandlerHeaders(headers, options)`, `stripRepresentationHeaders(headers)`, `SECURITY_HEADERS`, `resolveResponseSecurity(security)`, `DEFAULT_RESPONSE_SECURITY`. | +| Component rendering | `wrapRunner(run, plan, host)`, `RenderHost`, `requestRoutePath(url)`, `renderFailureMessage(error, url)`, `RequestRenderError`, `REQUEST_RENDER_LIMITS`, `createNameHasher(digest)`. | +| Script output | `scriptJobOutput(invoked, policy)` and `settleScriptOutput(result, yielded, policy)` (what a settled script becomes on every host: printed, stripped, scoped, or reported under the policy, with the `ScriptOutputPolicy` type), `serverScriptOutput(value)` (the text a returned value prints, or why it is refused), `serverScriptOutputErrorMessage(error, url, owner, options)`, `applyOutputScope(output, scope)`, `decodeOutputScope(encoded)`, `stripAnsiEscapeCodes(text)`. | +| Content policy | `contentSecurityPolicyHeader(policy, hashes)`, which returns the header name and value for one HTML response, from the graph's content security policy and the route's hashes. | A buffered page response for a page without stream scripts, given a `page` from `graph.pages` and a `run` like the tutorial's: diff --git a/docs/content/internals/server.md b/docs/content/internals/server.md index 7239e72b8..3275d3faa 100644 --- a/docs/content/internals/server.md +++ b/docs/content/internals/server.md @@ -203,7 +203,7 @@ The operational default for production is immutable release directories: `dist/` At boot time, `server-prod.ts` also checks for `.bascik/dist/server-scripts.json`. A missing sidecar is optional only when no stored page references a server script: a genuinely static release with no server scripts and no placeholders remains valid and is served normally. Only a missing file counts as missing: a regular file where the `.bascik/` folder or its output folder belongs is a broken layout, and startup fails naming that file. Windows reports this case as `ENOENT` rather than `ENOTDIR`, so before reading `ENOENT` as "no sidecar" the server checks the path's nearest existing ancestor (`fileInPlaceOfFolder` in `fs-errors.ts`). If any page carries a `data-bascik-server-id` placeholder and the sidecar is absent, startup fails (see readiness validation below). When present, it is parsed and validated before the socket binds; a malformed, schema-incompatible, or stale sidecar aborts startup with an actionable rebuild message. Each entry retains the authored HTML source path alongside the script source and optional external module path. Page HTML templates in `dist/` contain inert placeholder script tags (``) that reference these entries. On each incoming request, `executeServerScripts` resolves each placeholder by ID, rewrites relative imports from the authored source directory and import-root (`@/`) imports from `scripts.importRoot`, runs the corresponding script with the request context in-process via `ScriptRegistry`, and injects the returned markup into the response. -What a returned value prints is decided by `serverScriptOutput` (`server-script-output.ts`), a portable module the Cloudflare Worker's job runner shares, so both hosts print the same bytes: a string as it is, an `html` or `raw()` value as its markup, an array joined with no separator (nested arrays flattened up to 1000 levels, `null`, `undefined` and `false` items empty), a number, bigint or `true` as text, and an object with its own `toString` as that text. A plain object, a `Map`, a `Response`, an `Error` (whatever its `toString`, since its text may be an internal message), a function, a symbol, or an array holding a Promise is refused with `serverScriptOutputErrorMessage`, in the `server script error at ""` frame and naming the script (`The server script in src/pages/x.html:12 returned ...`). `runServerScriptJob` then applies it by mode: in development (`bascik`) it is a failure under `scripts.onServerScriptError` like a throw (a `ServerScriptError` under `'error'`, so a `server` job is a 500 before commit and a `stream` job an empty slot with the error logged); in production (`bascik --server`) it never fails the page: the slot is empty and the message is a `console.warn` once per script start position for the life of the process, nothing under `'ignore'`. The Worker always behaves as production, once per job per isolate; its page jobs carry no owner in the graph, so its message names the script's kind where Node names the file and line. A throw from the author's own `toString` while printing is the script failing, under the policy. +What a settled script becomes is decided in one place, `settleScriptOutput` (`script-output-policy.ts`), a portable module in the runtime that `runServerScriptJob`, the Cloudflare Worker's job runner and the Netlify function's all call: it prints the value, strips ANSI escapes, applies a component's output scope, refuses a value that is not markup, and applies `scripts.onServerScriptError` to a failure. Each host passes a `ScriptOutputPolicy` naming only what is its own: its log, how it describes a failure (Node maps the stack to the authored file; a function names the owner the build recorded), the error class it throws, and how it remembers a script's first refused value. `scriptJobOutput` wraps it for a whole invocation, including a script that yields. What a returned value prints is decided by `serverScriptOutput` (`server-script-output.ts`), which that function calls, so every host prints the same bytes: a string as it is, an `html` or `raw()` value as its markup, an array joined with no separator (nested arrays flattened up to 1000 levels, `null`, `undefined` and `false` items empty), a number, bigint or `true` as text, and an object with its own `toString` as that text. A plain object, a `Map`, a `Response`, an `Error` (whatever its `toString`, since its text may be an internal message), a function, a symbol, or an array holding a Promise is refused with `serverScriptOutputErrorMessage`, in the `server script error at ""` frame and naming the script (`The server script in src/pages/x.html:12 returned ...`). `runServerScriptJob` then applies it by mode: in development (`bascik`) it is a failure under `scripts.onServerScriptError` like a throw (a `ServerScriptError` under `'error'`, so a `server` job is a 500 before commit and a `stream` job an empty slot with the error logged); in production (`bascik --server`) it never fails the page: the slot is empty and the message is a `console.warn` once per script start position for the life of the process, nothing under `'ignore'`. The Worker always behaves as production, once per job per isolate; its page jobs carry no owner in the graph, so its message names the script's kind where Node names the file and line. A throw from the author's own `toString` while printing is the script failing, under the policy. When the sidecar has page-owned entries, boot also reads `.bascik/dist/components.json` (`readComponentManifest` in `component-manifest-file.ts`, which the server imports instead of the builder in `component-manifest.ts`, so boot never loads the build pipeline). It is a required runtime artifact for such a release: missing, of another schema, malformed, or naming a component script the sidecar does not stage, it aborts startup before the socket binds, with a message that says to run `bascik --build`. `loadComponentJobs` (`request-render-node.ts`) then builds one `ScriptJob` per component script, shared by every request. When the store keeps a page with a plan and a manifest, `prepareServerScriptPlan` (`server-scripts.ts`) builds the page's portable plan and `prepareRenderer` (`request-render.ts`) the renderer's per-page work: the component list, its compiled tag search, the plan's script modes, and under `minify.html` the page scan. Each is cached on the plan or the manifest, so the first request to a server or stream route does not pay for them. A static release has no sidecar entries and no manifest, and boots exactly as before. diff --git a/docs/content/package-exports.md b/docs/content/package-exports.md index acfb39cd8..ef1560a61 100644 --- a/docs/content/package-exports.md +++ b/docs/content/package-exports.md @@ -57,7 +57,8 @@ Key functions: - `composeBufferedResponse(plan, run, signal)` composes a fully buffered `Uint8Array` body for a page whose `data-bascik-server` jobs all resolve before the response starts. - `streamComposedResponse(plan, run, writer, options)` drives the two-phase streamer: `server` jobs resolve before commit, `stream` jobs run with bounded lookahead after headers are sent. A `stream` job whose runner resolves to an async iterable (a script that yields) is written one chunk at a time. Returns a `ComposedStreamer` with `ready`, `commit`, `done`, `signal`, and `toReadableStream()`. -- `invokeWithDeadline(handler, options)` runs a handler under a timeout and an upstream `AbortSignal`, settling exactly once. Once the deadline fires or the upstream aborts, the result is a timeout or an abort even if the handler settles afterwards. `invokeLoadedWithDeadline(load, call, options)` does the same with a module load under the same deadline, and `createAbortGate(signal)` is the settlement rule both use. With `chunks: true`, a handler that returns a generator gives `result.source` instead of `result.value`: a `ScriptChunkSource` pulled one value at a time under the same deadline, which counts only the time a pull is pending. `scriptChunkOutput(source, print, fail)` turns it into the chunks of a job, and `collectScriptJobOutput(output)` joins them. `scriptTimeoutError(ms)` builds the `Script execution timed out after Nms` error that server, stream and component scripts report on every host. +- `invokeWithDeadline(handler, options)` runs a handler under a timeout and an upstream `AbortSignal`, settling exactly once. Once the deadline fires or the upstream aborts, the result is a timeout or an abort even if the handler settles afterwards. `invokeLoadedWithDeadline(load, call, options)` does the same with a module load under the same deadline, and `createAbortGate(signal)` is the settlement rule both use. With `chunks: true`, a handler that returns a generator gives `result.source` instead of `result.value`: a `ScriptChunkSource` pulled one value at a time under the same deadline, which counts only the time a pull is pending. `scriptChunkOutput(source, print, fail)` turns it into the chunks of a job, and `collectScriptJobOutput(output)` joins them. +- `scriptJobOutput(invoked, policy)` turns an invocation's result into the job's output by the rule every host shares: a value is printed, stripped of ANSI escapes and given its component's class names; a value that is not markup is refused; a failure follows `scripts.onServerScriptError`. `settleScriptOutput(result, yielded, policy)` is the same for one result. The `ScriptOutputPolicy` names what is the host's own: its log, its description of a failure, its error class, and how it remembers a script's first refused value. `scriptTimeoutError(ms)` builds the `Script execution timed out after Nms` error that server, stream and component scripts report on every host. - `dispatchApiHandler(module, request, context, options)` dispatches a web `Request` to an API route module's exported methods, handling `HEAD`, `OPTIONS`, and the `Allow` header. The runtime also exports the shared response policy (`dynamicPageHeaders`, `mergeHandlerHeaders`, `stripRepresentationHeaders`, `errorResponse`, `internalErrorPage`, `SECURITY_HEADERS`) and pure route matching (`matchApiRoute`, `sortApiRoutes`, `pageLookupCandidates`, `isUnsafePathname`, `hasHiddenSegment`). diff --git a/pkg/src/lib/script-output-policy.test.ts b/pkg/src/lib/script-output-policy.test.ts new file mode 100644 index 000000000..c67caae9f --- /dev/null +++ b/pkg/src/lib/script-output-policy.test.ts @@ -0,0 +1,587 @@ +/** + * The one rule every host settles a server or stream script by: what a + * returned or yielded value prints, what happens to a value that is not + * markup, and what a failure becomes under `scripts.onServerScriptError`. + * + * Node (`server-scripts.ts`), the Cloudflare Worker and the Netlify function + * each used to carry their own copy of this; they now call + * `settleScriptOutput` and `scriptJobOutput` with a policy that names only + * what differs between them (where a log goes, how a failure is described, + * which error class is thrown, how "once per script" is remembered). These + * tests pin the shared behavior once, with a policy made of spies. + * + * Misuse lens: a value that is not markup, a `toString` that throws, a + * hostile getter, an aborted request, a timeout, a host hook that throws, + * and a policy missing its optional hooks. + */ +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { encodeOutputScope } from "./output-scope.ts"; +import { invokeWithDeadline } from "./request-execution.ts"; +import { + scriptJobOutput, + settleScriptOutput, + type ScriptOutputPolicy, + type ScriptSettleResult, +} from "./script-output-policy.ts"; + +const HTML = Symbol.for("bascik.html"); +const URL_ = "https://example.test/orders?q=1"; + +type Spied = ScriptOutputPolicy & { + warn: ReturnType void>>; + firstRefusal: ReturnType boolean>>; + describeFailure: ReturnType< + typeof vi.fn<(error: Error, result: ScriptSettleResult) => string> + >; +}; + +/** A production policy under "error" whose every hook is a spy. */ +const policy = (overrides: Partial = {}): Spied => + ({ + onServerScriptError: "error", + production: true, + requestUrl: URL_, + owner: "stream script in src/pages/orders.html:12", + warn: vi.fn(), + firstRefusal: vi.fn(() => true), + describeFailure: vi.fn((error: Error) => `described: ${error.message}`), + ...overrides, + }) as Spied; + +const ok = (value: unknown): ScriptSettleResult => ({ ok: true, value }); +const failed = ( + error: Error, + extra: Partial = {}, +): ScriptSettleResult => ({ ok: false, error, ...extra }); + +describe("settleScriptOutput: what a value prints", () => { + it("prints markup by the shared rule: a string as it is, an html value, an array joined, a number; nothing for null, undefined and false", () => { + const p = policy(); + expect(settleScriptOutput(ok("

    a

    "), false, p)).toBe("

    a

    "); + expect(settleScriptOutput(ok({ [HTML]: "x" }), false, p)).toBe( + "x", + ); + expect(settleScriptOutput(ok(["", ["a"], ""]), false, p)).toBe( + "a", + ); + expect(settleScriptOutput(ok(42), false, p)).toBe("42"); + for (const empty of [null, undefined, false]) + expect(settleScriptOutput(ok(empty), false, p)).toBe(""); + expect(p.warn).not.toHaveBeenCalled(); + expect(p.describeFailure).not.toHaveBeenCalled(); + }); + + it("strips terminal color codes unless stripAnsi is false", () => { + const colored = "
  • \u001b[31mred\u001b[0m
  • "; + expect(settleScriptOutput(ok(colored), false, policy())).toBe( + "
  • red
  • ", + ); + expect( + settleScriptOutput(ok(colored), false, policy({ stripAnsi: true })), + ).toBe("
  • red
  • "); + expect( + settleScriptOutput(ok(colored), false, policy({ stripAnsi: false })), + ).toBe(colored); + }); + + it("applies a component's output scope to the printed markup, and leaves a page script's output alone", () => { + const outputScope = encodeOutputScope({ + classes: { row: "bascik__orders__row" }, + elements: {}, + }); + const scoped = settleScriptOutput( + ok('
  • x
  • '), + false, + policy({ outputScope }), + ); + expect(scoped).toContain("bascik__orders__row"); + expect( + settleScriptOutput(ok('
  • x
  • '), false, policy()), + ).toBe('
  • x
  • '); + }); + + it("strips color codes before scoping, so a code inside a tag cannot hide its class", () => { + const outputScope = encodeOutputScope({ + classes: { row: "bascik__orders__row" }, + elements: {}, + }); + const out = settleScriptOutput( + ok('
  • x
  • '), + false, + policy({ outputScope }), + ); + expect(out).toContain("bascik__orders__row"); + }); + + it("misuse: an undecodable output scope leaves the markup as printed instead of throwing", () => { + expect( + settleScriptOutput(ok("

    a

    "), false, policy({ outputScope: "%%%" })), + ).toBe("

    a

    "); + }); +}); + +describe("settleScriptOutput: a value that is not markup, in production", () => { + it("is an empty output with one located warning that says the slot is empty", () => { + const p = policy(); + expect(settleScriptOutput(ok({ title: "Dune" }), false, p)).toBe(""); + expect(p.firstRefusal).toHaveBeenCalledTimes(1); + expect(p.warn).toHaveBeenCalledTimes(1); + const message = p.warn.mock.calls[0][0]; + expect(message).toContain('[bascik] server script error at "/orders"'); + expect(message).toContain( + "The stream script in src/pages/orders.html:12 returned an object ([object Object])", + ); + expect(message).toContain("The slot is empty"); + expect(p.describeFailure).not.toHaveBeenCalled(); + }); + + it("never throws, whatever the policy says about failures", () => { + for (const onServerScriptError of ["error", "warn", "ignore"] as const) + expect( + settleScriptOutput( + ok(() => "fn"), + false, + policy({ onServerScriptError }), + ), + ).toBe(""); + }); + + it("is logged only when the host says this is the script's first refusal", () => { + const p = policy({ firstRefusal: vi.fn(() => false) }); + expect(settleScriptOutput(ok({}), false, p)).toBe(""); + expect(p.firstRefusal).toHaveBeenCalledTimes(1); + expect(p.warn).not.toHaveBeenCalled(); + }); + + it('under "ignore" nothing is logged and the first refusal is not used up', () => { + const p = policy({ onServerScriptError: "ignore" }); + expect(settleScriptOutput(ok({}), false, p)).toBe(""); + expect(p.firstRefusal).not.toHaveBeenCalled(); + expect(p.warn).not.toHaveBeenCalled(); + }); + + it("a yielded value says yielded and that only that value is skipped", () => { + const p = policy(); + expect(settleScriptOutput(ok({}), true, p)).toBe(""); + const message = p.warn.mock.calls[0][0]; + expect(message).toContain("yielded an object"); + expect(message).toContain("That value is skipped"); + expect(message).not.toContain("The slot is empty"); + }); + + it("without an owner the message still names the script", () => { + const p = policy({ owner: undefined }); + settleScriptOutput(ok(Symbol("s")), false, p); + expect(p.warn.mock.calls[0][0]).toContain("The script returned a symbol"); + }); +}); + +describe("settleScriptOutput: a value that is not markup, in development", () => { + const dev = (onServerScriptError: "error" | "warn" | "ignore") => + policy({ production: false, onServerScriptError }); + + it('under "error" it throws the located message, without the production note, through the host\'s error class', () => { + class HostError extends Error {} + const p = policy({ + production: false, + createError: (message) => new HostError(message), + }); + let thrown: unknown; + try { + settleScriptOutput(ok([Promise.resolve("x")]), false, p); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HostError); + const message = (thrown as Error).message; + expect(message).toContain("returned an array holding a Promise at [0]"); + expect(message).not.toContain("The slot is empty"); + expect(p.warn).not.toHaveBeenCalled(); + expect(p.firstRefusal).not.toHaveBeenCalled(); + }); + + it('under "warn" it logs every time, with no once-per-script gate, and the output is empty', () => { + const p = dev("warn"); + expect(settleScriptOutput(ok({}), false, p)).toBe(""); + expect(settleScriptOutput(ok({}), false, p)).toBe(""); + expect(p.warn).toHaveBeenCalledTimes(2); + expect(p.firstRefusal).not.toHaveBeenCalled(); + }); + + it('under "ignore" the output is empty and nothing is logged', () => { + const p = dev("ignore"); + expect(settleScriptOutput(ok({}), false, p)).toBe(""); + expect(p.warn).not.toHaveBeenCalled(); + }); + + it("a yielded value is worded as a yield", () => { + expect(() => settleScriptOutput(ok({}), true, dev("error"))).toThrow( + /yielded an object/, + ); + }); + + it("without createError it throws a plain Error", () => { + let thrown: unknown; + try { + settleScriptOutput(ok({}), false, dev("error")); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).constructor).toBe(Error); + }); +}); + +describe("settleScriptOutput: a failed call or pull", () => { + it('under "error" it throws the framed report the host described', () => { + const boom = new Error("boom"); + const p = policy(); + const result = failed(boom); + expect(() => settleScriptOutput(result, false, p)).toThrow( + '[bascik] server script error at "/orders":\ndescribed: boom', + ); + expect(p.describeFailure).toHaveBeenCalledWith(boom, result); + expect(p.warn).not.toHaveBeenCalled(); + }); + + it('under "warn" it logs that report and the output is empty', () => { + const p = policy({ onServerScriptError: "warn" }); + expect(settleScriptOutput(failed(new Error("boom")), false, p)).toBe(""); + expect(p.warn).toHaveBeenCalledWith( + '[bascik] server script error at "/orders":\ndescribed: boom', + ); + }); + + it('under "ignore" the output is empty, nothing is logged and the failure is not even described', () => { + const p = policy({ onServerScriptError: "ignore" }); + expect(settleScriptOutput(failed(new Error("boom")), false, p)).toBe(""); + expect(p.warn).not.toHaveBeenCalled(); + expect(p.describeFailure).not.toHaveBeenCalled(); + }); + + it("is the same in development and production", () => { + for (const production of [true, false]) + expect(() => + settleScriptOutput( + failed(new Error("boom")), + false, + policy({ production }), + ), + ).toThrow(/described: boom/); + }); + + it("throws through the host's error class", () => { + class HostError extends Error {} + expect(() => + settleScriptOutput( + failed(new Error("boom")), + false, + policy({ createError: (message) => new HostError(message) }), + ), + ).toThrow(HostError); + }); + + it('a client that went away is an empty output with no report, even under "error"', () => { + const p = policy(); + expect( + settleScriptOutput( + failed(new Error("gone"), { aborted: true, timedOut: false }), + false, + p, + ), + ).toBe(""); + expect(p.describeFailure).not.toHaveBeenCalled(); + expect(p.warn).not.toHaveBeenCalled(); + }); + + it("a timeout is reported, and the host is told it was one", () => { + const p = policy(); + const result = failed(new Error("Script execution timed out after 30ms"), { + timedOut: true, + }); + expect(() => settleScriptOutput(result, false, p)).toThrow(/timed out/); + expect(p.describeFailure.mock.calls[0][1]).toBe(result); + expect(p.describeFailure.mock.calls[0][1].timedOut).toBe(true); + }); + + it("a failure the host calls silent (a socket reset) is an empty output with no report", () => { + const reset = failed(new Error("ECONNRESET"), { + thrown: { code: "ECONNRESET" }, + }); + const isSilentFailure = vi.fn( + (result: ScriptSettleResult) => + (result.thrown as { code?: string })?.code === "ECONNRESET", + ); + const p = policy({ isSilentFailure }); + expect(settleScriptOutput(reset, false, p)).toBe(""); + expect(isSilentFailure).toHaveBeenCalledWith(reset); + expect(p.describeFailure).not.toHaveBeenCalled(); + // Any other failure still reports. + expect(() => + settleScriptOutput(failed(new Error("boom")), false, p), + ).toThrow(/boom/); + }); + + it("misuse: a failed result that carries no error still reports one", () => { + const p = policy(); + expect(() => settleScriptOutput({ ok: false }, false, p)).toThrow( + /described: Server script failed/, + ); + }); + + it("is the same for a pull (yielded) as for a call", () => { + expect(() => + settleScriptOutput(failed(new Error("row failed")), true, policy()), + ).toThrow( + '[bascik] server script error at "/orders":\ndescribed: row failed', + ); + }); +}); + +describe("settleScriptOutput: the author's own toString", () => { + const throwing = { + toString() { + throw new RangeError("toString blew up"); + }, + }; + + it("a toString that throws is the script failing: it follows the failure policy, not the refusal one", () => { + const p = policy(); + expect(() => settleScriptOutput(ok(throwing), false, p)).toThrow( + /described: toString blew up/, + ); + expect(p.describeFailure.mock.calls[0][0]).toBeInstanceOf(RangeError); + // The host is handed a failed result, never the ok one it passed in. + expect(p.describeFailure.mock.calls[0][1].ok).toBe(false); + expect(p.firstRefusal).not.toHaveBeenCalled(); + }); + + it('under "warn" it is logged and the output is empty; under "ignore" it is silent', () => { + const warn = policy({ onServerScriptError: "warn" }); + expect(settleScriptOutput(ok(throwing), false, warn)).toBe(""); + expect(warn.warn).toHaveBeenCalledTimes(1); + const ignore = policy({ onServerScriptError: "ignore" }); + expect(settleScriptOutput(ok(throwing), false, ignore)).toBe(""); + expect(ignore.warn).not.toHaveBeenCalled(); + }); + + it("a toString that throws something that is not an Error is reported as one", () => { + const p = policy(); + expect(() => + settleScriptOutput( + ok({ + toString() { + // eslint-disable-next-line no-throw-literal + throw "just a string"; + }, + }), + false, + p, + ), + ).toThrow(/described: just a string/); + }); + + it("it is reported even when the request was not aborted and no silent rule applies to it", () => { + const isSilentFailure = vi.fn(() => false); + expect(() => + settleScriptOutput(ok(throwing), false, policy({ isSilentFailure })), + ).toThrow(/toString blew up/); + }); +}); + +describe("settleScriptOutput: the result it is given is never changed", () => { + it("leaves the caller's result object as it was", () => { + const result = ok({ + toString() { + throw new Error("x"); + }, + }); + const before = { ...result }; + expect(() => settleScriptOutput(result, false, policy())).toThrow(); + expect(result).toEqual(before); + }); +}); + +describe("scriptJobOutput", () => { + it("a call that returned is settled as one string", () => { + const p = policy(); + expect(scriptJobOutput(ok("

    a

    "), p)).toBe("

    a

    "); + }); + + it("a call that failed follows the policy at once", () => { + expect(() => scriptJobOutput(failed(new Error("boom")), policy())).toThrow( + /described: boom/, + ); + expect( + scriptJobOutput( + failed(new Error("boom")), + policy({ onServerScriptError: "ignore" }), + ), + ).toBe(""); + }); + + it("a script that yields gives an iterable: each value settled as a yield, by the same policy", async () => { + const invoked = await invokeWithDeadline( + async function* () { + yield "
  • \u001b[31mone\u001b[0m
  • "; + yield null; + yield ["
  • ", "two", "
  • "]; + yield { title: "not markup" }; + yield "
  • three
  • "; + }, + { chunks: true }, + ); + const p = policy(); + const output = scriptJobOutput(invoked, p); + expect(typeof output).not.toBe("string"); + const chunks: string[] = []; + for await (const chunk of output as AsyncIterable) + chunks.push(chunk); + expect(chunks).toEqual(["
  • one
  • ", "
  • two
  • ", "
  • three
  • "]); + expect(p.warn).toHaveBeenCalledTimes(1); + expect(p.warn.mock.calls[0][0]).toContain("yielded an object"); + }); + + it('a pull that fails under "error" throws from next(), after the values already given', async () => { + const invoked = await invokeWithDeadline( + async function* () { + yield "
  • one
  • "; + throw new Error("row two failed"); + }, + { chunks: true }, + ); + const iterator = ( + scriptJobOutput(invoked, policy()) as AsyncIterable + )[Symbol.asyncIterator](); + expect((await iterator.next()).value).toBe("
  • one
  • "); + await expect(iterator.next()).rejects.toThrow( + '[bascik] server script error at "/orders":\ndescribed: row two failed', + ); + expect((await iterator.next()).done).toBe(true); + }); + + it('a pull that fails under "warn" ends the output there and logs once', async () => { + const invoked = await invokeWithDeadline( + async function* () { + yield "
  • one
  • "; + throw new Error("row two failed"); + }, + { chunks: true }, + ); + const p = policy({ onServerScriptError: "warn" }); + const chunks: string[] = []; + for await (const chunk of scriptJobOutput( + invoked, + p, + ) as AsyncIterable) + chunks.push(chunk); + expect(chunks).toEqual(["
  • one
  • "]); + expect(p.warn).toHaveBeenCalledTimes(1); + }); + + it("a pull that fails because the client went away ends the output with no report", async () => { + const upstream = new AbortController(); + const invoked = await invokeWithDeadline( + async function* () { + yield "
  • one
  • "; + await new Promise(() => {}); + }, + { chunks: true, signal: upstream.signal }, + ); + const p = policy(); + const iterator = (scriptJobOutput(invoked, p) as AsyncIterable)[ + Symbol.asyncIterator + ](); + await iterator.next(); + const pending = iterator.next(); + upstream.abort(new Error("client went away")); + expect((await pending).done).toBe(true); + expect(p.describeFailure).not.toHaveBeenCalled(); + expect(p.warn).not.toHaveBeenCalled(); + }); + + it("the host's silent rule sees the raw rejection of a failed pull", async () => { + const invoked = await invokeWithDeadline( + async function* () { + yield "
  • one
  • "; + // eslint-disable-next-line no-throw-literal + throw { code: "ECONNRESET" }; + }, + { chunks: true }, + ); + const p = policy({ + isSilentFailure: (result) => + (result.thrown as { code?: string })?.code === "ECONNRESET", + }); + const chunks: string[] = []; + for await (const chunk of scriptJobOutput( + invoked, + p, + ) as AsyncIterable) + chunks.push(chunk); + expect(chunks).toEqual(["
  • one
  • "]); + expect(p.describeFailure).not.toHaveBeenCalled(); + }); + + it("a development refusal of a yielded value ends the output and closes the generator", async () => { + const cleanedUp = vi.fn(); + const invoked = await invokeWithDeadline( + async function* () { + try { + yield "
  • one
  • "; + yield { not: "markup" }; + yield "
  • never
  • "; + } finally { + cleanedUp(); + } + }, + { chunks: true }, + ); + const iterator = ( + scriptJobOutput( + invoked, + policy({ production: false }), + ) as AsyncIterable + )[Symbol.asyncIterator](); + await iterator.next(); + await expect(iterator.next()).rejects.toThrow(/yielded an object/); + await new Promise((r) => setTimeout(r, 0)); + expect(cleanedUp).toHaveBeenCalledTimes(1); + }); + + it("a value the generator returns is settled as its last yield", async () => { + const invoked = await invokeWithDeadline( + async function* () { + yield "
  • one
  • "; + return "
  • last
  • "; + }, + { chunks: true }, + ); + const chunks: string[] = []; + for await (const chunk of scriptJobOutput( + invoked, + policy(), + ) as AsyncIterable) + chunks.push(chunk); + expect(chunks).toEqual(["
  • one
  • ", "
  • last
  • "]); + }); +}); + +describe("script-output-policy module", () => { + it("is portable: no node: imports and no config (a Worker settles with it)", () => { + const source = readFileSync( + new URL("./script-output-policy.ts", import.meta.url), + "utf8", + ); + expect(source).not.toMatch(/from\s+["']node:/); + expect(source).not.toMatch(/from\s+["']\.\/config/); + expect(source).not.toMatch(/\bprocess\.|\bBuffer\b|console\./); + }); + + it("is exported from the runtime entry adapters bundle", async () => { + const runtime = await import("../runtime.ts"); + expect(runtime.settleScriptOutput).toBe(settleScriptOutput); + expect(runtime.scriptJobOutput).toBe(scriptJobOutput); + }); +}); diff --git a/pkg/src/lib/script-output-policy.ts b/pkg/src/lib/script-output-policy.ts new file mode 100644 index 000000000..14c8ec162 --- /dev/null +++ b/pkg/src/lib/script-output-policy.ts @@ -0,0 +1,216 @@ +/** + * @module script-output-policy + * + * What a settled server or stream script becomes, on every host. + * ───────────────────────────────────────────────────────────────────────────── + * + * A script's call (or, for a script that yields, each pull) settles to a + * value or a failure. This module turns that into the script's output: + * + * - a value prints by the shared rule (`serverScriptOutput`), has terminal + * color codes stripped unless `scripts.stripAnsi` is off, and receives its + * component's scoped names when the script lives in a component; + * - a value that is not markup is refused: in development it is a failure + * under `scripts.onServerScriptError`; in production it never fails the + * page, the output is empty and one warning is logged per script; + * - a failure throws under `"error"` (a 500 before the response commits, an + * empty slot after), is logged under `"warn"`, and is silent under + * `"ignore"`; a client that went away is never reported. + * + * The Node server, the Cloudflare Worker and the Netlify function all settle + * through here, which is what makes them send the same bytes and word the + * same mistake the same way. A host passes a `ScriptOutputPolicy` naming + * only what differs: where a log goes, how a failure is described (Node maps + * the stack to the authored file), which error class is thrown, and how + * "once per script" is remembered. + * + * Portable: Web platform only. No `node:*` imports, no config, no globals of + * a particular host. `serverless-contract.test.ts` bundles it with the rest + * of the runtime. + */ +import { stripAnsiEscapeCodes } from "./ansi-escapes.ts"; +import { applyOutputScope, decodeOutputScope } from "./output-scope.ts"; +import { + scriptChunkOutput, + type ScriptChunkSource, + type ScriptJobOutput, +} from "./request-execution.ts"; +import { + serverScriptOutput, + serverScriptOutputErrorMessage, +} from "./server-script-output.ts"; + +/** `scripts.onServerScriptError`. */ +export type ServerScriptErrorPolicy = "error" | "warn" | "ignore"; + +/** + * One settled call or pull. An `InvokeResult` (the adapters) and the Node + * registry's `ScriptExecutionResult` both fit, so a host passes what it has. + */ +export interface ScriptSettleResult { + ok: boolean; + /** Present when `ok`. */ + value?: unknown; + /** Present when not `ok`. */ + error?: Error; + /** The deadline fired before the script settled. */ + timedOut?: boolean; + /** The upstream signal aborted before the script settled (not a timeout). */ + aborted?: boolean; + /** The raw rejection, when the host kept it, for `isSilentFailure`. */ + thrown?: unknown; +} + +/** What a host settles a script's output by. One per job. */ +export interface ScriptOutputPolicy { + /** `scripts.onServerScriptError` as this host has it. */ + onServerScriptError: ServerScriptErrorPolicy; + /** + * Whether a value that is not markup may fail the page. Development + * (`false`): it is a failure under `onServerScriptError`, reported on every + * request. Production (`true`): the output is empty and, unless the policy + * is `"ignore"`, one warning is logged per script (`firstRefusal`). + */ + production: boolean; + /** The request's URL; its pathname names the route in every message. */ + requestUrl: string; + /** + * The script, for the message of a refused value: `stream script in + * src/pages/orders.html:12`. Absent: "The script". + */ + owner?: string; + /** `scripts.stripAnsi`: only `false` keeps terminal color codes. */ + stripAnsi?: boolean; + /** + * The encoded output scope of a script written inside a component: its + * output receives the component's class and element names. Absent for a + * page's own script. + */ + outputScope?: string; + /** Where a warning goes: the host's own log. */ + warn: (message: string) => void; + /** + * Production only: whether this is the first refused value this script + * has had since the host started. The host remembers (by job, by source + * position); called at most once per refusal, and not at all under + * `"ignore"`. + */ + firstRefusal: () => boolean; + /** + * The body of a failure report, under the shared first line + * `[bascik] server script error at "":`. A host that can map the + * stack to the authored file does it here; `result.timedOut` says the + * deadline fired. Not called under `"ignore"`. + */ + describeFailure: (error: Error, result: ScriptSettleResult) => string; + /** The error thrown under `"error"`; a plain `Error` when absent. */ + createError?: (message: string) => Error; + /** + * A failure that is dropped without a report whatever the policy says: a + * socket the client reset. A client abort is always dropped and needs no + * rule here. + */ + isSilentFailure?: (result: ScriptSettleResult) => boolean; +} + +const fail = (policy: ScriptOutputPolicy, message: string): never => { + throw policy.createError ? policy.createError(message) : new Error(message); +}; + +/** + * The output of one settled call (`yielded` false) or one pull from a script + * that yields (`yielded` true): the printed markup, or `""`. Throws under + * `"error"` for a failure, and in development for a value that is not + * markup. `result` is not changed. + */ +export const settleScriptOutput = ( + result: ScriptSettleResult, + yielded: boolean, + policy: ScriptOutputPolicy, +): string => { + let settled = result; + if (result.ok) { + // An array is joined with no separator, the way `html` inserts one; an + // object, a Response, an unawaited Promise in an array, a function or a + // symbol is refused instead of printing "[object ...]". A throw from the + // author's own toString is the script failing: it follows the failure + // policy below like any other throw. + let printed: ReturnType | undefined; + try { + printed = serverScriptOutput(result.value); + } catch (thrown) { + settled = { + ok: false, + error: thrown instanceof Error ? thrown : new Error(String(thrown)), + }; + } + if (printed?.error) { + if (!policy.production) { + if (policy.onServerScriptError === "ignore") return ""; + const message = serverScriptOutputErrorMessage( + printed.error, + policy.requestUrl, + policy.owner, + { yielded }, + ); + if (policy.onServerScriptError === "error") fail(policy, message); + policy.warn(message); + return ""; + } + // Production never fails the page for this: an empty output and one + // warning per script. + if (policy.onServerScriptError !== "ignore" && policy.firstRefusal()) + policy.warn( + serverScriptOutputErrorMessage( + printed.error, + policy.requestUrl, + policy.owner, + { production: true, yielded }, + ), + ); + return ""; + } + if (printed) { + // Color codes go first, so one inside a tag cannot hide its class. + const text = + policy.stripAnsi === false + ? printed.text + : stripAnsiEscapeCodes(printed.text); + const scope = decodeOutputScope(policy.outputScope); + return scope ? applyOutputScope(text, scope) : text; + } + } + + if (policy.isSilentFailure?.(settled)) return ""; + // The client went away before the script settled: nothing is delivered, + // so there is nothing to report, even when the script answered the abort + // with a value inside its listener. + if (settled.aborted && !settled.timedOut) return ""; + if (policy.onServerScriptError === "ignore") return ""; + const error = settled.error ?? new Error("Server script failed"); + const message = + `[bascik] server script error at "${new URL(policy.requestUrl).pathname}":\n` + + policy.describeFailure(error, settled); + if (policy.onServerScriptError === "error") fail(policy, message); + policy.warn(message); + return ""; +}; + +/** + * A job's output from its invocation: the settled string of a script that + * returned, or, for a script that yields (`invoked.source`, from + * `invokeLoadedWithDeadline` with `chunks: true`), an iterable whose every + * value and every failed pull is settled by the same policy. Under `"error"` + * a failure throws: from this call, or from the iterable's `next()`. + */ +export const scriptJobOutput = ( + invoked: ScriptSettleResult & { source?: ScriptChunkSource }, + policy: ScriptOutputPolicy, +): ScriptJobOutput => + invoked.ok && invoked.source + ? scriptChunkOutput( + invoked.source, + (value) => settleScriptOutput({ ok: true, value }, true, policy), + (failed) => settleScriptOutput(failed, true, policy), + ) + : settleScriptOutput(invoked, false, policy); diff --git a/pkg/src/lib/server-scripts-chunks.test.ts b/pkg/src/lib/server-scripts-chunks.test.ts index ad4a5168f..f61a2d20f 100644 --- a/pkg/src/lib/server-scripts-chunks.test.ts +++ b/pkg/src/lib/server-scripts-chunks.test.ts @@ -575,3 +575,92 @@ describe("a script that yields where nothing is streamed", () => { ]); }); }); + +describe("the Node host's side of the shared output policy", () => { + it("a socket reset while a script yields ends its output with no report, as for a script that returns", async () => { + const { writes, done } = await stream( + page(`export default async function* () { + yield "
  • one
  • "; + throw Object.assign(new Error("Client reset"), { code: "ECONNRESET" }); + }`), + ); + await done; + expect(writes).toEqual([ + "
      ", + "
    • one
    • ", + "
    foot
    ", + ]); + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('under "error" a failed call, a failed pull and a refused value all throw a ServerScriptError', async () => { + const run = (body: string) => + executeServerScripts( + page(body, "data-bascik-server"), + new Request("http://localhost/orders"), + baseContext, + 30000, + FILE, + ).then( + () => undefined, + (error: unknown) => error as Error, + ); + for (const body of [ + `export default () => { throw new Error("call failed"); };`, + `export default async function* () { yield "a"; throw new Error("pull failed"); }`, + `export default () => ({ not: "markup" });`, + `export default async function* () { yield { not: "markup" }; }`, + ]) { + const error = await run(body); + expect(error?.name, body).toBe("ServerScriptError"); + expect(error?.message, body).toMatch( + /^\[bascik\] server script error at "\/orders":\n/, + ); + } + }); + + it("the report of a failed pull is the framed, source-mapped trace: the route, the message, then the authored file and line", async () => { + const { done } = await stream( + page(`export default async function* () { + yield "
  • one
  • "; + throw new Error("row two failed"); + }`), + ); + await done; + const message = String(errorSpy.mock.calls[0]![0]); + const lines = message.split("\n"); + expect(lines[0]).toBe('[bascik] server script error at "/orders":'); + expect(lines[1]).toBe("Error: row two failed"); + expect(lines[2]).toMatch(/^\s+at .*src\/pages\/orders\.html:\d+:\d+\)?$/); + // Never the data: URL the inline module was loaded from. + expect(message).not.toContain("data:text/javascript"); + }); + + it("a refused value in development names the script's file and line as its owner", async () => { + const { done } = await stream( + page(`export default () => ({ not: "markup" });`), + ); + await done; + expect(String(errorSpy.mock.calls[0]![0])).toMatch( + /^\[bascik\] server script error at "\/orders":\nThe stream script in src\/pages\/orders\.html:\d+ returned an object \(\[object Object\]\), which is not markup\./, + ); + }); + + it("production warns once per script by where it starts, so two scripts each get their warning", async () => { + (BascikConfig as unknown as Record).isProdServer = true; + // The gate lasts for the process and is keyed by where a script starts, + // so these two start on lines no other test in this file uses. + const html = + "\n".repeat(40) + + `
    ` + + `
    `; + for (let i = 0; i < 2; i++) { + const { done } = await stream(html); + await done; + } + expect(warnSpy).toHaveBeenCalledTimes(2); + for (const call of warnSpy.mock.calls) + expect(String(call[0])).toContain("The slot is empty"); + }); +}); diff --git a/pkg/src/lib/server-scripts.ts b/pkg/src/lib/server-scripts.ts index 11bd4bc7f..0334d295d 100644 --- a/pkg/src/lib/server-scripts.ts +++ b/pkg/src/lib/server-scripts.ts @@ -42,7 +42,6 @@ import { scriptFailureResult, type ScriptExecutionResult, } from "./script-registry.ts"; -import { stripAnsiEscapeCodes } from "./script-runner.ts"; import { LeadingSlashSpecifierError, resolveScriptSrcPath, @@ -53,22 +52,17 @@ import { } from "./module-specifiers.ts"; import { getImportRoot } from "./import-root.ts"; import { installDataUrlResolveHook } from "./data-url-resolve-hook.ts"; -import { applyOutputScope, decodeOutputScope } from "./output-scope.ts"; -import { - serverScriptOutput, - serverScriptOutputErrorMessage, - type ServerScriptOutputError, -} from "./server-script-output.ts"; +import { scriptJobOutput } from "./script-output-policy.ts"; import { createNodeRenderHost } from "./request-render-node.ts"; import { prepareRenderer, wrapRunner } from "./request-render.ts"; import type { ComponentManifest } from "./component-manifest-file.ts"; import { collectScriptJobOutput, composeBufferedResponse, - scriptChunkOutput, streamComposedResponse, STREAM_LOOKAHEAD, type ExecutionPlan, + type InvokeResult, type ScriptJobOutput, type ScriptOutputRunner, } from "./request-execution.ts"; @@ -450,50 +444,6 @@ const compiledServerTypeScript = ( /** Scripts whose refused return production already warned about, by where they start. */ const warnedRefusedReturns = new Set(); -/** - * A script returned something that is not markup (F060). In development it - * is a server-script failure like a throw: under `onServerScriptError: - * "error"` a located ServerScriptError (the 500 page, or the stream error - * log after commit), under `"warn"` a warning per request, under `"ignore"` - * nothing. In production it never fails the page: the slot is empty and, - * unless the policy is `"ignore"`, a warning is logged once per script, as - * the Cloudflare Worker does. `yielded` is for one value of a script that - * yields: the same policy, worded for a yield, and in production only that - * value is skipped. - */ -const refusedReturn = ( - error: ServerScriptOutputError, - job: ScriptJob, - request: Request, - containingFile: string | undefined, - startsAt: string, - yielded: boolean, -): string => { - const behavior = BascikConfig.scripts?.onServerScriptError ?? "error"; - const line = job.sourceLine ?? job.startLine; - const owner = containingFile - ? `${job.mode} script in ${relative(process.cwd(), containingFile).replace(/\\/g, "/")}${line ? `:${line}` : ""}` - : `${job.mode} script`; - const production = Boolean(BascikConfig.isProdServer || BascikConfig.isBuild); - if (!production) { - const message = serverScriptOutputErrorMessage(error, request.url, owner, { - yielded, - }); - if (behavior === "error") throw new ServerScriptError(message); - if (behavior === "warn") console.warn(message); - return ""; - } - if (behavior === "ignore" || warnedRefusedReturns.has(startsAt)) return ""; - warnedRefusedReturns.add(startsAt); - console.warn( - serverScriptOutputErrorMessage(error, request.url, owner, { - production: true, - yielded, - }), - ); - return ""; -}; - /** * Load and invoke one script job and join its output into one string: `""` * under `onServerScriptError: warn | ignore`, a throw under `"error"`. The @@ -667,132 +617,105 @@ export const runServerScriptJobOutput = async ( job.sourceColumn ?? 1, ); - const invoked: ScriptExecutionResult = await scriptRegistry.invoke( - specifier, - [request, context], - { - timeoutMs, - originalSourcePath, - lineOffset, - exportName: "default", - chunks: true, - ...(!runsFromFile ? { moduleOwner: job } : {}), - ...(signal ? { signal } : {}), - }, - ); - - // A script that yields: each value it yields is settled like a returned - // one, and a failed pull (a throw inside the generator, the deadline, the - // client leaving) like a failed call. - if (invoked.ok && invoked.source) { - return scriptChunkOutput( - invoked.source, - (value) => settle({ ok: true, value }, true), - (failed) => settle(scriptFailureResult(failed), true), - ); - } - return settle(invoked, false); - - /** The output of one settled call or pull, by the policy; see the function comment. */ - function settle( - result: ScriptExecutionResult, - yielded: boolean, - ): string { - // An array is joined with no separator, the way `html` inserts one; an - // object, a Response, an unawaited Promise in an array, a function or a - // symbol is refused instead of printing "[object ...]" (F060). A throw from - // the author's own toString is the script failing: it follows the policy - // below like any other throw. - let printed: ReturnType | undefined; - if (result.ok) { - try { - printed = serverScriptOutput(result.value); - } catch (thrown) { - result = { - ok: false, - error: thrown instanceof Error ? thrown : new Error(String(thrown)), - }; - } - } + const invoked = await scriptRegistry.invoke(specifier, [request, context], { + timeoutMs, + originalSourcePath, + lineOffset, + exportName: "default", + chunks: true, + ...(!runsFromFile ? { moduleOwner: job } : {}), + ...(signal ? { signal } : {}), + }); - if (printed) { - if (printed.error) { - return refusedReturn( - printed.error, - job, - request, - containingFile, - `${originalSourcePath}:${lineOffset}:${columnOffset + 1}`, - yielded, + // What the value prints, what a value that is not markup becomes and what + // a failure becomes are the runtime's shared policy (script-output-policy.ts), + // which the Cloudflare Worker and the Netlify function settle by too. A + // script that yields has each value and each failed pull settled the same + // way. What is Node's own is named here. + const startsAt = `${originalSourcePath}:${lineOffset}:${columnOffset + 1}`; + const ownerLine = job.sourceLine ?? job.startLine; + return scriptJobOutput(invoked, { + onServerScriptError: BascikConfig.scripts?.onServerScriptError ?? "error", + // In development a value that is not markup is a server-script failure + // like a throw (the 500 page, or the stream error log after commit). In + // production it never fails the page: the slot is empty and a warning + // is logged once per script, as the Cloudflare Worker does. + production: Boolean(BascikConfig.isProdServer || BascikConfig.isBuild), + requestUrl: request.url, + owner: containingFile + ? `${job.mode} script in ${relative(process.cwd(), containingFile).replace(/\\/g, "/")}${ownerLine ? `:${ownerLine}` : ""}` + : `${job.mode} script`, + // scripts.stripAnsi off: terminal color codes reach the page as printed. + stripAnsi: BascikConfig.scripts?.stripAnsi, + // A component's server or stream script prints markup that is spliced + // in after scoping, so its class and element names are applied the way a + // build script's output receives them (prompt 157), whether the + // component was placed on the page statically or rendered at request + // time. Page-level scripts carry no scope and are untouched. + outputScope: job.outputScope, + warn: (message) => console.warn(message), + firstRefusal: () => { + if (warnedRefusedReturns.has(startsAt)) return false; + warnedRefusedReturns.add(startsAt); + return true; + }, + createError: (message) => new ServerScriptError(message), + // A socket the client reset is not the script's failure. A failed call + // carries the registry's flag; a failed pull from a script that yields + // carries the raw rejection, which the registry classifies the same way. + isSilentFailure: (result) => { + const flagged = (result as ScriptExecutionResult).isNetworkReset; + if (flagged !== undefined) return flagged; + return ( + "thrown" in result && + scriptFailureResult(result as InvokeResult).isNetworkReset === + true + ); + }, + describeFailure: (err, result) => { + // Columns after a rewritten import on the same line are put back first, in module coordinates. + const moduleReferences = runsFromFile + ? [moduleFilePath!, pathToFileURL(moduleFilePath!).href] + : [specifier, dataUri]; + const rawTrace = remapRewrittenColumns( + err.stack || err.message, + moduleReferences, + specifierEdits, + ); + const cleanedMsg = cleanStackTrace( + rawTrace, + specifier, + originalSourcePath, + lineOffset, + columnOffset, + ); + // An in-process `import()` of a module with a syntax error reports only Node's + // own loader frames, so nothing names the file. Point at where the script starts. + // A timeout is raised by the registry, not the script, so its trace names no + // file either; a nested component script's timeout (prompt 157) would + // otherwise be impossible to place. + if ( + err.name === "TypeError" && + /does not export a function named "default"\.$/.test(err.message) + ) { + // Written like a build script that prints markup, or missing its + // `export default`: Node's text names the module, which for an inline + // script is a URL-encoded data: URL. Name where the script starts and + // what it needs instead (audit 159 finding 134). + return ( + `The server script that starts at ${startsAt} has no default export. ` + + "Export a function that returns the markup, for example " + + '`export default () => "

    Hello

    ";` (see /scripts/server).' ); } - // scripts.stripAnsi off: terminal color codes reach the page as printed. - const output = - BascikConfig.scripts?.stripAnsi === false - ? printed.text - : stripAnsiEscapeCodes(printed.text); - // A component's server or stream script prints markup that is spliced - // in after scoping, so its class and element names are applied here the - // way a build script's output receives them (prompt 157), whether the - // component was placed on the page statically or rendered at request - // time. Page-level scripts carry no scope and are untouched. - const scope = decodeOutputScope(job.outputScope); - return scope ? applyOutputScope(output, scope) : output; - } - - const behavior = BascikConfig.scripts?.onServerScriptError ?? "error"; - const err = result.error ?? new Error("Server script error"); - if (result.isNetworkReset) return ""; - // The client went away before the script settled: nothing is delivered, so - // there is nothing to report, even when the script answered the abort with - // a value inside its listener. The Worker drops an aborted job the same way. - if (result.aborted) return ""; - - // Columns after a rewritten import on the same line are put back first, in module coordinates. - const moduleReferences = runsFromFile - ? [moduleFilePath!, pathToFileURL(moduleFilePath!).href] - : [specifier, dataUri]; - const rawTrace = remapRewrittenColumns( - err.stack || err.message, - moduleReferences, - specifierEdits, - ); - let cleanedMsg = cleanStackTrace( - rawTrace, - specifier, - originalSourcePath, - lineOffset, - columnOffset, - ); - // An in-process `import()` of a module with a syntax error reports only Node's - // own loader frames, so nothing names the file. Point at where the script starts. - // A timeout is raised by the registry, not the script, so its trace names no - // file either; a nested component script's timeout (prompt 157) would - // otherwise be impossible to place. - if ( - err.name === "TypeError" && - /does not export a function named "default"\.$/.test(err.message) - ) { - // Written like a build script that prints markup, or missing its - // `export default`: Node's text names the module, which for an inline - // script is a URL-encoded data: URL. Name where the script starts and - // what it needs instead (audit 159 finding 134). - cleanedMsg = - `The server script that starts at ${originalSourcePath}:${lineOffset}:${columnOffset + 1} has no default export. ` + - "Export a function that returns the markup, for example " + - '`export default () => "

    Hello

    ";` (see /scripts/server).'; - } else if ( - (err instanceof SyntaxError || result.timedOut) && - !cleanedMsg.includes(`${originalSourcePath}:`) - ) { - cleanedMsg += `\n in the script that starts at ${originalSourcePath}:${lineOffset}:${columnOffset + 1}`; - } - const requestPath = new URL(request.url).pathname; - const errorMsg = `[bascik] server script error at "${requestPath}":\n${cleanedMsg}`; - if (behavior === "error") throw new ServerScriptError(errorMsg); - if (behavior === "warn") console.warn(errorMsg); - return ""; - } + if ( + (err instanceof SyntaxError || result.timedOut) && + !cleanedMsg.includes(`${originalSourcePath}:`) + ) + return `${cleanedMsg}\n in the script that starts at ${startsAt}`; + return cleanedMsg; + }, + }); }; /** diff --git a/pkg/src/runtime.ts b/pkg/src/runtime.ts index 76b3f233b..a9a97d3b1 100644 --- a/pkg/src/runtime.ts +++ b/pkg/src/runtime.ts @@ -7,8 +7,10 @@ * it is built on (prompt 157: component-expansion-core, scoping-instantiate), * plus what a host needs to run component scripts as Node does: the output * scope a component script's markup receives, the scoped-name hash from the - * host's own sha256, the ANSI stripping every script's output gets, and the - * rule for what a script's return value prints (prompt 158 R3-04). + * host's own sha256, the ANSI stripping every script's output gets, the + * rule for what a script's return value prints (prompt 158 R3-04), and the + * one function every host settles a script's output by (print, refuse, + * strip, scope, report under `scripts.onServerScriptError`). */ export * from "./lib/request-execution.ts"; @@ -21,4 +23,5 @@ export * from "./lib/output-scope.ts"; export * from "./lib/name-hash.ts"; export * from "./lib/ansi-escapes.ts"; export * from "./lib/server-script-output.ts"; +export * from "./lib/script-output-policy.ts"; export * from "./lib/content-security-policy.ts";