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
7 changes: 7 additions & 0 deletions .changeset/embed-bundled-assets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@webmcp-stack/codegen": patch
---

Embed the agent skill and the journey helper in the build instead of reading them from the package at runtime.

The hosted playground deployed without the package's `assets/` directory and failed at runtime with "bundled asset missing: journey.webmcp.ts". A `readFile` of the package's own files is invisible to bundlers and file tracers, so the assets were never part of the deployment. They are now embedded at build time, which removes the runtime read entirely, needs no per-host tracing configuration, and leaves `assets/` as the reviewed source of truth (a test asserts the embed stays byte for byte equal to it).
41 changes: 41 additions & 0 deletions docs/notes/2026-09-18-hosted-playground.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,44 @@ frame. No fake persistence, no account, no storage.
the page promises not to have.
- No `/playground` entry in `sitemap.ts`. It is a tool, not a page to rank; the docs page
and the nav link are the way in. Revisit if it earns organic traffic.

## Follow-up: the first deploy shipped without its assets (same day)

The deployed playground failed on every request:

```
webmcp-codegen: bundled asset missing: journey.webmcp.ts
(looked in /vercel/path0/packages/codegen/assets/journey.webmcp.ts, ...)
```

The tools output always writes the journey helper and the agent skill, and `assetText()`
read them from the package's `assets/` directory with `fs` at runtime. Two things kept
that invisible until production:

- webpack bundles the package into the route and rewrites `import.meta.url` to the build
machine's absolute path, so on a developer's machine the read succeeded from their own
checkout.
- Next's file tracer cannot see a `readFile` whose path is computed, so `assets/` was
never in the function bundle. The traced-files manifest
(`site/.next/server/app/api/playground/route.js.nft.json`) listed the package's
`package.json` and nothing else.

Rejected fixes, and why:

- Add the folder to the trace (`outputFileTracingIncludes`): works, but it fixes this one
host and leaves every other serverless use of the pipeline to rediscover the trap.
- Static `new URL("../assets/...", import.meta.url)` references so tracers notice the
files: webpack rewrites those into web-served asset URLs (`/_next/static/media/...`)
that `readFile` cannot open. Verified by deleting `assets/` and running the production
server, which still failed.

What shipped: the assets are embedded at package build time. `assets.ts` imports them as
text (`?raw`), which vitest resolves natively and tsup resolves with a small esbuild
plugin, so the shipped code carries the text and no host has to be told about the files.
`assets/` stays the reviewed source of truth, and a test asserts the embed matches it
byte for byte.

Verified against the deployment condition itself: with `packages/codegen/assets` deleted,
`next build` plus `next start` generated 19 tools from the Petstore spec and 6 from the
Immich excerpt, both HTTP 200. Before the fix the same test returned 422 with the error
above.
4 changes: 2 additions & 2 deletions packages/codegen/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
"assets"
],
"scripts": {
"build": "tsup src/index.ts src/cli.ts src/sources/index.ts src/outputs/index.ts src/dev/index.ts src/dev/server.ts src/dev/ui.ts --format esm --dts --sourcemap --clean",
"dev": "tsup src/index.ts src/cli.ts src/sources/index.ts src/outputs/index.ts src/dev/index.ts src/dev/server.ts src/dev/ui.ts --format esm --dts --sourcemap --watch",
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
Expand Down
31 changes: 31 additions & 0 deletions packages/codegen/src/outputs/assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { assetText } from "./assets.js";

/**
* The build embeds these files (see the `?raw` imports in assets.ts) so the
* shipped package never reads its own files at runtime. That embedding is
* also the deployment fix: a runtime read is invisible to bundlers and file
* tracers, which is how the hosted playground deployed without assets/ and
* failed with "bundled asset missing".
*
* These tests are the guard on the other side: the embedded text must stay
* byte-for-byte equal to the reviewed file, because a stale embed would ship
* an old journey helper or agent skill and nothing else would notice.
*/

const ASSETS = new URL("../../assets/", import.meta.url);

describe("assetText", () => {
it("returns each reviewed asset byte for byte", async () => {
for (const name of ["journey.webmcp.ts", "skill/SKILL.md"]) {
const onDisk = await readFile(new URL(name, ASSETS), "utf8");
expect(await assetText(name)).toBe(onDisk);
}
});

it("says which assets exist when asked for one that does not", async () => {
await expect(assetText("nope.md")).rejects.toThrow(/unknown asset: nope\.md/);
await expect(assetText("nope.md")).rejects.toThrow(/journey\.webmcp\.ts, skill\/SKILL\.md/);
});
});
41 changes: 24 additions & 17 deletions packages/codegen/src/outputs/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,33 @@
* These ship as files in the published package rather than as template
* strings in source because they are also the reviewable artifacts - the
* design docs and the docs site point at assets/ directly, and two sources
* of truth would drift. The package publishes dist + assets; the two
* candidate roots cover the built layout (dist/x.js -> ../assets) and the
* source tree under test (src/outputs/x.ts -> ../../assets).
* of truth would drift. assets/ stays the source; the *build* embeds a
* snapshot of it (the `?raw` imports below), so the shipped code carries the
* text and never reads its own files at runtime.
*
* That second part is not a micro-optimization. A runtime readFile() of a
* package's own files is invisible to bundlers and file tracers, and the
* hosted playground shipped without assets/ and threw "bundled asset
* missing" in production. Embedding removes the runtime read, so no host
* has to be told about these files. Add the import for any new asset.
*/

import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import journeyHelper from "../../assets/journey.webmcp.ts?raw";
import agentSkill from "../../assets/skill/SKILL.md?raw";

/** Every asset a host can ask for, embedded at build time. */
const ASSETS: Record<string, string> = {
"journey.webmcp.ts": journeyHelper,
"skill/SKILL.md": agentSkill,
};

export async function assetText(name: string): Promise<string> {
const here = dirname(fileURLToPath(import.meta.url));
const candidates = [join(here, "..", "assets", name), join(here, "..", "..", "assets", name)];
for (const candidate of candidates) {
try {
return await readFile(candidate, "utf8");
} catch {
// Try the next layout.
}
const text = ASSETS[name];
if (text === undefined) {
throw new Error(
`webmcp-codegen: unknown asset: ${name}. ` +
`Add it to src/outputs/assets.ts. Known: ${Object.keys(ASSETS).join(", ")}.`,
);
}
throw new Error(
`webmcp-codegen: bundled asset missing: ${name} (looked in ${candidates.join(", ")})`,
);
return text;
}
10 changes: 10 additions & 0 deletions packages/codegen/src/raw-assets.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* `?raw` imports come back as strings.
*
* vitest resolves them natively; the published build does it with the plugin
* in tsup.config.ts. Mirrors what vite/client declares for the same syntax.
*/
declare module "*?raw" {
const text: string;
export default text;
}
48 changes: 48 additions & 0 deletions packages/codegen/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { defineConfig, type Options } from "tsup";

/**
* The build, plus one plugin.
*
* `?raw` imports (see src/outputs/assets.ts) are how the agent skill and the
* journey helper travel: vitest resolves them natively, and this plugin does
* the same for the shipped build, so the package carries their text instead
* of reading its own files at runtime.
*
* That matters beyond tidiness. A runtime read of a package's own files is
* invisible to bundlers and file tracers, and the hosted playground deployed
* without assets/ and threw "bundled asset missing" in production. With the
* text embedded, no host has to be told about these files at all.
*/
const embedRawAssets: NonNullable<Options["esbuildPlugins"]>[number] = {
name: "embed-raw-assets",
setup(build) {
build.onResolve({ filter: /\?raw$/ }, (args) => ({
// The real path, without the suffix, resolved from the importer.
path: resolve(dirname(args.importer), args.path.replace(/\?raw$/, "")),
namespace: "raw-asset",
}));
build.onLoad({ filter: /.*/, namespace: "raw-asset" }, async (args) => ({
contents: `export default ${JSON.stringify(await readFile(args.path, "utf8"))};`,
loader: "js",
}));
},
};

export default defineConfig({
entry: [
"src/index.ts",
"src/cli.ts",
"src/sources/index.ts",
"src/outputs/index.ts",
"src/dev/index.ts",
"src/dev/server.ts",
"src/dev/ui.ts",
],
format: ["esm"],
dts: true,
sourcemap: true,
clean: true,
esbuildPlugins: [embedRawAssets],
});
Loading