Skip to content

feat(functions-compiler): plan, size and split an app's function shards - #623

Merged
yurynix merged 11 commits into
mainfrom
feat/functions-compiler-shard-planner
Sep 17, 2026
Merged

yurynix merged 11 commits into
mainfrom
feat/functions-compiler-shard-planner

Conversation

@yurynix

@yurynix yurynix commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

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.py and apper PR #23460.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Other (please describe):

Changes Made

  • Source assembly (src/assembly.ts): collectReachableFiles / cfwBundleInput walk a function's relative imports with esbuild's metafile and hand the compiler exactly the files the function reaches. Single-file functions keep the flat main.ts submission byte-for-byte; escapes into the frontend tree stay forbidden.
  • Shard planning (src/shards/plan.ts): targetShardCount, assertWithinCapacity and planFreshShards for 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.
  • Size measurement (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.
  • Split on overflow + whole-build validation (src/shards/build.ts): compileFunctionShards compiles 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.
  • Self-describing bundles: prepareApp now 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-function bundle() 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 runtime package.json read, 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.ts fails the build if the literal drifts from package.json.
  • Fixes found while porting: a zero or negative shardSize hung the chunking loop instead of refusing; the reachability walk rejected top-level await and the path.sep working 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.
  • Public surface: the new assembly, plan, size and shard-build entry points are re-exported from src/index.ts and pinned by the existing published-surface test.
  • README: rewritten from "what is missing" to what the package now does, with a provenance table, the banner format, the reproducibility contract, and the two deliberate divergences from apper's Python (unparseable-source handling, and Node's gzip reading ~0.7% heavier than Python's).

Testing

  • I have tested these changes locally
  • I have added/updated tests as needed
  • All tests pass (npm test) — 295 of 296. The one failure is shared-dep-conflict.e2e.test.ts, which resolves date-fns/date-fns-tz live from the registry, is green in CI, and touches nothing on this branch

Six 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's bun run test — the two boxes above are left unchecked because the suite was not executed in this session.

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (if applicable)
  • My changes generate no new warnings
  • I have updated docs/ (AGENTS.md) if I made architectural changes

Additional 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 iife and so rejected top-level await, which the real compiler accepts; it passed path.sep as 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: 0 hung a chunking loop instead of being refused; and the banner's version, read from package.json at 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/*/src and packages/*/tests while this package uses test/, 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_override ratchet, 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

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>
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

🚀 Package Preview Available!


Install this PR's preview build with npm:

npm i @base44-preview/cli@0.1.15-pr.623.1596711

Prefer not to change any import paths? Install using npm alias so your code still imports base44:

npm i "base44@npm:@base44-preview/cli@0.1.15-pr.623.1596711"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "base44": "npm:@base44-preview/cli@0.1.15-pr.623.1596711"
  }
}

Preview published to npm registry — try new features instantly!

yurynix and others added 10 commits September 14, 2026 11:21
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>
@yurynix
yurynix merged commit c9e6069 into main Sep 17, 2026
17 checks passed
@yurynix
yurynix deleted the feat/functions-compiler-shard-planner branch September 17, 2026 15:41
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants