fix: validate sandbox plugin exports and fix plugin packaging (#363)

* fix(webhook-notifier): add build step and export built files

The webhook-notifier plugin exported raw TypeScript source from its
package.json exports (./sandbox pointed to src/sandbox-entry.ts).
When the Vite plugin resolved this at site build time, it embedded
unbuilt TypeScript into the sandbox module, causing "Unexpected token
'{'" errors at runtime.

Add a tsdown build step (matching sandboxed-test's pattern) and update
the exports map to point to dist/*.mjs.

Fixes #150

* fix(core): reject unbuilt source in sandbox module generator and bundle validator

Add two validation checks to prevent plugins with misconfigured exports
from silently breaking site builds:

1. generateSandboxedPluginsModule() now throws a clear error if a
   sandbox entrypoint resolves to a TypeScript/JSX source file instead
   of pre-built JavaScript. This catches the problem at site build time
   with an actionable message.

2. The `emdash bundle` command now validates that all package.json
   exports point to built files (.js/.mjs), not source (.ts/.tsx/.jsx).
   This catches the misconfiguration at plugin publish time, before
   consumers are affected.

Fixes #150

* chore: add changeset for sandbox source validation

* fix: use slash syntax for e18e rule override in oxlintrc

The test file override for e18e/prefer-static-regex used parenthesis
syntax ("e18e(prefer-static-regex)") which is the diagnostic display
format, not the config format. Changed to slash syntax to match the
top-level rule declarations so the override actually takes effect.

* test: add tests for sandbox source validation

Add tests for both validation checks:

- generateSandboxedPluginsModule: verifies it embeds pre-built JS,
  rejects .ts/.tsx/.mts source files, and includes the plugin ID in
  error messages.

- findSourceExports: verifies it flags .ts/.tsx/.mts/.cts/.jsx exports,
  accepts .mjs/.js exports, and handles conditional export maps.

Also extracts findSourceExports() from the inline bundle.ts validation
into bundle-utils.ts so it can be tested without the CLI harness.

* fix(atproto, audit-log): add build step and export built files

Same issue as webhook-notifier — both plugins exported raw TypeScript
source from their package.json sandbox exports. Add tsdown build steps
and update exports to point to dist/*.mjs.

* refactor(smoke): replace sequential per-site astro builds with recursive pnpm build

The build verification section was running `astro build` individually
and sequentially for every demo and template (~12 sites). Replace with
a single `pnpm run --recursive --filter {./demos/*} --filter
{./templates/*} build` which pnpm parallelizes automatically.
This commit is contained in:
Matt Kane
2026-04-07 22:34:35 +01:00
committed by GitHub
parent 885df3b3a8
commit 91e31fb2ca
12 changed files with 321 additions and 48 deletions

View File

@@ -14,6 +14,8 @@ import type { MediaProviderDescriptor } from "../../media/types.js";
import { defaultSeed } from "../../seed/default.js";
import type { PluginDescriptor } from "./runtime.js";
const TS_SOURCE_EXT_RE = /^\.(ts|tsx|mts|cts|jsx)$/;
/** Pattern to remove scoped package prefix from plugin ID */
const SCOPED_PREFIX_PATTERN = /^@[^/]+\/plugin-/;
@@ -435,7 +437,17 @@ export const sandboxedPlugins = [];
// Resolve the bundle to a file path using project's require context
const filePath = resolveModulePathFromProject(bundleSpecifier, projectRoot);
// Read the source code
const ext = filePath.slice(filePath.lastIndexOf("."));
if (TS_SOURCE_EXT_RE.test(ext)) {
throw new Error(
`Sandboxed plugin "${descriptor.id}" entrypoint "${bundleSpecifier}" resolves to ` +
`unbuilt source (${filePath}). Sandbox entries must be pre-built JavaScript. ` +
`Ensure the plugin's package.json exports point to built files (e.g. dist/*.mjs) ` +
`and run the plugin's build step before building the site.`,
);
}
const code = readFileSync(filePath, "utf-8");
// Create the plugin entry with embedded code and sandbox config

View File

@@ -213,6 +213,32 @@ export async function resolveSourceEntry(
return undefined;
}
// ── Export validation ───────────────────────────────────────────────────────
const TS_SOURCE_EXPORT_RE = /\.(?:ts|tsx|mts|cts|jsx)$/;
/**
* Find package.json exports that point to source files instead of built output.
* Returns an array of `{ exportPath, resolvedPath }` for each offending export.
*/
export function findSourceExports(
exports: Record<string, unknown>,
): Array<{ exportPath: string; resolvedPath: string }> {
const issues: Array<{ exportPath: string; resolvedPath: string }> = [];
for (const [exportPath, exportValue] of Object.entries(exports)) {
const resolved =
typeof exportValue === "string"
? exportValue
: exportValue && typeof exportValue === "object" && "import" in exportValue
? (exportValue as { import: string }).import
: null;
if (resolved && TS_SOURCE_EXPORT_RE.test(resolved)) {
issues.push({ exportPath, resolvedPath: resolved });
}
}
return issues;
}
// ── Directory helpers ────────────────────────────────────────────────────────
/**

View File

@@ -27,6 +27,7 @@ import {
extractManifest,
findNodeBuiltinImports,
findBuildOutput,
findSourceExports,
resolveSourceEntry,
calculateDirectorySize,
createTarball,
@@ -495,6 +496,20 @@ export const bundleCommand = defineCommand({
consola.start("Validating bundle...");
let hasErrors = false;
// Check that package.json exports point to built files, not source.
// Plugins published to npm with source exports will break site builds
// because the sandbox module generator embeds the resolved file as-is.
if (pkg.exports) {
for (const issue of findSourceExports(pkg.exports as Record<string, unknown>)) {
consola.error(
`Export "${issue.exportPath}" points to source (${issue.resolvedPath}). ` +
`Package exports must point to built files (e.g. dist/*.mjs). ` +
`Add a build step and update the exports map.`,
);
hasErrors = true;
}
}
// Check for Node.js builtins in backend.js
const backendPath = join(bundleDir, "backend.js");
if (await fileExists(backendPath)) {

View File

@@ -181,39 +181,37 @@ async function fetchWithRetry(url: string, retries = 10, delayMs = 1500): Promis
}
// ---------------------------------------------------------------------------
// Build verification — runs `astro build` for every site to catch adapter
// and bundling errors that dev mode doesn't surface.
// Build verification — runs a single recursive `pnpm build` across all demos
// and templates in parallel, then verifies each site produced output.
// ---------------------------------------------------------------------------
describe.sequential("Site build verification", () => {
const BUILD_TIMEOUT = 120_000;
describe("Site build verification", () => {
it("all demos and templates build successfully", { timeout: 300_000 }, async () => {
await ensureBuilt();
for (const site of SITE_MATRIX) {
if (site.mode === "typecheck") continue;
it(`${site.name} builds successfully`, { timeout: BUILD_TIMEOUT + 30_000 }, async () => {
await ensureBuilt();
try {
await execAsync("pnpm", ["exec", "astro", "build"], {
cwd: site.dir,
timeout: BUILD_TIMEOUT,
try {
await execAsync(
"pnpm",
["run", "--recursive", "--filter", "{./demos/*}", "--filter", "{./templates/*}", "build"],
{
cwd: WORKSPACE_ROOT,
timeout: 240_000,
env: {
...process.env,
CI: "true",
},
});
} catch (error) {
const stderr =
error instanceof Error && "stderr" in error ? (error as { stderr: string }).stderr : "";
const stdout =
error instanceof Error && "stdout" in error ? (error as { stdout: string }).stdout : "";
throw new Error(`${site.name} build failed:\n\n${stderr || stdout}`.slice(0, 5000), {
cause: error,
});
}
});
}
},
);
} catch (error) {
const stderr =
error instanceof Error && "stderr" in error ? (error as { stderr: string }).stderr : "";
const stdout =
error instanceof Error && "stdout" in error ? (error as { stdout: string }).stdout : "";
throw new Error(`Site builds failed:\n\n${stderr || stdout}`.slice(0, 5000), {
cause: error,
});
}
});
});
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,113 @@
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { PluginDescriptor } from "../../../src/astro/integration/runtime.js";
import { generateSandboxedPluginsModule } from "../../../src/astro/integration/virtual-modules.js";
function descriptor(overrides: Partial<PluginDescriptor> = {}): PluginDescriptor {
return {
id: "test-plugin",
version: "1.0.0",
entrypoint: "@test/plugin/sandbox",
format: "standard",
capabilities: [],
allowedHosts: [],
storage: {},
adminPages: [],
adminWidgets: [],
...overrides,
};
}
describe("generateSandboxedPluginsModule", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "emdash-vm-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
async function setupFakeProject(exportPath: string, content: string) {
// Create a fake project root with package.json
await writeFile(join(tmpDir, "package.json"), JSON.stringify({ name: "test-project" }));
// Create the plugin package inside node_modules
const pluginDir = join(tmpDir, "node_modules", "@test", "plugin");
await mkdir(pluginDir, { recursive: true });
// Determine the directory for the export file
const fileParts = exportPath.split("/");
if (fileParts.length > 1) {
const dir = join(pluginDir, ...fileParts.slice(0, -1));
await mkdir(dir, { recursive: true });
}
await writeFile(join(pluginDir, exportPath), content);
await writeFile(
join(pluginDir, "package.json"),
JSON.stringify({
name: "@test/plugin",
exports: { "./sandbox": `./${exportPath}` },
}),
);
}
it("returns empty module when no plugins configured", () => {
const result = generateSandboxedPluginsModule([], tmpDir);
expect(result).toContain("export const sandboxedPlugins = []");
});
it("embeds pre-built JavaScript successfully", async () => {
await setupFakeProject("dist/sandbox-entry.mjs", "export default { hooks: {} };");
const result = generateSandboxedPluginsModule(
[descriptor({ entrypoint: "@test/plugin/sandbox" })],
tmpDir,
);
expect(result).toContain("sandboxedPlugins");
expect(result).toContain("test-plugin");
expect(result).toContain("export default { hooks: {} };");
});
it("throws for .ts source files", async () => {
await setupFakeProject("src/sandbox-entry.ts", "export default {};");
expect(() =>
generateSandboxedPluginsModule([descriptor({ entrypoint: "@test/plugin/sandbox" })], tmpDir),
).toThrow(/unbuilt source/);
});
it("throws for .tsx source files", async () => {
await setupFakeProject("src/sandbox-entry.tsx", "export default {};");
expect(() =>
generateSandboxedPluginsModule([descriptor({ entrypoint: "@test/plugin/sandbox" })], tmpDir),
).toThrow(/unbuilt source/);
});
it("throws for .mts source files", async () => {
await setupFakeProject("src/sandbox-entry.mts", "export default {};");
expect(() =>
generateSandboxedPluginsModule([descriptor({ entrypoint: "@test/plugin/sandbox" })], tmpDir),
).toThrow(/unbuilt source/);
});
it("includes plugin id in error message", async () => {
await setupFakeProject("src/sandbox-entry.ts", "export default {};");
expect(() =>
generateSandboxedPluginsModule(
[descriptor({ id: "my-broken-plugin", entrypoint: "@test/plugin/sandbox" })],
tmpDir,
),
).toThrow(/my-broken-plugin/);
});
});

View File

@@ -21,6 +21,7 @@ import {
resolveSourceEntry,
findNodeBuiltinImports,
findBuildOutput,
findSourceExports,
} from "../../../src/cli/commands/bundle-utils.js";
import type { ResolvedPlugin } from "../../../src/plugins/types.js";
@@ -298,3 +299,62 @@ describe("findNodeBuiltinImports", () => {
expect(findNodeBuiltinImports(code)).toEqual(["fs"]);
});
});
describe("findSourceExports", () => {
it("flags .ts exports", () => {
const issues = findSourceExports({ ".": "./src/index.ts" });
expect(issues).toEqual([{ exportPath: ".", resolvedPath: "./src/index.ts" }]);
});
it("flags .tsx exports", () => {
const issues = findSourceExports({ "./admin": "./src/admin.tsx" });
expect(issues).toEqual([{ exportPath: "./admin", resolvedPath: "./src/admin.tsx" }]);
});
it("flags .mts exports", () => {
const issues = findSourceExports({ ".": "./src/index.mts" });
expect(issues).toHaveLength(1);
});
it("flags .cts exports", () => {
const issues = findSourceExports({ ".": "./src/index.cts" });
expect(issues).toHaveLength(1);
});
it("flags .jsx exports", () => {
const issues = findSourceExports({ ".": "./src/index.jsx" });
expect(issues).toHaveLength(1);
});
it("accepts .mjs exports", () => {
const issues = findSourceExports({ ".": "./dist/index.mjs" });
expect(issues).toEqual([]);
});
it("accepts .js exports", () => {
const issues = findSourceExports({ ".": "./dist/index.js" });
expect(issues).toEqual([]);
});
it("handles conditional exports with import field", () => {
const issues = findSourceExports({
".": { import: "./src/index.ts", types: "./dist/index.d.mts" },
});
expect(issues).toEqual([{ exportPath: ".", resolvedPath: "./src/index.ts" }]);
});
it("accepts conditional exports pointing to built files", () => {
const issues = findSourceExports({
".": { import: "./dist/index.mjs", types: "./dist/index.d.mts" },
});
expect(issues).toEqual([]);
});
it("flags multiple bad exports", () => {
const issues = findSourceExports({
".": "./src/index.ts",
"./sandbox": "./src/sandbox-entry.ts",
});
expect(issues).toHaveLength(2);
});
});