feat(functions-compiler): plan, size and split an app's function shards - #623
Merged
Merged
Conversation
Adds the half of the pipeline that turns "compile this set of sources" into
"produce the deployable Cloudflare Workers bundles for this app". Ported from
apper's `function_bundle.py`, `shard_planning.py` and the size/split machinery
in `cloudflare_wfp_runtime.py`. Only the fresh-build path crosses over;
incremental reuse and the per-app ratchet need a previous deploy and stay with
the service.
- `assembly.ts` — the reachability walk that turns a function's entry plus the
backend files it reaches into `{entry, files}`, keeping the flat `main.ts`
submission when it reaches nothing else.
- `shards/plan.ts` — the fresh partition and the capacity refusal.
- `shards/size.ts` — raw UTF-8 bytes and level-6 gzip against Cloudflare's
64 MiB ceiling and our compressed cap.
- `shards/build.ts` — compile, measure, halve an oversized shard, and refuse a
partial result. `bundleApp` returning `ok: true` with a failed function is
what the service ships; a whole-app build cannot.
Two things worth a reviewer's attention.
**The walk uses esbuild, not a second parser.** apper walks the import graph
with tree-sitter as a stand-in for what the bundler would see. Here esbuild
does it, so it is not a stand-in — the file set is what the compiler itself
reaches. All ten fixtures from `test_function_bundle.py` are ported and pass.
One trap: TypeScript elides an import whose bindings go unused, which silently
dropped a reached file until `verbatimModuleSyntax` was set.
**Python and Node do not gzip to the same size.** On an identical 122,324-byte
module, Python's `gzip.compress(..., 6)` gives 42,765 bytes and Node's
`gzipSync` gives 43,057 — Node reads about 0.7% heavier. A module within that
margin of the cap can pass one lane and fail the other. The direction is the
safe one: the local gate refuses slightly earlier than the service would, so
nothing doomed slips through locally. Pinned in a test with the numbers.
The ordering and capacity traps from the Python survive deliberately: a single
shard keeps the caller's order while a multi-shard plan sorts by name, and
capacity is judged at the global shard size rather than a ratcheted-down one.
Both have a test saying why, because both look like cleanups.
276 tests over 27 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
🚀 Package Preview Available!Install this PR's preview build with npm: npm i @base44-preview/cli@0.1.15-pr.623.1596711Prefer not to change any import paths? Install using npm alias so your code still imports npm i "base44@npm:@base44-preview/cli@0.1.15-pr.623.1596711"Or add it to your {
"dependencies": {
"base44": "npm:@base44-preview/cli@0.1.15-pr.623.1596711"
}
}
Preview published to npm registry — try new features instantly! |
The module comment framed the two exception paths in `_build_shard_with_split` as a safeguard the local loop gives up. They are not. The bundler's 413 is its HTTP request-body cap, measuring the shard's packed source going over the wire — a deploy concern, and there is no request to reject locally. Cloudflare's 10027 is a real Worker limit, but it is the same 64 MiB `workerRawSizeBreach` computes from the bytes in hand before anything is uploaded, so catching it from a rejected upload is a failsafe rather than the design. Nothing is lost by measuring instead, and no compensating headroom is needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reachability walk is one esbuild build over a function's whole graph, so anything unparseable in it rejects the build. Nothing caught that, and the rejection left `collectReachableFiles` as an exception — past every diagnostic path, so a syntax error in a shared module read as an infrastructure failure rather than a compile error the builder agent can fix. apper cannot reach that state: it reads each file on its own, so a file it cannot parse contributes no edges while the rest of the set still assembles. The fallback here is the flat single-file submission, which is what a single-file function has always had, and the compiler then reports the error itself. Neither lane had a test for this path; both cases now have one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four cases from apper's TestBundleSizeMeasurement had not come across, and they are the regression net for the compressed cap rather than restatements of it: the largest module in production, the second-largest at a 1.49x ratio, the 35.3 MiB module Cloudflare has accepted five times, and the one asserting compression stays off the event loop. They matter more in this lane than in apper's, because Node's gzip reads ~0.7% heavier on identical input, so the margin they pin is smaller here. The two new build cases exist because a version's identity is the hash of the compiled artifacts and never of the sources: the same set has to compile to the same bytes, and the caller order that changes them inside a single shard is now stated by a test instead of a comment. README: the shard pipeline has landed, so the section saying it was missing becomes what the package does, what arrives as an input, and what shifts the emitted bytes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The only conflict is this package's README: main rewrote the visibility half of "Status and scope" when the package went public, and this branch rewrote its last sentence because shard planning now lives here. Kept both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A compiled shard named its functions only as scattered `registerLazy`
literals in minified output, in build order, with no declared place to look.
A tool could find them only by guessing the minified shape, and anyone
holding a script pulled from Cloudflare could not answer "what is in this?"
at all — the readable copy lived in the API response and the version record,
which is exactly the copy that can drift from the bytes it describes.
//!b44:1 {"functions":["health","sendReminder"],"telemetry":false,"runtimeSecrets":false,"compiler":"0.1.0"}
`//!b44:<format>` is a fixed sentinel, so `head -1` answers the question with
no execution and no parsing, and the payload is JSON so a reader parses it in
one call. An esbuild banner is prepended verbatim and not minified away.
What it carries and what it may never: `functions` sorted regardless of build
order, both wrapper flags because they change the emitted bytes and the
secrets delivery a deploy must pair with, and this package's version. No
timestamp or build id, which would re-mint a version for unchanged code now
that identity is the hash of these bytes; no app id, which would make the
same functions compile differently per app; no shard position, which would
make two identical shards differ.
Only the app path emits it. The legacy single-function `bundle()` stays
bannerless, which keeps that lane byte-comparable with the engine apper still
runs in production.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects in the reachability walk, one of which hid the other two. It set no output format, so esbuild defaulted to iife — and iife rejects top-level await. A Deno function with `const cfg = await …` at module scope is legal, and the real compiler accepts it, but the walk threw. It also passed `path.sep` as the working directory, which is `\` on Windows and not an absolute path, so esbuild refused it outright: every walk on Windows failed, and the CLI supports win32. Neither can change the emitted bytes — only the metafile is read, never the output. Both were invisible because the fallback caught every failure, the compiler's own included, and degraded each to the flat single-file submission. A legal multi-file function then failed with "must reference a file bundled with this function", naming the user's import for our bug. The fallback now applies only when every esbuild message carries a location in this plugin's namespace, which is how a source failure looks; a configuration fault carries no location and is rethrown. The new test fails without the format fix and passes with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anging `shardSize: 0` passed the capacity check — capacity is judged at the global size — and then reached a chunking loop that advances by `shardSize`, so it never advanced. Planning hung rather than refusing. The Python raises on the same input, because `range()` refuses a zero step. All three counts are now validated as integers of at least one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The banner reads this package's version, and it read it from package.json at runtime. That resolves from src/ and from the published lib/src/, but the CLI bundles this module into an artifact of its own, where no package.json sits beside it and the one above belongs to the CLI. The read degraded to "unknown", so the same functions compiled to different bytes in the CLI lane and in the service lane — on the one field whose job is to identify the engine that produced them. A literal in src/version.ts resolves in every host: esbuild inlines it, and the bundled artifact was checked to carry `COMPILER_VERSION = "0.1.0"`. version.test.ts fails the build if it drifts from package.json, so bumping the package still means two files but cannot mean forgetting one. The version stays in the emitted bytes deliberately: a compiler release re-mints a version for every app even when the body is unchanged, and that cost is accepted in exchange for artifacts that name what compiled them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI lints `packages/*/src` and `packages/*/tests`, and this package's tests
live in `test/` — so nothing was checking them and 12 formatting and
import-order errors had accumulated across the files this PR touches. Green
CI was not evidence about them.
Formatting only; no test changed what it asserts. Three
`noTemplateCurlyInString` warnings are left standing on purpose: those
`${...}` sequences are inside single-quoted fixture sources, where they are
the user code under test rather than a template literal someone forgot to
mark.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The split loop recurses, but nothing exercised it deeper than a single halving, so the nested flatMap that collects recursive results went unchecked. apper's own test drives 8 to 4 to 2. Two cases, both calibrating their own cap against real compiles: - eight functions under a cap between a pair's size and a quad's, so the planned shard halves and each half halves again. Four shards of two is only reachable at depth two — a single level would emit two shards of four, over the cap. - four functions where one alone breaches, so the recursion reaches it. The halves that fit are not a smaller success, and the build fails rather than emitting a module missing a handler. Sizes had to grow with function count for a second level to be reachable at all, which a fixture of trivial functions does not do — the injected shim dominates them. Hence a deterministic incompressible payload: fixed-seed, so a test that calibrates its own cap measures the same bytes every run. This file also carries the biome pass from the previous commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
netanelgilad
approved these changes
Sep 17, 2026
yurynix
added a commit
that referenced
this pull request
Sep 17, 2026
main has been red since #623 landed: it added src/version.ts pinned to 0.1.0 and the test that guards it, having branched before #628 bumped package.json to 0.1.1. Setting the literal to 0.1.1 is the whole of that fix. The publish workflow would have reintroduced the drift on every release. `npm version` rewrites package.json alone, and the literal cannot be read from package.json at run time — a host that bundles this module ships none beside it (that is what src/version.ts documents). So a release would have published a banner naming the previous version and left main red again. scripts/sync-version.ts rewrites the literal from package.json and throws if it matches nothing, because a silent no-op sed is precisely the failure being prevented. The workflow runs it between the bump and the build, and the release commit now carries both files. Bumping by hand runs the same script. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
yurynix
added a commit
that referenced
this pull request
Sep 17, 2026
* ci(functions-compiler): add a manual npm publish workflow The CLI's manual-publish.yml cannot be reused for @base44/functions-compiler. Most of it is CLI-specific — standalone binaries, the Homebrew tap, PostHog sourcemaps, the skills-repo dispatch, the GitHub Release that carries the tarballs — and every one of those steps would need an `if:` guard on a package input, in the one job that must not break. The two trains also tag differently: `v<version>` is the CLI's series, so this one tags `functions-compiler-v<version>`. Sharing the file would buy nothing anyway. npm trusted publishing keys a publisher on the repo *and* the workflow filename, so @base44/functions-compiler needs its own registry entry either way; a separate file makes that entry narrower — it can publish this package and nothing else. Two differences from the CLI workflow worth naming: - Nothing is stripped from package.json before publish. The CLI deletes devDependencies because everything is bundled; here esbuild, @deno/loader and zod are real runtime dependencies consumers install. - The job only builds and publishes. The packaging proof that hits the registry (scripts/verify-package.ts) stays in functions-compiler.yml, behind the Wix gateway, where it runs on every push to main — so this job still resolves nothing, which is what its gateway exemption rests on. Before the first run, a trusted publisher for @base44/functions-compiler must be registered on npmjs.com against this repo and this filename. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci(functions-compiler): declare the repository for trusted publishing npm's trusted-publisher validation requires package.json's repository URL to match the GitHub repository the OIDC token comes from. packages/cli already declares it; this package did not, so the first publish would have failed the check with nothing in the workflow to explain why. `directory` points at the package inside the monorepo, which is also what makes the npm page link to the right subtree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(functions-compiler): keep COMPILER_VERSION in step with package.json main has been red since #623 landed: it added src/version.ts pinned to 0.1.0 and the test that guards it, having branched before #628 bumped package.json to 0.1.1. Setting the literal to 0.1.1 is the whole of that fix. The publish workflow would have reintroduced the drift on every release. `npm version` rewrites package.json alone, and the literal cannot be read from package.json at run time — a host that bundles this module ships none beside it (that is what src/version.ts documents). So a release would have published a banner naming the previous version and left main red again. scripts/sync-version.ts rewrites the literal from package.json and throws if it matches nothing, because a silent no-op sed is precisely the failure being prevented. The workflow runs it between the bump and the build, and the release commit now carries both files. Bumping by hand runs the same script. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Description
Moves the rest of apper's fresh-build function pipeline into
@base44/functions-compiler, so the CLI and apper's bundler service run one engine end to end. The package no longer just compiles a single module per call: it now assembles each function's reachable sources, plans the shard partition, compiles each shard, measures it against Cloudflare's ceilings, and halves any shard that is over one — all before anything is uploaded. It also stamps every compiled shard with a first-line manifest, so a script pulled back from Cloudflare can say what is inside it.Related Issue
Follow-up to #621 (extraction of the production function compiler into this monorepo). Ports apper's
function_bundle.py,shard_planning.py,cloudflare_wfp_runtime.pyand apper PR #23460.Type of Change
Changes Made
src/assembly.ts):collectReachableFiles/cfwBundleInputwalk a function's relative imports with esbuild's metafile and hand the compiler exactly the files the function reaches. Single-file functions keep the flatmain.tssubmission byte-for-byte; escapes into the frontend tree stay forbidden.src/shards/plan.ts):targetShardCount,assertWithinCapacityandplanFreshShardsfor the fresh (no previous deploy) path. Capacity is judged at the global shard size so a ratcheted-down packing size never locks an app out; the single-shard path keeps caller order while a multi-shard plan sorts by name, as apper does, because order changes the emitted bytes.src/shards/size.ts): raw UTF-8 bytes and level-6 gzip judged against Cloudflare's 64 MiB uncompressed ceiling and the supplied compressed cap. Compression runs off the event loop.src/shards/build.ts):compileFunctionShardscompiles shards with bounded concurrency, recursively halves any oversized shard, and refuses a partial result — every declared function must land in exactly one successful shard. Duplicate names are rejected before anything compiles.prepareAppnow emits a//!b44:1 {...}banner (sorted function names, wrapper flags, compiler version), prepended verbatim by esbuild so it survives minification. It deliberately carries nothing volatile, so unchanged code still hashes to the same version. The legacy single-functionbundle()lane stays bannerless and byte-comparable with the engine apper still runs.src/version.ts: the compiler version is now a literal rather than a runtimepackage.jsonread, which silently degraded to "unknown" once the CLI bundled the module — making the same functions compile to different bytes in the CLI and in the service.version.test.tsfails the build if the literal drifts frompackage.json.shardSizehung the chunking loop instead of refusing; the reachability walk rejected top-level await and thepath.sepworking dir that the compiler itself accepts; an unparseable source now falls back to the flat submission (so the compiler reports the real diagnostic) instead of throwing out of assembly, while a genuine walk-configuration fault still propagates.src/index.tsand pinned by the existing published-surface test.Testing
npm test) — 295 of 296. The one failure isshared-dep-conflict.e2e.test.ts, which resolvesdate-fns/date-fns-tzlive from the registry, is green in CI, and touches nothing on this branchSix new test files (~1,000 lines):
assembly.test.ts,shard-plan.test.ts,shard-size.test.ts,shard-build.e2e.test.ts,bundle-banner.e2e.test.ts,version.test.ts. They cover reachability against the Python fixtures, the capacity/ratchet and ordering rules, the one real 97 MB production rejection, multi-level split recursion, byte-identical recompiles, and banner survival past minification; the shard-build and banner suites are real compiles rather than mocks. CI runs the package'sbun run test— the two boxes above are left unchecked because the suite was not executed in this session.Checklist
docs/(AGENTS.md) if I made architectural changesAdditional Notes
Auto PR Description regenerates this body on every push, so the four paragraphs below are re-applied by hand after each one. They are the parts that cannot be read off the diff.
Reviewed independently, and the findings are fixed. A Claude Fable review (full findings in the thread) raised one blocker and four fix-here defects, all now on this branch: the reachability walk compiled as
iifeand so rejected top-level await, which the real compiler accepts; it passedpath.sepas a working directory, which is not absolute on Windows and failed every walk there; the fallback caught the compiler's own configuration faults and relabelled them as the user's unresolved import, which is what hid the first two;shardSize: 0hung a chunking loop instead of being refused; and the banner's version, read frompackage.jsonat runtime, degraded to"unknown"in the artifact the CLI bundles this module into, so the two lanes emitted different bytes.Two of the review's follow-ups are also in. The split recursion is now driven past one level — eight functions under a cap between a pair's size and a quad's, which only four shards of two can satisfy — along with the case where one function survives every halving and must fail the whole build rather than emit its siblings. And biome now passes over this PR's test files: CI lints
packages/*/srcandpackages/*/testswhile this package usestest/, so nothing was checking them and green CI was not evidence about them.Three follow-ups are deliberately still out: a split's halves run outside the concurrency limiter, over-capacity throws where compile failures return
ok: false, and an empty function list yields one empty shard.Two accepted trade-offs, decided rather than overlooked. The compiler version stays in the emitted bytes, so a release of this package re-mints a version for every app even when the compiled body is unchanged — accepted in exchange for artifacts that name what produced them. And when apper adopts this package the banner arrives with it, so every deployed per-app shard re-mints once, fleet-wide; that is planned as part of adoption.
One thing for base44-dev/apper#25154 to absorb. That PR asserts the packaged compiler and apper's in-repo engine emit byte-identical modules, and its table includes a two-function app bundle. The banner breaks that row by design, and it is deliberately not mirrored into the in-repo engine — apper inherits the banner by adopting this package, and that engine is on its way out. While both exist, that row is a transitional check and belongs narrowed to the single-function shapes, which stay bannerless.
Only the fresh-build slice crosses over. Incremental shard reuse, the per-app
shard_size_overrideratchet, entitlement and settings reads, provider upload, binding resolution and secret delivery stay in apper — policy numbers arrive as inputs and the package reads no configuration of its own. Nothing in the CLI calls the new surface yet; this PR lands the engine.🤖 Generated by Claude | 2026-09-17 13:56 UTC | 1596711