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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cloudflare/compiled-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ async function start(directory: string, identity: string) {
export async function compileArtifactSource(source, project) { return compile(source, "client", project); }
export async function compileArtifactServerSource(source, project) { return compile(source, "server", project); }
export async function compileScriptSource() { throw new Error("Unexpected script compile"); }
export async function compilePluginSource() { throw new Error("Unexpected plugin compile"); }
export function typecheckArtifactSource() { return []; }
export function typecheckArtifactServerSource() { return []; }
` }));
Expand Down
59 changes: 46 additions & 13 deletions cloudflare/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import ts from "../dist/cloudflare/typescript.js";
import files from "../dist/cloudflare/compiler-files.json";
import browserPlugins from "../dist/cloudflare/plugin-browser.json";
import browserRuntime from "../dist/cloudflare/browser-runtime.json";
import pluginRuntime from "../dist/cloudflare/plugin-runtime.json";
import { sandboxToDiagnostics, type Diagnostic } from "../src/diagnostics";
import { scanArtifactSource } from "../src/sandbox";
import type { AppManifest, ResourceLedger } from "./native-worker/manifest";
Expand Down Expand Up @@ -183,17 +184,24 @@ export async function compileArtifactServerSource(source: string, project = empt
}

async function queueCompile(source: string, server: boolean, script = false, project = emptyProject(), native?: { manifest: AppManifest; resources: ResourceLedger }) {
const release = await compileSlot();
if (!release) return { ok: false, diagnostics: [{ severity: "error" as const, message: "Compiler is busy; retry shortly", file: server ? serverPath : sourcePath }] };
try { return await (native ? compileNative(source, project, native.manifest, native.resources) : script ? compileScript(source, project) : server ? compileServer(source, project) : compileSource(source, project)); }
finally { release(); }
}

/** Waits for this build's turn and returns its release, or null when busy. */
async function compileSlot(): Promise<(() => void) | null> {
// esbuild WASM and semantic checking share the isolate's 128 MiB budget.
// Serialize builds and bound retained request sources under load.
if (pendingCompilations >= 8) return { ok: false, diagnostics: [{ severity: "error" as const, message: "Compiler is busy; retry shortly", file: server ? serverPath : sourcePath }] };
if (pendingCompilations >= 8) return null;
pendingCompilations++;
const ticket = nextCompilation++;
// celld 0.4.1 attributes native timers to the request driving a continuation.
// Wait on this request's own timer instead of another request's promise, so
// disconnecting a preview cannot cancel the next build's esbuild operations.
while (ticket !== activeCompilation) await new Promise(resolve => setTimeout(resolve, 5));
try { return await (native ? compileNative(source, project, native.manifest, native.resources) : script ? compileScript(source, project) : server ? compileServer(source, project) : compileSource(source, project)); }
finally { activeCompilation++; pendingCompilations--; }
return () => { activeCompilation++; pendingCompilations--; };
}

export function compileNativeWorker(source: string, manifest: AppManifest, resources: ResourceLedger, project = emptyProject()) {
Expand Down Expand Up @@ -330,16 +338,41 @@ async function compileScript(source: string, project: ArtifactProject) {
if (result.warnings?.length) throw new Error(result.warnings.join("\n"));
const js = result.modules[result.mainModule];
if (typeof js !== "string") throw new Error("Script compiler did not return JavaScript");
const tree = ts.createSourceFile("script.js", js, ts.ScriptTarget.ES2022, false, ts.ScriptKind.JS);
const checkImports = (node: ts.Node) => {
const specifier = ts.isImportDeclaration(node) || ts.isExportDeclaration(node) ? node.moduleSpecifier
: ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword ? node.arguments[0] : undefined;
if (specifier && (!ts.isStringLiteral(specifier) || !/^(node:|cloudflare:)/.test(specifier.text))) {
throw new Error("Script has an unresolved external import; use Workers runtime builtins or self-contained source");
}
ts.forEachChild(node, checkImports);
};
checkImports(tree);
assertBuiltinImports(js, "Script has an unresolved external import; use Workers runtime builtins or self-contained source");
return {ok: true, js, diagnostics: [] as Diagnostic[]};
} catch (error) { return {ok: false, diagnostics: [{severity: "error" as const, file: "script.ts", message: error instanceof Error ? error.message : String(error)}]}; }
}

function assertBuiltinImports(js: string, message: string) {
const tree = ts.createSourceFile("module.js", js, ts.ScriptTarget.ES2022, false, ts.ScriptKind.JS);
const checkImports = (node: ts.Node) => {
const specifier = ts.isImportDeclaration(node) || ts.isExportDeclaration(node) ? node.moduleSpecifier
: ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword ? node.arguments[0] : undefined;
if (specifier && (!ts.isStringLiteral(specifier) || !/^(node:|cloudflare:)/.test(specifier.text))) throw new Error(message);
ts.forEachChild(node, checkImports);
};
checkImports(tree);
}

const PLUGIN_ENTRY = 'import plugin from "./plugin.ts";\nimport { runtimePluginHandler } from "artifacts:plugin-runtime";\nexport default runtimePluginHandler(plugin);\n';
const PLUGIN_HELPER = "export function definePlugin(plugin) { return plugin; }\nexport function definePlugins(plugins) { return plugins; }\n";

/** Compiles a published plugin's single-file source with its isolate wrapper.
* It may import the typed `@sidequery/artifacts/plugins` helper and Workers
* builtins only. Shares the compile queue and isolate budget with artifacts. */
export async function compilePluginSource(source: string): Promise<{ ok: true; js: string } | { ok: false; error: string }> {
const release = await compileSlot();
if (!release) return { ok: false, error: "Compiler is busy; retry shortly" };
try {
const result = await createWorker({
files: { "plugin.ts": source, "entry.ts": PLUGIN_ENTRY }, entryPoint: "entry.ts", target: "es2022", sourcemap: false,
virtualModules: { "@sidequery/artifacts/plugins": PLUGIN_HELPER, "artifacts:plugin-runtime": pluginRuntime.js },
});
if (result.warnings?.length) throw new Error(result.warnings.join("\n"));
const js = result.modules[result.mainModule];
if (typeof js !== "string") throw new Error("Plugin compiler did not return JavaScript");
assertBuiltinImports(js, "A published plugin is one self-contained file; it may import @sidequery/artifacts/plugins and Workers builtins only");
return { ok: true, js };
} catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; }
finally { release(); }
}
2 changes: 1 addition & 1 deletion cloudflare/native-gallery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ test("gallery lists Worker drafts across workspaces with owner isolation, pagina
build.onLoad({ filter: /\/cloudflare\/compiler\.ts$/ }, () => ({ loader: "ts", contents: `
export function compileNativeWorker() { return { ok: false, diagnostics: [{ severity: "error", message: "Fixture draft" }] }; }
function unexpected() { throw new Error("Gallery reads must not compile or execute projects"); }
export { unexpected as compileArtifactSource, unexpected as compileArtifactServerSource, unexpected as compileScriptSource,
export { unexpected as compileArtifactSource, unexpected as compileArtifactServerSource, unexpected as compileScriptSource, unexpected as compilePluginSource,
unexpected as typecheckArtifactSource, unexpected as typecheckArtifactServerSource };
` }));
} }],
Expand Down
6 changes: 3 additions & 3 deletions cloudflare/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ const dispatchInstalled = createPluginDispatcher(installed, { input: validatePlu
export async function dispatchPlugin(request: PluginRequest, context?: PluginInvocationContext): Promise<unknown> {
assertPluginRequest(request, context);
if (installedNames.has(request.plugin)) return dispatchInstalled(request, context);
const record = await runtimePlugin(context.env as RuntimePluginEnvironment, request.plugin);
if (!record?.operations.some(operation => operation.name === request.operation)) throw new PluginError("Plugin operation not found", 404);
return callRuntimePlugin(record, request.operation, pluginJson(request.input), context.user, context);
const plugin = await runtimePlugin(context.env as RuntimePluginEnvironment, request.plugin);
if (!plugin?.description.operations.some(operation => operation.name === request.operation)) throw new PluginError("Plugin operation not found", 404);
return callRuntimePlugin(plugin, request.operation, pluginJson(request.input), context.user, context);
}

export async function pluginCatalogFor(env: object): Promise<PluginInfo[]> {
Expand Down
9 changes: 4 additions & 5 deletions cloudflare/runtime-plugins-celld.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { cp, mkdtemp, rm, writeFile } from "node:fs/promises";
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { bundleRuntimePlugin } from "../scripts/bundle-plugin";
import { prepareCelldConfig } from "../scripts/prepare-celld";
import { spawnCelld, stopCelld, waitForReady, type RunningCelld } from "../e2e/celld-auth-runtime";

Expand All @@ -30,7 +29,7 @@ beforeAll(async () => {
const esbuild = process.env.CELLD_ESBUILD ?? resolve(root, "node_modules/.bin/esbuild");
if (!binary) throw new Error("celld was not found; install celld or set CELLD_BIN to its executable");
if (!existsSync(join(root, "dist/worker-app/worker.js"))) throw new Error("missing dist/worker-app/worker.js; run bun run build:cloudflare");
const record = await bundleRuntimePlugin(join(root, "e2e/runtime-plugin-fixture.ts"));
const source = await readFile(join(root, "e2e/runtime-plugin-fixture.ts"), "utf8");
project = await mkdtemp(join(tmpdir(), "artifact-celld-plugins-"));
await cp(join(root, "dist/worker-app"), join(project, "dist/worker-app"), { recursive: true });
await cp(join(root, "dist/cloudflare/assets"), join(project, "dist/cloudflare/assets"), { recursive: true });
Expand All @@ -46,7 +45,7 @@ beforeAll(async () => {
await writeFile(configPath, JSON.stringify({ name: config.name, main: "seed.js", compatibility_date: config.compatibility_date, r2_buckets: [plugins] }));
runtime = spawnCelld(binary, esbuild, project, port);
await waitForReady(runtime, origin);
const seeded = await fetch(`${origin}/?key=plugins/test-warehouse.json`, { method: "PUT", body: JSON.stringify(record) });
const seeded = await fetch(`${origin}/?key=plugins/test-warehouse.ts`, { method: "PUT", body: source });
if (!seeded.ok) throw new Error(`Seeding failed: ${seeded.status}\n${runtime.logs()}`);
await stopCelld(runtime);
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
Expand All @@ -59,7 +58,7 @@ afterAll(async () => {
if (project) await rm(project, { recursive: true, force: true });
});

celldTest("a published plugin runs in its own isolate on celld", async () => {
celldTest("a published plugin source compiles and runs in its own isolate on celld", async () => {
const client = new Client({ name: "runtime-plugin-celld", version: "1" });
try {
await client.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=test`)));
Expand Down
Loading
Loading