feat(functions-compiler): extract the production function compiler into the CLI monorepo - #621
Merged
Merged
Conversation
Adds @base44/functions-compiler at packages/functions-compiler: the engine that turns backend-function sources into a single Cloudflare Workers module, moved out of apper's infra/base44-userapp-bundler so the CLI can compile locally instead of calling a bundler service over HTTP. The move is verbatim. Every shim source, compile-time asset and moved test is byte-identical to apper at b27ce1f6; the library modules differ only by the .js import extensions this repo requires. Deliberate exceptions: - tracing.ts becomes an injectable CompilerTracer (default no-op) instead of importing dd-trace, so the compiler ships neither the tracer nor service credentials. apper's service registers its own adapter. - log.ts keeps the existing Datadog JSON writer as the default sink and gains setLogSink, so a CLI build can route diagnostics somewhere other than stdout. - src/index.ts is new: the package's public surface. - Four internal types lost their `export` to satisfy knip. What stays in apper: the HTTP service, auth, body limits, worker pool, deadlines, dd-trace wiring and the endpoint-envelope tests. Its engine copy is untouched — per the extraction plan it goes only once the service consumes a released version of this package. Packaging: lib/ mirrors the source layout so the plugins' relative asset reads resolve the same compiled or not. scripts/verify-package.ts packs the tarball, installs it in a directory that can see neither repo, and compiles a real function there. Evidence: 207/207 moved tests pass on Node 20.20.2 and 24.16.0; the packaged compiler produces identical bytes on both; and across six output modes (plain, post-response telemetry, runtime secrets, shared imports, actor, multi-function app) the extracted engine's modules hash the same as apper's, once esbuild's node_modules path-depth comments are normalized. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y modules Formats only the 11 modules the package actually compiles into lib/, plus three small lint fixes: two lazy shim reads move their `??=` out of `return`, and the esbuild result gets an explicit type. Compile-time assets are excluded in biome.json instead. Their text is injected verbatim into the user's bundle, so reformatting them is not cosmetic — measured over six output modes (plain, post-response telemetry, runtime secrets, shared imports, actor, multi-function app), formatting the shim and private-data-source sources changed every emitted module's hash. With them excluded, all six hashes are unchanged and still match apper's engine. `test/` stays byte-identical to apper's copy until that copy is deleted; the repo's `packages/*/src` lint glob does not reach it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `bun` export condition pointed at `./src/index.ts`, which the tarball does not ship — a published copy consumed under Bun would have resolved a missing file. Drop it: both consumers use the built output, and one resolution path means one answer to which code ran. Also tidies scripts/verify-package.ts (Node's mkdir instead of shelling out, and the .npmrc the consumer install actually reads). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropping the source-resolving export condition means knip can no longer infer the entry from package.json, so every re-export read as dead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three gaps, none of them covered by the suite that came across with the engine: - **Real repository paths.** Production sends `base44/functions/<name>/entry.ts` plus every reachable backend file, so relative imports cross directories. Every moved spec but one used a flat `main.ts`. Ported the fixtures from apper's test_function_bundle.py — which proves what its adapter collects and then asserts, in comments, what the compiler does with them — and ran them through the compiler: cross-directory and transitive shared imports compile and execute in workerd, and the escapes (`../../../src/...`, a missing target, absolute paths, `file:` URLs) are refused with the diagnostic the Python side promises the author will see. - **Partial app results.** `bundleApp` can return `ok: true` with a failed function. The service depends on it and a whole-app build must reject it, but the only coverage was a registry-cache-dependent conflict spec that skips its own assertions when the conflict does not fire. These fixtures fail for a resolution reason the compiler decides alone, so they are deterministic and need no registry. - **The hooks the extraction added.** `setLogSink` and `setCompilerTracer` had no tests at all, and nothing imported `src/index.ts`, so a rename in the published surface would have broken apper silently at its next upgrade. One test is a characterization, not an assertion of intent: `prepareApp`'s comment claims each function is sealed into its own `fn_<index>/` keyspace, and it is not — the resolver only checks map membership, so `../fn_1/main.ts` resolves. Same app, same Worker, and the manifest-store gate that guards credentials is separate and holds, so it is pinned rather than changed here. 230 tests over 22 files, green on Node 20.20.2 and 24.16.0. 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.14-pr.621.b7a8af7Prefer not to change any import paths? Install using npm alias so your code still imports npm i "base44@npm:@base44-preview/cli@0.1.14-pr.621.b7a8af7"Or add it to your {
"dependencies": {
"base44": "npm:@base44-preview/cli@0.1.14-pr.621.b7a8af7"
}
}
Preview published to npm registry — try new features instantly! |
…all ships Audited the packed tarball: 81 files, all under lib/ plus README and package.json. No keys, tokens, JWTs, connection strings, credentialed URLs, internal hosts or build-machine paths; the source maps carry no `sourcesContent`, and the only absolute URLs are upstream libraries' own issue links. The `BASE44_*` / `X-Base44-*` names that do ship are names the generated worker reads at runtime, never values — and that code already compiles into every deployed user worker. Two changes so this stays true: - `publishConfig.access: restricted`. A scoped package already defaults to restricted, but an invisible default is a bad thing to rely on for a package that carries proprietary runtime source. - `verify-package.ts` now asserts the tarball contains only `lib/`, `README.md` and `package.json`. It runs in CI, so widening `files` — which is how a test fixture or a local .npmrc reaches a registry — fails the build instead of shipping. Confirmed it fails when `files` is widened. The README gains a Status and scope section saying what the package is, what it is not, and that it holds no credentials of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README described the engine as it stands and left a reader to guess what a whole-app build still needs. Spells it out instead: the package compiles one module per call, and the shard planning, size measurement, overflow splitting, source assembly and whole-build validation that turn that into deployable Cloudflare Workers bundles are still Python in apper. Names where each piece lives today and what it must do, which half of the Python crosses over (fresh-build only — everything that remembers a previous deploy stays), and the two ordering/capacity details that would otherwise get "cleaned up" into a byte change during the port. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er's HTTP specs Auditing what the move actually carried across turned up a misclassification of my own: apper's `bundle.e2e`, `bundle-app.e2e` and `http-imports.e2e` were left behind as "service tests", but they are mixed. Auth, routing, body limits and the 400/413 envelope are the endpoint's; a large part of the rest is the compiler's, asserted only through HTTP and therefore uncovered here. Most of it turned out to be covered by the specs that did move — the package matrix, the workerd runtime suite, the runtime-secrets suite. Six were not, and this adds them: - A nested function name containing "/" compiles and still routes. The name is the routing key, and the plan flags the slash as an unresolved compatibility question, so it should fail loudly rather than be silently rewritten. - A dependency imported by two functions is inlined once, counted by lodash's own sentinel — the same marker apper's endpoint spec uses. This is the whole reason a shard is one compile instead of N. - A syntax error carries file, line, column and lineText, and an error inside a shared file is attributed to that file rather than the entry. - A `file:` URL reached indirectly through a `data:` module is refused. The data: module is resolved by the Deno resolver, not the user-files plugin, so it is the path where a filesystem read could slip past the check on the user's own imports. - A jsr: specifier resolves and runs. - A declared-but-absent optional dependency stays external instead of failing the build. 237 tests over 23 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
netanelgilad
approved these changes
Sep 14, 2026
This was referenced Sep 14, 2026
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
Adds
@base44/functions-compileras a new workspace package atpackages/functions-compiler— the production engine that turns backend-function sources into a single Cloudflare Workers module (generated worker entry, Deno shim, runtime and private-data-source assets, npm/jsr resolution through@deno/loader+ esbuild). It is extracted verbatim from apper'sinfra/base44-userapp-bundlerso the CLI can compile functions locally instead of calling a bundler service over HTTP, and both consumers run one engine. Nothing inpackages/cliconsumes it yet; this PR lands the package, its tests, its packaging proof and its CI lane.Related Issue
None
Type of Change
Changes Made
packages/functions-compiler(@base44/functions-compiler, publishedrestricted):bundle(one function) andbundleApp(several functions into one module with per-function status), error attribution, andsrc/index.tsas the sole public surface.deno-bundle.tsplus thesrc/esbuild/resolver and virtual-module plugins,worker-entry.ts,actor-compat.ts, thesrc/shim/sources, and thesrc/runtime/+src/private-data-sources/modules that are read as text and injected into the user bundle.setCompilerTracer(default no-op, replaces a directdd-traceimport) andsetLogSink(default remains the Datadog-shaped JSON writer), so the package ships no tracer and no service credentials.installFetchGuard()/createGuardedFetch()block outboundfetchother than the compiler's own dependency resolution.exportsresolves only throughlib/(thebun→src/index.tscondition was dropped, since the tarball does not shipsrc/);buildruns the shim build,tsc -p tsconfig.build.jsonandscripts/copy-assets.tsso the compile-time.tsassets sit next to the compiled JS;filesislib+README.md, andpublishConfig.accessis pinned torestrictedrather than relying on the scoped-package default.scripts/verify-package.tspacks the tarball, asserts it contains onlylib/,README.mdandpackage.json, installs it in a directory that can see neither repo, and compiles a real function (with annpm:dependency) on the hostnode. It runs in CI, so wideningfilesfails the build instead of shipping..github/workflows/functions-compiler.yml— typecheck, the vitest suite (compiles real npm packages and executes output in workerd via miniflare),bun run build, then the packaged-asset verification.biome.jsonexcludes the compile-time asset paths (reformatting them would change every compiled worker's bytes);knip.jsongains a workspace entry withsrc/index.tsas an explicit entry point;.npmrcmaps@jsr:tohttps://npm.jsr.iofor@deno/loader;bun.lockupdated for the new workspace.README.mdcovering status and scope (internal, restricted, carries no credentials of its own), the asset/"do not reformat" rule, layout, commands, and an explicit table of what has not moved yet (source assembly, shard planning, size measurement, split-on-overflow, whole-build validation);docs/AGENTS.mdgains the package in the architecture list.base44/functions/<name>/entry.tsfixtures with cross-directory and transitive imports, plus refusal of../escapes, absolute paths andfile:URLs), partialbundleAppresults (ok: truewith a failed function), the newsetLogSink/setCompilerTracerhooks, and six engine assertions previously reachable only through apper's HTTP specs (nested function names containing/, single-inlining of a dependency shared by two functions, syntax-error file/line/column attribution, refusal of afile:URL reached via adata:module,jsr:specifier resolution, and a declared-but-absent optional dependency staying external).Testing
npm test)Checklist
docs/(AGENTS.md) if I made architectural changesAdditional Notes
b27ce1f6; library modules differ only by the.jsimport extensions this repo requires, the injectable tracer/log sink, the newsrc/index.ts, and four internal types that lostexportto satisfy knip. Evidence recorded on the feature commit: 207/207 moved tests green on Node 20.20.2 and 24.16.0, and across six output modes the extracted engine's modules hash the same as apper's once esbuild'snode_modulespath-depth comments are normalized.src/shim/,src/runtime/,src/private-data-sources/,runtime-context.tsorstatic-egress*.ts— their text is injected into user bundles, so whitespace changes what every compiled function hashes to.biome.jsonandknip.jsonexclude them for that reason;test/is likewise held byte-identical until apper's copy is deleted.prepareApp's comment claims each function is sealed into its ownfn_<index>/keyspace, and it is not — the resolver only checks map membership, so../fn_1/main.tsresolves. Same app, same Worker, and the manifest-store gate that guards credentials is separate and holds, so the behaviour is pinned here rather than changed.bun run testinpackages/functions-compiler; the repo usesbun, notnpm test). The suite needs registry access and a few minutes because it compiles real npm packages and runs them in workerd.🤖 Generated by Claude | 2026-09-14 06:47 UTC | b7a8af7
How much of this is apper's code, measured
Diffed against
infra/base44-userapp-bundleratb27ce1f6, with whitespace removed so Biome's reflowing does not inflate the count.worker-entry.ts, differs by 52 characters out of 14,5740.059% of the moved code changed — 178 characters out of 302,381. All of it accounted for:
.json relative imports (3 characters each), two??=moved out of areturn, onelet result:annotation, andexportdropped from four internal types (6 characters each).The four files that are not moved code:
src/tracing.tssrc/log.tssetLogSinksrc/index.tsindex.tsis the server bootstrap, which did not come acrosstest/helpers.tsCharacter counts show the text was copied, not that the behaviour survived; a single character in the right place would be enough to break it. That is what the byte-parity evidence is for: across six compile modes the emitted modules hash identically to apper's, and 237 tests pass on Node 20.20.2 and 24.16.0.
To check it independently:
diff -r packages/functions-compiler/src <apper>/infra/base44-userapp-bundler/srcatb27ce1f6. The whole delta fits on a screen.What is missing: shards, and whole-app CFW bundles
This package compiles one module per call. It does not decide which functions belong in which module, how large the result may be, or what to do when it is too large — so it cannot yet produce the deployable Cloudflare Workers bundles for a whole app. That work is still Python in apper's
backend/app/cloudflare_functions/and is the next PR.function_bundle.py—cfw_bundle_input,collect_reachable_backend_filesentry+filesthis package takes, keeping the flat single-file case and the refusal to escape into the frontend treeshard_planning.py—full_repartition,target_shard_countcloudflare_wfp_runtime.py—measure_bundle_bytes,judge_bundle_sizecloudflare_wfp_runtime.py—_build_shard_with_splitOnly the fresh-build slice crosses over. Everything that remembers a previous deploy stays in apper: incremental shard reuse (it needs the previous deployment map), the per-app
shard_size_overrideratchet, entitlement and settings reads, provider upload, binding resolution and secret delivery. Policy numbers — shard size, shard count, the gzip cap, whether the gate is enforced — arrive as inputs; the package never reads them itself.Two details from the Python that must survive the port, both checked against the current code:
max_shards. A final "shard count ≤ max_shards" assertion would lock out apps that deploy fine today.Also out of scope for the new lane: Deno deployment targets, existing-Worker reuse, incremental deploy state, and actors — though the engine keeps its actor support for the legacy service that still uses it.
The same section is in the package README, which is where it stays current.