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
2 changes: 1 addition & 1 deletion .github/skills/pre-push/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
101 changes: 101 additions & 0 deletions adapters/cloudflare/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<main></main>");
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`<p>${item.name}</p>`.\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 = () => "<b>\u001b[31mred\u001b[0m</b>";
const stripped = await worker(
graphOf({ pages: { "/account": failingPage(colored) } }),
).fetch(get("/account"), {}, ctx());
expect(await stripped.text()).toBe("<main><b>red</b></main>");
const kept = await worker(
graphOf({
stripAnsi: false,
pages: { "/account": failingPage(colored) },
}),
).fetch(get("/account"), {}, ctx());
expect(await kept.text()).toBe(
"<main><b>\u001b[31mred\u001b[0m</b></main>",
);
});
});
112 changes: 26 additions & 86 deletions adapters/cloudflare/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,18 @@

import { componentExecution } from "./component-host.js";
import {
applyOutputScope,
collectScriptJobOutput,
decodeOutputScope,
composeBufferedResponse,
dispatchApiHandler,
invokeLoadedWithDeadline,
scriptChunkOutput,
scriptJobOutput,
scriptTimeoutError,
streamComposedResponse,
type ApiHandlerContext,
type ApiHandlerModule,
type ExecutionPlan,
type ExecutionSegment,
type PlatformContext,
type InvokeResult,
type RenderHost,
type ScriptJobOutput,
type ScriptOutputRunner,
Expand All @@ -62,9 +59,6 @@ import {
InvalidApiPathError,
matchApiRoute,
pageLookupCandidates,
stripAnsiEscapeCodes,
serverScriptOutput,
serverScriptOutputErrorMessage,
contentSecurityPolicyHeader,
DEFAULT_RESPONSE_SECURITY,
type ApiRouteDefinition,
Expand All @@ -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. */
Expand Down Expand Up @@ -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<unknown>, 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<typeof serverScriptOutput> | 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. */
Expand Down
102 changes: 102 additions & 0 deletions adapters/netlify/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,3 +525,105 @@ describe("createNetlifyHandler: a script that yields", () => {
expect(await res.text()).not.toContain("<li>1</li>");
});
});

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("<p></p>");
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`<p>${item.name}</p>`.\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 = () => "<b>\u001b[31mred\u001b[0m</b>";
const quiet = { log: () => {}, logEvent: () => {}, logRequest: () => {} };
expect(
await (
await createNetlifyHandler(
graph(scripted(colored)),
quiet,
)(get("/dashboard"))
).text(),
).toBe("<p><b>red</b></p>");
expect(
await (
await createNetlifyHandler(
graph(scripted(colored), { stripAnsi: false }),
quiet,
)(get("/dashboard"))
).text(),
).toBe("<p><b>\u001b[31mred\u001b[0m</b></p>");
});
});
Loading
Loading