Skip to content

Write the bundled format blueprints in TypeScript - #466

Draft
Maximo-Guk wants to merge 19 commits into
mainfrom
maximo/blueprints-typescript
Draft

Write the bundled format blueprints in TypeScript#466
Maximo-Guk wants to merge 19 commits into
mainfrom
maximo/blueprints-typescript

Conversation

@Maximo-Guk

@Maximo-Guk Maximo-Guk commented Sep 9, 2026

Copy link
Copy Markdown
Member

A blueprint's files/ may be TypeScript: client.ts and server.ts are bundled with their lib/**/*.ts imports into the shipped client.js / server.js, and type-checked under each side's own globals. The Docs, Sheets and Slides blueprints are rewritten this way, and the code they share lives once as gadget libraries, imported as gadgets:<name>/client and gadgets:<name>/server and inlined by the build, so nothing changes at runtime.

The blueprints, the libraries and the build now live in their own package, packages/bundled-blueprints (@gadgets/bundled-blueprints): blueprints/, libraries/, src/. The Workshop backend keeps two thin CLIs (scripts/build-bundled-blueprints.ts, scripts/import-bundled-blueprint.ts) and installs the generated module. The mechanism is renamed from "format" to "bundled" throughout, FORMAT_BLUEPRINTS_DIR included (now BUNDLED_BLUEPRINTS_DIR, no fallback); the blueprint IDs format.* are unchanged. Making the gadgets: imports resolve at load time (PR #465) is re-ported onto this layout separately.

🤖 Generated with Claude Code

A blueprint's files/ may now be TypeScript: client.ts and server.ts are
bundled with their lib/**/*.ts imports into the client.js / server.js the
archive ships, readable rather than minified, so the installed gadget and
the agent that edits it still see one JavaScript file per side. Three
tsconfigs type-check the result under the globals each side really has:
the client under the DOM lib, the server under the Workers types as part
of this package's own program, and the blueprints' own tests under Node's.
A lib/ module is checked under whichever side imports it.

The build rejects what would otherwise ship wrong: an entry present as
both .ts and .js, a .ts file outside the entry/lib layout, .tsx/.mts/.cts,
a lib/ module no entry imports, an import that reaches outside files/, and
an import the entry's runtime does not supply (esbuild leaves a URL import
external without a word).

The Docs, Sheets and Slides blueprints are rewritten this way, each with a
lib/protocol.ts holding the types its two sides share, and the DOM helpers
and collaboration plumbing the three have in common factored into typed
modules under lib/ui/ and lib/sync/, one copy per blueprint. The Sheets
formula engine moves to lib/formula.ts with unit tests beside it, and the
latent mismatches the types exposed are fixed. The blueprints' Node-side
tests run under vitest.blueprints.config.ts, since the workerd suite can
neither spawn esbuild nor provide jsdom.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions github-actions Bot added the kernel Changes to the Workshop kernel label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Preview: pr466-maximo-bluepr-50dca8d3

https://pr466-maximo-bluepr-50dca8d3-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

Comment thread packages/workshop-backend/tsconfig.blueprints-client.json Outdated
Comment thread packages/bundled-blueprints/src/files.ts Outdated
Comment thread packages/bundled-blueprints/src/files.ts
@ask-bonk

ask-bonk Bot commented Sep 9, 2026

Copy link
Copy Markdown

Posted 3 actionable inline findings.

github run

The DOM helpers and collaboration plumbing the Docs, Sheets and Slides
blueprints have in common move from three copies under each blueprint's
lib/ to one source in packages/gadget-libraries, with the libraries' own
tests. A blueprint imports them as `gadgets:<name>/client` and
`gadgets:<name>/server`; the blueprint build resolves each to the library's
entry and inlines what the entry uses into the shipped client.js /
server.js, the way it inlines a lib/ module. The archive stays
self-contained and nothing resolves the specifier at runtime, so the kernel
is untouched; a gadget created from a blueprint carries its copy of the
library as of its creation, as it does today.

The build rejects a client importing a library's server side, a library
that does not exist, a malformed specifier, and any input from
node_modules, and the libraries directory is the one place an import may
reach outside files/. The blueprint tsconfigs map the specifier to the same
entry, and the workerd suite aliases it for the tests that import a
blueprint's server directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread packages/workshop-backend/scripts/format-blueprint-files.ts Outdated
Comment thread packages/bundled-blueprints/src/files.ts Outdated
Comment thread packages/workshop-backend/tsconfig.blueprints-tests.json Outdated
Comment thread packages/workshop-backend/scripts/format-blueprint-files.ts Outdated
@ask-bonk

ask-bonk Bot commented Sep 9, 2026

Copy link
Copy Markdown

Posted 4 actionable inline findings.

github run

…nto the checks

Review of #466 found seven holes in the TypeScript blueprint build, all
real, plus one README claim that was not.

The build script:

- A relative import written in a blueprint may not leave files/. The
  previous check ran on the metafile after esbuild had resolved
  everything, and allowed any input under packages/gadget-libraries --
  so `../../../../gadget-libraries/ui/src/el.ts` from client.ts was
  accepted, bypassing the `gadgets:` specifier's side check and reaching
  into a library's private modules. A plugin now refuses the import at
  resolve time whenever the importer is inside files/ and the target is
  not, so the specifier is the libraries' only door. The metafile check
  stays as defense in depth (an input outside files/ must be under the
  libraries and never node_modules).
- Containment treats an absolute `relative()` result (a different drive
  on Windows) as outside, in one `contains` helper both checks use.
- A dynamic `import()` of a computed path is rejected. esbuild inlines a
  literal one like a static import, but leaves `import(p)` in the bundle
  as written, where it would resolve inside the sandbox against nothing
  the build checked. Comments cannot trip the scan: esbuild drops them.
- A `lib/x.ts` beside a `lib/x.js` is rejected like an entry twin was:
  tsc would type the .ts while the bundle shipped the .js.
- Roots are realpath'd before bundling: esbuild reports importers with
  symlinks resolved, so on macOS a blueprint under /var/folders was
  compared against a /private/var path and the plugin never fired.

The type-check and test wiring:

- `moduleDetection: force` in the three blueprint tsconfigs. Each entry
  is bundled alone, but an entry without an import or export was a
  global script to tsc, sharing declarations with another blueprint's.
  It is a no-op for the backend's own `src/`, which shares the server
  program and whose modules all import or export.
- `tsconfig.blueprints-tests.json` gets the `gadgets:*` path mapping and
  the `blueprint-lib` vitest project the matching alias, so a blueprint
  test can import a module that reaches a library.
- `packages/gadget-libraries` is a Worker input for the integration
  suite's watch mode: the build inlines it into the archives the Worker
  ships, so a library edit is a Worker change too.

The gadget-libraries README said the doc comments in `src/` reach the
agent through the inlined code. They do not: esbuild drops ordinary
comments whatever the minify settings. The bundle is unminified, so a
library's names and structure survive; its comments are for this
repository's readers. The README also states the relative-path rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ask-bonk

ask-bonk Bot commented Sep 9, 2026

Copy link
Copy Markdown

@Maximo-Guk Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

Comment thread packages/workshop-backend/scripts/format-blueprint-files.ts Outdated
Comment thread packages/bundled-blueprints/src/files.ts Outdated
Comment thread packages/workshop-backend/tsconfig.blueprints-server.json Outdated
Comment thread packages/bundled-blueprints/libraries/sync/src/subscribers.ts
Comment thread packages/bundled-blueprints/libraries/sync/src/save-scheduler.ts
@ask-bonk

ask-bonk Bot commented Sep 9, 2026

Copy link
Copy Markdown

Posted 6 actionable inline findings.

github run

…emaining gaps

Sheets kept its structure snapshot and whole-sheet replacements queued
across a rejected applyOperation, for the scheduler's retry. A rejection
is ambiguous -- Cap'n Web rejects every in-flight call when the socket
drops, after the server may have committed -- and the server applies both
payloads wholesale, so the retry could overwrite a collaborator's edit
made in the gap. The next attempt after a failure now fetches the
document first; if its revision is not the one the payloads were built
against, they are dropped, the server's copy adopted, and only the
versioned cell ops go out. The client still subscribes once at init and
does not re-subscribe after a broken session; that is a follow-up.

Formula: ROW and COLUMN look through the paren node the parser now keeps
for the serializer, so ROW((A5)) is 5 again rather than #REF!.

Subscribers: a newcomer dropped for failing a seed has its leave
announced. It was a member while it seeded, so a subscriber added in
that window was seeded with it and would otherwise show a phantom until
its roster expired it.

Blueprint build: a require() that survives bundling is rejected -- esbuild
rewrites it to a __require shim that throws when reached, without a
warning or, for a computed path, a metafile import. The specifier scan
allows comments between a keyword and its operand, and a lib/ module the
metafile shows inlined counts as imported, so the scan only has to
witness type-only imports.

The blueprint server tsconfig is a standalone program under
@cloudflare/workers-types/experimental, as the libraries' is, so that a
bare DurableObject or WorkerEntrypoint sees an empty Cloudflare.Env
rather than the backend's bindings, which a gadget never receives. The
package's own tsc runs beside it again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ask-bonk

ask-bonk Bot commented Sep 9, 2026

Copy link
Copy Markdown
  • P1 workspace-sheets/files/client.ts:1269: Reloading after an ambiguous failure discards structural changes but retains coordinate-based cell edits, potentially overwriting a different logical cell after insert/delete/sort. Associated cell operations must be rebased or discarded.

  • P2 format-blueprint-files.ts:352: require.resolve() becomes __require.resolve(), bypassing the residual-require check and failing at runtime. Reject all residual __require usage.

  • P2 gadget-libraries/ui/src/images.ts:75: Docs previously preserved GIFs below 2 MB; the new data-URL limit effectively lowers that to about 1.05 MB, silently flattening affected animations.

  • P2 workshop-backend/package.json:13: test:watch omits vitest.blueprints.config.ts, so blueprint-local and Node bundler tests are never watched.

github run

…w nits

Sheets keys its pending cell ops by coordinate and nothing remapped them
when a structural change (insert or delete rows or columns, sort) moved
the sheet's cells and queued a whole-sheet replacement. The server
applies cell ops after replacements, so an edit queued in the 180 ms
before the change landed on whatever cell now held its coordinate. That
was so before the resync. Queuing a replacement now drops the sheet's
queued cell ops: the replacement carries the sheet's whole cell map,
local edits included, so they were redundant as well as misplaced.

The resync added by 2edb64f left a second copy of the problem: after a
failed save it discarded the replacement but kept cell ops queued after
it, keyed by the layout the replacement built, which the server may not
have. If our commit had landed they were mostly redundant; if a peer's
had, they would write into other cells. The reload now drops the cell
ops of every sheet whose replacement it discards -- an edit typed after
a structural change and before a failed save is lost, as every edit was
on a failed save before the resync -- and clears the undo history, whose
coordinates presume that layout too. Cell ops on sheets with no pending
replacement stay queued as before.

Blueprint build: the residual-require check matched only a call of the
__require shim, so require.resolve(...), typeof require and a bare
require passed along got through and would throw when reached. Any
occurrence of the identifier is now the witness: esbuild emits the shim
only when a reference to require survives, and renames a source
identifier of that name away from it.

Images: the library kept a GIF animated only within maxDataUrlLength
(about 1 MB of file), where Docs kept one under 2 MB; a GIF between the
two was flattened without notice. A GIF gets a budget of its own,
maxGifDataUrlLength, defaulting to Docs' threshold.

workshop-backend gains test:watch:blueprints for the Node-side blueprint
tests, which test:watch could not include: one vitest process takes one
config.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Maximo-Guk

Copy link
Copy Markdown
Member Author

All four addressed in fb2304e.

  • P1 workspace-sheets/files/client.ts: real, in two halves. The pre-existing half (on main too): pendingCellOps are keyed by sheetId!REF and nothing remapped them when a structural change (insert/delete rows or columns, sort) moved the cells and queued a whole-sheet replacement, and the server applies cell ops after replacements, so an edit queued in the 180 ms before the change landed at a stale coordinate. queueReplacement now drops the sheet's queued cell ops first: the replacement carries the whole cell map, local edits included, so they were redundant as well as misplaced. The half from 2edb64f4: the reload discarded the replacement but kept cell ops queued after it, keyed by the layout the replacement built. The reload now drops the cell ops of every sheet whose replacement it discards, and clears the undo/redo history, whose coordinates presume that layout too. Dropped rather than rebased: if our commit landed, the snapshot already holds what the replacement carried and only an edit typed after it is lost, which is what every failure did before the resync; if a peer's landed, there is no layout to rebase against, and writing the wrong cell is worse than losing the edit. Cell ops on sheets with no pending replacement stay queued, as before.
  • P2 format-blueprint-files.ts: the pattern is now /\b__require\b/u, with the message "references require; the bundle is an ES module and the gadget runtime has no require". esbuild emits the shim only when some reference to require survives (require(p), require.resolve(...), typeof require, a bare require passed along) and renames a source identifier of that name away from it, so the identifier alone is the witness. The test covers require.resolve.
  • P2 gadget-libraries/ui/src/images.ts: a parity regression rather than a defect, fixed for parity. A GIF gets a budget of its own, ImageLimits.maxGifDataUrlLength, defaulting to 2,700,000 chars (about 2 MB of file, the threshold Docs applied before the library); a GIF over it is flattened within maxDataUrlLength as before. Slides is unaffected (own image code). Tests and the README updated.
  • P2 workshop-backend/package.json: one vitest process takes one config, so a second script, test:watch:blueprints, watches vitest.blueprints.config.ts; test:run and the vp test task already ran it. Noted in the config's header.

name: "own-file-imports",
setup(pluginBuild) {
// A Go regular expression, so no `u` flag: esbuild compiles the filter itself.
pluginBuild.onResolve({ filter: /^\.\.?\// }, args => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reject absolute imports from blueprint sources

This hook intercepts only ./ and ../. Esbuild resolves an absolute filesystem import by default, and the metafile check later accepts any target under packages/gadget-libraries. A client.ts can therefore import the absolute path to sync/server.ts or any private src module, bypassing both the side check and the documented gadgets: "only door". Match absolute paths from blueprint importers here as well, or reject them before the containment allowlist.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e4e3301, structurally rather than with a third guard. Discussing this led to dropping the gadgets: specifier altogether: it only existed so the build had one door it could check, and every other resolver then needed its own mapping for it. Blueprints now import a library by the package's own name, @gadgets/bundled-blueprints/libraries/<name>/<side>, declared in exports the way @gadgets/scripts already shares its sources, so tsc, vitest and editors resolve it with no paths and no alias. The build gives esbuild the package name as an alias for the package root (so a tree with no node_modules above it, like the test fixtures or a BUNDLED_BLUEPRINTS_DIR, still builds) and replaces the two resolve plugins and the metafile loop with one audit that walks metafile.inputs[*].imports from the entry: an import written in a blueprint file that lands outside files/ must be the exported subpath, of the entry's side, resolved to exactly that library's <side>.ts. So a relative path, an absolute path, a bare specifier some node_modules satisfies, the package root and a src/ module all fail the same way, naming the importer and the specifier as written (original in the metafile). Tests cover absolute paths from client.ts and a lib/ module to both a library entry and a src/ module, a node_modules beside files/, and the extension-spelled and src/ package subpaths. Bundles are byte-identical; the three files/README.md changed text.

invalid(label, `${entry.name}.ts contains a dynamic import whose path is not a string ` +
`literal; the bundler cannot check it`);
}
if (RESIDUAL_REQUIRE_PATTERN.test(text)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Parse residual syntax instead of matching string contents

Because text is unminified output, ordinary string literals survive. For example, document.body.textContent = "__require" matches this regex and rejects a valid blueprint despite containing no require reference; "import(foo)" similarly trips the computed-dynamic-import check at line 500. Parse or lex the output, or otherwise exclude literals rather than scanning raw text.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined, for now. Real, but confined to a gadget whose output contains the literal text __require or import( followed by a non-quote, it fails the build with a message that names the check, and the fix is a JavaScript parser in the build path (typescript6 is in the workspace, but nothing under src/ depends on it today and the backend's build scripts run it directly under node). The comments on both patterns already say they are scans and which direction they err. Will revisit if a real blueprint hits it.

this.#inFlight = false;
if (this.#saveAgain || (outcome !== "pending" && this.#options.isDirty())) {
this.#saveAgain = false;
this.schedule(this.#failures ? retryDelay(this.#failures) : RETRY_BASE_MS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep the failure status visible during backoff

The catch sets Save failed — retrying, but this call immediately invokes schedule(), which synchronously replaces it with Saving… for the entire retry delay (eventually up to 10 seconds). Base Sheets left Save failed visible; after this refactor users see a false active-save state and no failure indication. Arm the retry timer without changing the offline status until the attempt actually starts.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d5f2214: the retry no longer goes through schedule(). A private #arm arms the timer and says nothing; the save announces Saving… when the timer fires, unless the line already says so from the keystroke that scheduled it, so typing, conflicts and pending outcomes report exactly the sequences they did and a failure reports offline until the retry starts. One correction to the premise: base Sheets left Save failed visible because it never retried at all -- it consumed pendingCellOps before sending and dropped them when the send failed -- so the library was already an improvement there; the message just came too early. Test asserts ["saving", "offline"] through the whole backoff and "saving", "saved" once the retry lands.

@ask-bonk

ask-bonk Bot commented Sep 10, 2026

Copy link
Copy Markdown

Posted 3 actionable inline findings.

github run

Maximo-Guk and others added 2 commits September 9, 2026 23:11
The bundled format blueprints came to main as data the backend embeds
into src/generated/format-blueprints.ts. Once they were TypeScript with
tests, the backend package carried three tsconfig programs, a second
vitest config, an esbuild bundler under scripts/, and a sibling package
(packages/gadget-libraries) that five places reached by relative path,
all for code that never runs in its Worker; and `test:watch` could not
cover the blueprint tests.

packages/format-blueprints (@gadgets/format-blueprints, private) now
holds all of it:

- blueprints/: the three blueprints, content unchanged.
- libraries/: what was packages/gadget-libraries, as a plain directory.
  The `gadgets:<name>/<side>` specifier stays.
- src/: the archive codec, source reader and esbuild bundler
  (files.ts, from scripts/format-blueprint-files.ts), the manifest
  parser (manifest.ts), and the library half of the generator
  (generate.ts: generateFormatBlueprintsModule and
  BUNDLED_BLUEPRINTS_DIR).

One vitest config with two projects (gadgets under jsdom, the build
under node) and five tsc programs: client, server and tests as before,
`server-tests` for a test of a server side (named server.test.ts or
<topic>.server.test.ts), and `node` for src/. The server-side tests get
their own program because they import a Durable Object: it needs the
Workers types, and judging it under the DOM lib flags lib.dom's own
Crypto and CompressionStream typings in blueprint source that is fine
under workerd's.

The two CLIs stay in packages/workshop-backend/scripts as thin entry
points, since the generated module is the backend's and
FORMAT_BLUEPRINTS_DIR keeps resolving against the backend package root.
Unset, the source is the new package's blueprints/. The backend drops
the blueprint tsconfigs, vitest.blueprints.config.ts, the third vitest
run and `test:watch:blueprints`, the `gadgets:` alias in its vitest
config, and the jsdom and @cloudflare/workers-types dev dependencies;
it gains the workspace dependency.

Trade-off: the sheets and slides server tests moved with their
blueprints and now run under node against a stub of cloudflare:workers,
so blueprint server code is no longer exercised inside workerd. The
backend's format-blueprints.test.ts still installs and inspects the
built archives in workerd.

The integration-tests watch table now lists packages/format-blueprints
always and the FORMAT_BLUEPRINTS_DIR tree only when the variable is
set. PR #465's branch maximo/gadget-libraries stays frozen at 9794a1a,
to be re-ported onto this layout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"Format" is a runtime curation state: a promoted blueprint, which an
admin can take out of the formats menu (a bundled one) or put into it
(an unbundled one). "Bundled" is what the package actually holds, so the
package, the backend module, the generated module, the two CLIs, the vp
task, the package.json script, the identifiers and the env var are now
named for that.

- packages/format-blueprints -> packages/bundled-blueprints
  (@gadgets/bundled-blueprints); FormatBlueprintManifest,
  FormatBlueprintPresentation and their parsers -> BundledBlueprint*;
  generateFormatBlueprintsModule -> generateBundledBlueprintsModule.
- Backend: src/bundled-blueprints.ts (installBundledBlueprints,
  bundledBlueprintsManifestVersion), the generated
  src/generated/bundled-blueprints.ts (BUNDLED_BLUEPRINTS,
  BundledBlueprint), scripts/build-bundled-blueprints.ts,
  scripts/import-bundled-blueprint.ts, the `import:bundled-blueprint`
  script and the `build:bundled-blueprints` task.
- FORMAT_BLUEPRINTS_DIR -> BUNDLED_BLUEPRINTS_DIR, renamed outright with
  no fallback: a deployment still setting the old name builds the
  bundled set. The README says so.

Kept, deliberately: the blueprint IDs format.document, format.spreadsheet
and format.slides, which are install and promotion keys on live
deployments (the README carries a TODO: renaming them needs an
install-time migration keyed on the old id); the AdminSettings storage
keys installedFormatBlueprints and promotedFormatBlueprints, persisted
state for the same reason; and everything genuinely about promotion:
AdminConfig.formats, the Formats panel, FormatCuration,
BlueprintMetadata.output, OUTPUT_ICONS, the shared API's `bundled` flag
and the `formats.*` log events.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions github-actions Bot added the workshop/shared Changes to shared Workshop APIs label Sep 10, 2026
*/
const ENTRY_POINTS = [
{ name: "client", platform: "browser", external: [] },
{ name: "server", platform: "neutral", external: ["cloudflare:*"] },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Allowlist production Cloudflare modules

This wildcard also accepts test-only and nonexistent specifiers such as cloudflare:test. That is hidden for this repository’s blueprints by tsconfig.server.json, but a BUNDLED_BLUEPRINTS_DIR tree is explicitly not type-checked: esbuild preserves the import as external, this check accepts it, and the generated blueprint then fails when production WorkerLoader instantiates its server. Restrict this to the cloudflare:* modules that gadget workers actually receive (at least cloudflare:workers) rather than treating the namespace as available.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d5f2214: the server's externals are ["cloudflare:workers"] and matchesExternal is an exact match, the wildcard branch gone. That is the one cloudflare: module loadGadgetWorker gives a gadget: globalOutbound: null, so cloudflare:sockets is moot, and none of the flags behind the others. Test imports cloudflare:test from server.ts and gets esbuild's own Could not resolve, since it is no longer external.

@ask-bonk

ask-bonk Bot commented Sep 10, 2026

Copy link
Copy Markdown

Posted 1 actionable inline finding.

github run

…tead of guarding resolution

A blueprint imported a gadget library as `gadgets:<name>/<side>`, a specifier
of the build's own. It existed because esbuild inlines whatever it resolves,
and the build wanted one door into `libraries/` it could check: a resolve
plugin mapped the specifier and refused the wrong side, a second plugin
refused relative imports that left `files/`, and a metafile loop re-checked
what was inlined. Every other resolver -- tsc, vitest, an editor -- then
needed its own mapping (`paths` in four tsconfigs, an alias in vitest), and
an absolute path into `libraries/` walked past all three guards, as review
noticed.

The specifier is gone. A blueprint imports a library by the package's own
name, `@gadgets/bundled-blueprints/libraries/<name>/<side>`, which
package.json now exports the way `@gadgets/scripts` already exports its
TypeScript sources by name. In-repo blueprints refer to their own package,
which tsc, Vite and esbuild resolve through `exports` with nothing to
configure, so the `paths` blocks and the vitest alias go; a
`BUNDLED_BLUEPRINTS_DIR` tree elsewhere gets types by linking the package
into its workspace, as it must already do for `@gadgets/scripts`. `src/` and
`blueprints/` stay unreachable by name because `exports` does not list them.

The build still needs no `node_modules` above a blueprint -- the tests'
temporary fixtures and an external tree have none -- so it gives esbuild the
package name as an alias for the package root, read from package.json so it
cannot drift from `exports`, and resolves the import to the libraries it
ships with. The three guards collapse into one audit of the bundle graph:
starting at the entry and following `metafile.inputs[*].imports`, every
input is met as the edge that brought it in, and an import written in a
blueprint file that lands outside `files/` has to be the exported subpath,
of the entry's own side, resolved to exactly that library's `<side>.ts`.
A relative or absolute path into `libraries/`, a `src/` module, the package
root and a bare specifier some `node_modules` happens to satisfy all fail
the same way, naming the importer and the specifier as written; a library's
own imports may reach anything under `libraries/` but never `node_modules`.

The bundles are byte-identical: the libraries inline the same files. The
three blueprints' `files/README.md` name the new import, and a README is
shipped in the archive, so the archives change by that text alone.
…'s status up while it waits

The server bundle's externals were `cloudflare:*`, so any module under that
prefix passed the build. A gadget's Durable Object is loaded by
`loadGadgetWorker` with no outbound network and none of the flags behind the
other `cloudflare:` modules, so `cloudflare:workers` is the only one it can
use; the in-repo blueprints are type-checked against that, but a
`BUNDLED_BLUEPRINTS_DIR` tree is not, and its `cloudflare:test` would build
and fail when the object was instantiated. The externals now name
`cloudflare:workers` alone, `matchesExternal` is an exact match with the
wildcard branch gone, and a test imports `cloudflare:test` from a server.

After a failed save the scheduler said `Save failed — retrying` and then, in
the same tick, `Saving…`, because the retry went through `schedule()`, which
announces the save when it arms the timer. Nothing was in flight for the
whole backoff, up to ten seconds of it. Base Sheets never showed this: it
did not retry at all -- it consumed its pending cell ops before sending and
dropped them when the send failed -- so the failure simply stayed up. The
timer is now armed by `#arm`, which says nothing itself; the save announces
itself when the timer fires, unless the line already says `Saving…` from the
keystroke that scheduled it. Typing, conflicts and pending outcomes report
exactly what they did; a failure reports `offline` until the retry starts.
@ask-bonk

ask-bonk Bot commented Sep 10, 2026

Copy link
Copy Markdown
  • [P2] packages/bundled-blueprints/src/files.ts:511: Esbuild rewrites concatenated dynamic imports into glob helpers. Their synthetic edge is marked external and skipped, while matched out-of-tree files are inlined as disconnected metafile inputs. Audit all metafile inputs or reject glob imports.

  • [P2] packages/bundled-blueprints/src/files.ts:369: A mixed blueprint can bundle lib/shared.ts into client.ts while a passthrough server.js imports ./lib/shared.js. The build drops the TypeScript module, leaving the shipped server import unresolved. Reject this layout or preserve/build the referenced module.

github run

…mporting a dropped lib/*.ts

A dynamic `import()` of a template literal with substitutions is expanded by
esbuild into a glob helper: the metafile records an external edge whose path is
the wildcard, every file the pattern matched -- anywhere it reaches, including
outside files/ -- is inlined as an input no edge points at, and the output holds
no `import(`. None of the three existing checks saw it. `auditInputs` now rejects
the wildcard edge and then requires that the walk met every input the metafile
lists, so a bundle that inlines something no import brought in is refused
whatever produced it.

In a mixed tree, a module the archive ships as written (an un-migrated entry, or
a lib/*.js) could import a lib/*.ts, which is compiled into the entries and not
shipped; the gadget then failed to load with no build error. Such an import is
rejected before the bundle runs, naming the importer and the specifier.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`.mts or .cts`);
}
if (!path.endsWith(".ts")) {
output.set(path, source);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Audit package-library imports in JavaScript entries

Every non-TypeScript file is copied here before the only esbuild/audit pass, and a JavaScript-only tree returns at line 400. Thus a supported server.js containing import { MutationQueue } from "@gadgets/bundled-blueprints/libraries/sync/server" builds with that bare import unchanged, but WorkerLoader provides only archive modules and cloudflare:workers, so the instantiated gadget fails to load. Bundle JavaScript entries as well, or reject library imports from them instead of documenting libraries as available to all blueprints.

source: string,
dropped: ReadonlySet<string>,
): {specifier: string; module: string} | undefined {
for (const [, doubleQuoted, singleQuoted] of source.matchAll(SPECIFIER_PATTERN)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Decode escapes before resolving passthrough imports

SPECIFIER_PATTERN returns the source spelling, not the JavaScript string value. In a mixed tree, a retained server.js import of "./lib/sh\u0061red.js" is valid and resolves to ./lib/shared.js at runtime, but this loop checks a path containing the literal escape, misses lib/shared.ts, and lets the build drop that module after bundling the client. The generated server then has an unresolved import. Parse/cook the specifier before passing it to resolveWithinFiles.

@ask-bonk

ask-bonk Bot commented Sep 10, 2026

Copy link
Copy Markdown

Posted 2 actionable inline findings.

github run

… in a shipped module

A JavaScript module is copied into the archive as written, so a gadget
library it imports by package name is never inlined and the runtime has
nothing to resolve the name against. The shipped-module scan now runs
for a JavaScript-only tree too, ahead of the early return, and rejects a
package-name import from any module that ships as written.

The scan reads a specifier as spelled, so one written with an escape
could name a dropped lib module, or the package, past both checks. A
specifier containing a backslash is rejected outright rather than
decoded: nothing legitimate spells an archive path that way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

console.log("");
await import("./build-format-blueprints.ts");
await import("./build-bundled-blueprints.ts");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep the previous source until the generated build succeeds

This build is now the first point that applies the TypeScript/bundler policy to the imported files, but the extracted backup was deleted at line 249. If an archive contains a layout the new bundler rejects (for example client.ts beside client.js), the command fails here after permanently replacing the prior valid source; --new similarly leaves the invalid tree behind. Validate the staged tree before the rename, or retain and restore the backup through generation.


await visit(filesDir, "");
validateFilePaths(files.keys(), label);
return await bundleTypeScriptSources(filesDir, files, label);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Let the importer read broken source it is replacing

import-bundled-blueprint.ts calls readSourceFiles() on the current extracted tree only to compute its change summary. Since this helper now bundles, any syntax error, unresolved import, or new policy violation in the current TypeScript aborts the import before a valid archive can replace and repair it. Keep a raw-reader path for that caller rather than compiling the outgoing source.


/** A `baseVersion` as sent over RPC, read as the non-negative integer it must be; anything else is 0. */
export function normalizeBaseVersion(value: unknown): number {
return Math.max(0, Math.floor(Number(value) || 0));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not coerce invalid versions into valid preconditions

Flooring changes optimistic-concurrency semantics: against stored version 2, baseVersion: 2.9 becomes 2 and overwrites the item, while a negative version for a missing item becomes 0 and creates it. The previous Docs implementation preserved these numeric values, so both cases failed their version checks. Reject/preserve non-integer and negative values instead of mapping them onto meaningful versions.

async subscribe(callback: SubscriberCallbacks, client: Partial<Collaborator> = {}): Promise<DocumentSnapshot> {
// The registry keeps the stub, seeds the newcomer with everyone already
// connected, announces it to them, and drops it when its connection breaks.
this.subscribers.add(callback, normalizeCollaborator(client));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Normalize the same identity on cursor updates

The subscribed collaborator is normalized here, but updatePresence() and leavePresence() broadcast the raw ID. Reusing an overlong ID therefore emits the join under its truncated key and cursor updates under a second key; disconnect removes only the truncated entry, leaving a ghost cursor until expiry. Name/color normalization is also undone by the first cursor update. Apply the same collaborator normalization to all presence events.

@ask-bonk

ask-bonk Bot commented Sep 11, 2026

Copy link
Copy Markdown

Posted 4 actionable inline findings.

github run

Maximo-Guk and others added 3 commits September 10, 2026 19:58
A tree holding any .ts (other than a .d.ts) may hold no module the archive would ship as
written: a .js/.mjs/.cjs beside bundles it cannot share code with is rejected, naming the
file. JavaScript-only trees still pass through unchanged, so the importer and external
BUNDLED_BLUEPRINTS_DIR trees keep working; non-module files (JSON, CSS, README, assets) are
unaffected either way.

This one rule supersedes three special cases from 5f11a2a and 9b7229e: the entry twin
check, the lib/ twin check, and the shipped-.js-imports-a-dropped-lib/*.ts check, which can
no longer arise since a shipped module only ever sits in a JavaScript tree, where everything
it can name ships beside it. checkShippedImports keeps the escape and library-import checks,
which a JavaScript-only tree still needs, and now runs only for such a tree. The
incremental-migration path the mixed-tree support served is no longer needed: the three
in-repo blueprints are all TypeScript.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… anything

readSourceFiles now applies the bundler policy, so an export can be rejected at build time on
things extractFiles accepts (a .js beside a .ts, a .tsx, a library import from a shipped
module). The importer built only after the rename, by which point the prior source was
replaced and the backup deleted; --new left the rejected tree in place. The staged files/ is
now run through readSourceFiles before the rename, so a rejected export is refused with the
target untouched, and the post-rename build cannot fail on the files.

The outgoing tree is also read through readSourceFiles, for the change summary only. If the
current TypeScript does not build, the import now warns and reports the summary as
unavailable instead of aborting: the archive being imported may be what repairs it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ounds cursor and leave like the join

normalizeBaseVersion floored and clamped, so against stored version 2 a baseVersion of 2.9
was accepted and -1 for a missing item re-created it. A precondition that cannot be read
should fail: a value that is not a non-negative integer now reads as -1, which matches no
stored version and is not 0, so the upsert or deletion is rejected as stale or missing, as
main's `Number(v || 0)` did. Absent stays 0. The sender is the gadget's own client, which can
already send the honest value, so this is semantics rather than authority.

Docs normalized the collaborator on subscribe but kept ad-hoc String() bounds on cursor and
leave, so an overlong id joined under its truncated key, sent cursors under the full one, and
the registry's disconnect leave removed only the truncated entry, leaving a ghost until
STALE_MS. Cursor and leave now go through the same normalizeCollaborator as the join. Sheets
keeps its own self-consistent bounds as on main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
let metafile: Metafile;
let text: string;
try {
const result = await build({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reject glob imports before invoking esbuild

The audit runs only after build() has fully resolved and bundled every input. A tiny imported archive can therefore use e.g. import(../../../../../../${name}.js) to make esbuild recursively enumerate and bundle JavaScript outside files/; the importer calls this on its staged, attacker-supplied TypeScript before the wildcard edge is rejected at line 532. The 32 MiB source limit does not bound that external tree, so this can exhaust CPU/memory or read a large part of the host filesystem before validation fails. Reject template/glob imports in a pre-build parse or an esbuild resolver hook, before expansion occurs.

invalid(label, `${lib} is not imported by any entry point`);
}
}
return new Map([...output].toSorted(([a], [b]) => compareNames(a, b)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Validate paths introduced by TypeScript output

Only the on-disk input paths are validated. For example, client.ts plus client.js/assets.txt is a valid source tree, but compilation adds a client.js file and this returns a map where that file conflicts with the existing client.js/ directory. buildContent() eventually rejects it, but the importer's staged check calls only readSourceFiles(), so it accepts the stage, replaces the old tree and deletes its backup before the final generator fails. Re-run validateFilePaths(output.keys(), label) after adding generated entries (and validate the complete staged archive before swapping).

@ask-bonk

ask-bonk Bot commented Sep 11, 2026

Copy link
Copy Markdown
  • P1 packages/bundled-blueprints/blueprints/workspace-sheets/files/client.ts:1301: After resync, retry uses the collaborator’s current cell version as baseVersion, silently authorizing the stale local value to overwrite their edit. Preserve the version captured when the local edit was queued.

  • P2 packages/bundled-blueprints/src/files.ts:481: Generated client.js/server.js paths are not revalidated. A tree containing client.ts and client.js/assets.txt passes staged validation, then fails after replacing the previous source because generated client.js conflicts with that directory. Validate output paths before returning.

github run

Maximo-Guk and others added 2 commits September 10, 2026 20:45
…d runs; validate the paths the bundle adds

esbuild expands `import(`../../${name}.js`)`, or a concatenation that begins with a string, into a
glob over every file the pattern matches and bundles each of them before auditInputs can reject the
wildcard edge, so the walk was unbounded work on the machine running the build. The blueprint's
sources are now scanned for a dynamic import() or require() of anything but a plain string literal
and refused ahead of the build; the post-build checks stay as the backstop for library code.

The bundle also adds client.js / server.js to a tree validateFilePaths saw without them, so a
client.ts beside a client.js/ directory of non-modules survived readSourceFiles and failed only in
buildContent -- after the importer's staged check had accepted it and replaced the target. The
returned map's paths are validated before it is returned, so every caller gets the check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…is queued, not when it is sent

The send re-read the model's version for each pending op, on the claim that this made a stale op
fail. It does not once the model has moved: the resync that follows a failed save installs the
server's cells and versions, and a peer's upsert during the debounce does the same, so a surviving
local op re-read the collaborator's version as its base and the server accepted the stale value as
current. The same rule also sent every plain deletion with baseVersion 0, since a deletion removes
the cell from the model before the op is queued, and the server rejected the delete as a conflict
and the cell came back.

queueCellOp now takes the version the caller saw before it wrote the model, and the send uses it.
An edit typed while a save was in flight replaces its entry and stays pending, so the ack loop
rebases it onto the version it just adopted: it was made on top of what the save carried, and only
our write could have produced that version. Conflicts rebase nothing; the server's cell stands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ask-bonk

ask-bonk Bot commented Sep 11, 2026

Copy link
Copy Markdown

Findings

  • P1 workspace-sheets/files/client.ts:1284: Resync compares against mutable model.revision. A collaborator broadcast can advance it while stale replacements remain queued, causing retry to overwrite collaborator changes. Compare against the revision captured when the payload was created.
  • P2 workspace-sheets/files/client.ts:1295: applySnapshot() removes retained optimistic cell values. Successful retry updates the model but never calls renderGrid(), while the sender ignores its own broadcast, leaving stale cells displayed.
  • P2 bundled-blueprints/src/files.ts:453: Esbuild honors an enclosing "sideEffects": false, silently removing side-effect-only imports such as import "./lib/setup.ts". The audit still succeeds. Disable annotation-based pruning.
  • P2 bundled-blueprints/src/files.ts:281: The pre-build import scanner recognizes only LF-terminated comments. Bare CR, U+2028, or U+2029 can bypass the glob-import guard, allowing esbuild to enumerate files before rejection.

github run

Maximo-Guk and others added 2 commits September 10, 2026 21:54
…; ignore tree-shaking annotations

The pre-build scan ended a `//` comment at LF only, so `import //x<CR>(`...${n}`)` read as one comment up to the next LF and passed, and esbuild enumerated the pattern before the wildcard edge could reject it. A line comment now ends at CR, U+2028 and U+2029 as well, as it does for esbuild; the terminator stays required so the alternative cannot stop partway through a comment and read a string inside it as the operand. The rejection quotes only the keyword, not whatever comment sat in the gap.

esbuild applies a `"sideEffects": false` in an enclosing package.json to the package's own relative imports, dropping `import "./lib/setup.ts"` with a warning the silent log level swallows, and the audit cannot see it: the source names the module, and a dropped input is absent from the metafile. The build now sets `ignoreAnnotations`; blueprints and libraries write no `@__PURE__` annotations, so nothing else changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he revision it was built on; renders after a reload or conflict

The resync compared the server's revision against `model.revision`, which a collaborator's broadcast advances while the structure snapshot or sheet replacement sits in the queue: a replacement queued at R0 whose send failed, followed by a peer's edit landing at R1, passed the check at R1 and was sent wholesale, erasing the peer's cell. The payload now records the revision the model held when its first item was queued and is compared against that; if they differ it is dropped and the server's copy adopted, as before. With nothing wholesale pending there is no base and no reload, since cell ops carry their own versions. A wholesale edit queued while a save is in flight is built at the pre-ack revision, so a failure of the next save drops it: the loss every failure had before the resync, confined to that timing.

After a reload the grid showed the server's copy, and the acknowledged retry wrote the model without rendering; a conflict likewise adopted the server's cell into the model while the grid kept the optimistic value. The ack loop now renders in either case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
// Rejecting here leaves the flag set; the scheduler counts a failure and tries again.
const doc = await gadget.getDocument();
resyncBeforeSave = false;
if (doc.revision !== wholesaleBaseRevision) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Make the replay precondition atomic with the write

getDocument() releases the server mutation queue before this comparison, so the revision can change before the following applyOperation(). If the failed request did not commit, this can read base revision R, then a collaborator commits a cell edit at R+1, and the retry applies its stale whole-sheet replacement unconditionally at R+2, erasing that edit. Carry and validate a base revision in the same queued server operation (or deduplicate the original operation) instead of relying on this client-side check.

// save is what every failure did before the resync; writing the wrong cell is not. The
// undo history presumes that layout too. Cell ops on sheets with no replacement stay queued.
for (const sheetId of pendingReplacements.keys()) dropCellOpsFor(sheetId);
pendingReplacements.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve wholesale edits queued after the failed payload

This clears the live replacement/structure queues, not just the generation whose result was ambiguous. If save A commits but its reply is lost and the user queues structural edit B while A or this getDocument() is in flight, the revision mismatch caused by A clears B even though B was never sent; applySnapshot() then removes it locally too. Track the sent generation and discard only its entries, preserving replacements and structure queued afterward.

tsconfigRaw: {},
});
metafile = result.metafile;
text = result.outputFiles[0]!.text;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reject or handle additional esbuild outputs

A client.ts side-effect import of a .css or .module.css file makes esbuild emit both JavaScript and a sibling CSS output. This keeps only outputFiles[0], while the Workshop loads only the generated client.js; esbuild does not inject the discarded stylesheet, so the blueprint builds successfully but loses its styles at runtime. Require exactly one JavaScript output, or explicitly preserve and load every generated output.

@ask-bonk

ask-bonk Bot commented Sep 11, 2026

Copy link
Copy Markdown

Posted 3 actionable inline findings.

github run

…ning the text

The build decided three things by running regexes over module text: which
files a module imports (the "lib/ module no entry imports" check), whether a
shipped JavaScript module imports a gadget library, and whether a source holds
a dynamic import() or require() of anything but a string literal. All three
shared a hand-written comment grammar (GAP), and the last two review rounds
were both bypasses of it: a CR-terminated line comment, and before that a
comment between keyword and operand. A regex over JavaScript always has
another one.

The new src/scan.ts reads the imports from the syntax tree instead, with the
TypeScript 6 compiler API (the `typescript6` alias the repo already uses for
the configurator build; tsgo ships no JS API). GAP, SPECIFIER_PATTERN,
NON_LITERAL_DYNAMIC_IMPORT_PATTERN and COMPUTED_DYNAMIC_IMPORT_PATTERN are
gone; RESIDUAL_REQUIRE_PATTERN stays, since a single identifier in
comment-free output is not a grammar problem. Each TypeScript module is parsed
once and the result shared between the pre-build dynamic-import check and the
reachability walk; the output backstop parses the bundle as JavaScript.

Two behaviour changes, both toward exactness: a lib/ module named only in a
comment is no longer counted as imported, and an escaped specifier is compared
decoded, so the separate "spelled with an escape" rejection is dropped -- the
library check catches it under the name it spells. A type-position
`import("...")` is now seen as well as `import type`. Declaration files are
still read for what they import, as the text scan incidentally did.

The three shipped blueprints bundle byte-identically before and after (the
scan gates, it does not transform). Loading the compiler adds ~140ms to the
build script's import. `typescript6` is added as a devDependency of the
package beside esbuild.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ask-bonk

ask-bonk Bot commented Sep 11, 2026

Copy link
Copy Markdown

Findings

  • P2 packages/bundled-blueprints/src/scan.ts:45: Parenthesized/type-asserted require calls bypass scanning. Esbuild still expands (require)(\...`)` as a glob, reading matching files before the post-build audit rejects it.
  • P2 packages/bundled-blueprints/src/files.ts:421: platform: "browser" honors a blueprint’s files/package.json browser mappings, allowing bundled modules to differ from those TypeScript checked.
  • P2 packages/bundled-blueprints/blueprints/workspace-sheets/files/client.ts:1244: Prefix matching conflates valid sheet IDs such as a and a!b, potentially deleting the latter sheet’s pending edits.
  • P2 packages/workshop-backend/scripts/import-bundled-blueprint.ts:258: Staging validates bundled paths but not bundled snapshot size. Bundling can expand valid source beyond 32 MiB, causing generation to fail after the previous source backup was deleted.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kernel Changes to the Workshop kernel workshop/shared Changes to shared Workshop APIs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant