/**
* Render an artifact once, in a sandbox, to find out whether it renders at all.
*
* The failure this exists for: a model writes `` without
* a ``, `create-artifact` accepts it, the row saves, and then
* the page dies with `useChart must be used within a `. The
* artifact is broken, the user sees an error box, and the model — which got a
* success back — has no idea. Rendering it once at create time moves that
* discovery to the only moment it is cheap: the model is still holding the
* code, and can fix it and retry before anyone sees it.
*
* ## Where the code runs, and why that is the whole design
*
* The code being rendered was written by a model. It is untrusted, exactly as
* untrusted as anything `execute` runs — so it runs in the same place: a
* QuickJS sandbox from the kernel. It never runs in this process.
*
* That is not a precaution, it is the only defensible arrangement. An earlier
* version of this file compiled the artifact and built its component with
* `new Function` right here, in the trusted server, relying on the ~280-name
* scope to shadow anything dangerous. Shadowing is not a sandbox: the scope
* only binds identifiers the code chooses to mention, and `globalThis.process`
* or `Function("return process")()` walk straight past it to the host's
* environment — its secrets, its database handle, its network. There is no
* version of that idea that is safe, so none of it survives here.
*
* What crosses the boundary now is a string in and a string out. `code` is
* embedded as a JSON literal in the expression the sandbox evaluates, and a
* JSON `SmokeRenderResult` comes back. The renderer itself — React,
* react-dom/server, the component barrel, the tools proxy, the QueryClient —
* is prebuilt into `smoke-harness-bundle.gen.ts` and lives entirely inside the
* sandbox. This process holds no React at all.
*
* ## One path, every host
*
* QuickJS is a WASM interpreter, so `new Function` inside it is QuickJS
* building a QuickJS function — not V8 compiling a string. workerd's ban on
* runtime codegen is a ban on V8 codegen, which is why this same path works on
* Cloudflare, and why the old "probe `new Function`, skip the check where it
* throws" carve-out is gone along with the in-process evaluator. Cloud used to
* silently smoke-render nothing; now it runs the real check like everyone else.
*
* ## What it does and does not catch
*
* It catches a SYNCHRONOUS FIRST-RENDER THROW: a missing provider, a
* ReferenceError, a bad hook call, reading a property of something that is
* undefined before any data has arrived. That last one is not a false positive
* — `data` genuinely is undefined while a query is pending, so a component that
* dereferences it without a guard crashes for real, every time, in front of the
* user. Catching it is the point.
*
* It does NOT catch data-shape errors: code that only throws once a tool
* returns a payload of an unexpected shape. Nothing is fetched here, and
* inventing a plausible payload would be worse than not trying — it would
* reject correct code whenever the guess was wrong. That class stays a runtime
* error inside the frame, where the shell's error boundary already reports it.
*
* ## How it stays faithful to the real renderer
*
* Everything the component sees comes from the same modules the iframe uses:
* `compileJsx`/`evaluateComponent` for the scope, `createToolsProxy` for
* `tools`, and the same `TooltipProvider` + `QueryClientProvider` stack the
* inner renderer mounts, with the same QueryClient defaults — all of it now
* compiled into the harness rather than imported here. Anything that drifts is
* a bug in one shared source rather than a divergence between two.
*
* The one deliberate difference is the transport: every tool call returns a
* promise that never settles, so every query stays `isPending` and the
* component renders its loading state. That is exactly the state the user sees
* first in the real frame, and it is the state a first-render crash happens in.
*
* ## Boundedness
*
* The sandbox's interrupt handler preempts the interpreter, so a synchronous
* `while (true)` in a component body is now stopped at the deadline. In-process
* it could not be — no timeout can interrupt a spinning host thread — and such
* an artifact hung the whole request. Getting that for free is a second reason
* this belongs in the kernel.
*
* ## Fail open
*
* A missing harness bundle, an unavailable sandbox, a deadline: all `ok`.
* "Inconclusive" must never read as "broken", because the cost of a false
* rejection is a model unable to save correct code. Only a render error from
* the artifact ITSELF returns `failed`.
*/
import { executeSealedBundle } from "@executor-js/runtime-quickjs";
import * as Effect from "effect/Effect";
import harnessBundle from "./smoke-harness-bundle.gen";
import type { SmokeRenderResult } from "./smoke-render-result";
export type { SmokeRenderResult } from "./smoke-render-result";
/** How long the render may take before it is abandoned as inconclusive. An
* ordinary artifact loads the harness and renders in a few hundred
* milliseconds; this only bounds pathological code. */
const RENDER_DEADLINE_MS = 10_000;
const OK: SmokeRenderResult = { status: "ok" };
/**
* How the sandbox is reached. Indirected through a mutable binding for exactly
* one reason: the fail-open contract is the most important behaviour here and
* the hardest to provoke honestly — a real missing sandbox cannot be arranged
* from a test without breaking the runtime for every other test in the file.
* Swapping this seam simulates it at the boundary the host actually depends on.
*/
type SealedBundleRunner = typeof executeSealedBundle;
let runSealedBundle: SealedBundleRunner = executeSealedBundle;
/**
* Replace the sandbox call for the duration of a scope. Test-only, and
* `Disposable` so it cannot leak past the `using` that installed it.
*/
export const stubSealedBundle = (runner: SealedBundleRunner): Disposable => {
const previous = runSealedBundle;
runSealedBundle = runner;
return {
[Symbol.dispose]: () => {
runSealedBundle = previous;
},
};
};
/** Narrow the sandbox's JSON back to the contract. Anything unrecognized is
* `ok` — see the fail-open note in the header. */
const parseResult = (json: string): SmokeRenderResult => {
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: parsing a value that crossed a sandbox boundary
try {
const parsed: unknown = JSON.parse(json);
if (typeof parsed !== "object" || parsed === null) return OK;
const record = parsed as {
status?: unknown;
message?: unknown;
componentStack?: unknown;
markup?: unknown;
};
if (record.status !== "failed" || typeof record.message !== "string") {
// An `ok` verdict may carry the render's markup. It is still only a
// preview: a value of the wrong type is dropped rather than propagated,
// because "no preview" is always a safe answer and a malformed one is
// not.
return typeof record.markup === "string" && record.markup !== ""
? { status: "ok", markup: record.markup }
: OK;
}
return {
status: "failed",
message: record.message,
...(typeof record.componentStack === "string"
? { componentStack: record.componentStack }
: {}),
};
} catch {
return OK;
}
};
/**
* Render `code` once in a sandbox. Throws nothing: every outcome is a result.
*
* A compile error or a missing `App` comes back as `failed` too — the model
* gets the same message it would have seen in the frame, at the moment it can
* still act on it.
*/
export const smokeRenderArtifact = async (code: string): Promise => {
// No harness in this build. Nothing can be concluded, so conclude nothing.
if (harnessBundle === null) return OK;
return Effect.runPromise(
runSealedBundle({
bundle: harnessBundle,
// The artifact's source crosses as a JSON string literal inside the
// expression — the only value this process hands the sandbox, and never
// as a global it could be reached through.
call: `globalThis.__executorSmokeRender(${JSON.stringify(code)})`,
timeoutMs: RENDER_DEADLINE_MS,
}).pipe(
Effect.map(parseResult),
// A sandbox-side failure is not the artifact's fault. Log it and save.
Effect.catch((error) =>
Effect.as(Effect.logWarning("artifact smoke render was unavailable", error), OK),
),
),
);
};