Skip to content

feat(functions-compiler): extract the production function compiler into the CLI monorepo - #621

Merged
yurynix merged 8 commits into
mainfrom
feat/functions-compiler-package
Sep 14, 2026
Merged

yurynix merged 8 commits into
mainfrom
feat/functions-compiler-package

Conversation

@yurynix

@yurynix yurynix commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Note

Description

Adds @base44/functions-compiler as a new workspace package at packages/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's infra/base44-userapp-bundler so the CLI can compile functions locally instead of calling a bundler service over HTTP, and both consumers run one engine. Nothing in packages/cli consumes it yet; this PR lands the package, its tests, its packaging proof and its CI lane.

Related Issue

None

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

  • New package packages/functions-compiler (@base44/functions-compiler, published restricted): bundle (one function) and bundleApp (several functions into one module with per-function status), error attribution, and src/index.ts as the sole public surface.
  • Compiler internals: deno-bundle.ts plus the src/esbuild/ resolver and virtual-module plugins, worker-entry.ts, actor-compat.ts, the src/shim/ sources, and the src/runtime/ + src/private-data-sources/ modules that are read as text and injected into the user bundle.
  • Host-injectable diagnostics instead of vendored ones: setCompilerTracer (default no-op, replaces a direct dd-trace import) and setLogSink (default remains the Datadog-shaped JSON writer), so the package ships no tracer and no service credentials.
  • Process isolation: installFetchGuard() / createGuardedFetch() block outbound fetch other than the compiler's own dependency resolution.
  • Packaging: exports resolves only through lib/ (the bunsrc/index.ts condition was dropped, since the tarball does not ship src/); build runs the shim build, tsc -p tsconfig.build.json and scripts/copy-assets.ts so the compile-time .ts assets sit next to the compiled JS; files is lib + README.md, and publishConfig.access is pinned to restricted rather than relying on the scoped-package default.
  • Tarball guard: scripts/verify-package.ts packs the tarball, asserts it contains only lib/, README.md and package.json, installs it in a directory that can see neither repo, and compiles a real function (with an npm: dependency) on the host node. It runs in CI, so widening files fails the build instead of shipping.
  • CI: new .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.
  • Repo config: biome.json excludes the compile-time asset paths (reformatting them would change every compiled worker's bytes); knip.json gains a workspace entry with src/index.ts as an explicit entry point; .npmrc maps @jsr: to https://npm.jsr.io for @deno/loader; bun.lock updated for the new workspace.
  • Docs: package README.md covering 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.md gains the package in the architecture list.
  • Tests: 23 files / 237 tests. Beyond the suite that moved with the engine, this adds coverage for seams the move left open — real repository paths (base44/functions/<name>/entry.ts fixtures with cross-directory and transitive imports, plus refusal of ../ escapes, absolute paths and file: URLs), partial bundleApp results (ok: true with a failed function), the new setLogSink / setCompilerTracer hooks, 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 a file: URL reached via a data: module, jsr: specifier resolution, and a declared-but-absent optional dependency staying external).

Testing

  • I have tested these changes locally
  • I have added/updated tests as needed
  • All tests pass (npm test)

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

  • The move is deliberately verbatim. Shim sources, compile-time assets and moved tests are byte-identical to apper at b27ce1f6; library modules differ only by the .js import extensions this repo requires, the injectable tracer/log sink, the new src/index.ts, and four internal types that lost export to 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's node_modules path-depth comments are normalized.
  • Do not run Biome or a formatter over src/shim/, src/runtime/, src/private-data-sources/, runtime-context.ts or static-egress*.ts — their text is injected into user bundles, so whitespace changes what every compiled function hashes to. biome.json and knip.json exclude them for that reason; test/ is likewise held byte-identical until apper's copy is deleted.
  • One test is a characterization rather than 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 the behaviour is pinned here rather than changed.
  • The package is not wired into the CLI in this PR, and apper's engine copy is untouched — per the extraction plan it goes only once the service consumes a released version of this package.
  • Test verification comes from this branch's commit evidence and the new CI lane (bun run test in packages/functions-compiler; the repo uses bun, not npm 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-bundler at b27ce1f6, with whitespace removed so Biome's reflowing does not inflate the count.

Byte-identical to apper 52 of 76 files — 7,018 lines, not one character changed
Differ only by formatting plus a few characters 14 files. The largest engine file, worker-entry.ts, differs by 52 characters out of 14,574
Genuinely new 4 files, plus 6 new files of tests and build scripts (693 lines)

0.059% of the moved code changed — 178 characters out of 302,381. All of it accounted for: .js on relative imports (3 characters each), two ??= moved out of a return, one let result: annotation, and export dropped from four internal types (6 characters each).

The four files that are not moved code:

File Lines What it is
src/tracing.ts 32 Rewritten — apper's imports dd-trace; this takes an injectable tracer, default no-op
src/log.ts 52 apper's writer kept verbatim as the default sink, plus ~20 lines of setLogSink
src/index.ts 35 New content under a shared name; apper's index.ts is the server bootstrap, which did not come across
test/helpers.ts 40 A subset — the two compile helpers, without the HTTP ones that stayed with the service

Character 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/src at b27ce1f6. 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.

Missing piece Where it lives today What it has to do
Source assembly function_bundle.pycfw_bundle_input, collect_reachable_backend_files Turn a function's directory plus the shared files it reaches into the entry + files this package takes, keeping the flat single-file case and the refusal to escape into the frontend tree
Fresh shard planning shard_planning.pyfull_repartition, target_shard_count Group an app's functions into shards deterministically, and refuse a set over the supplied product capacity
Size measurement cloudflare_wfp_runtime.pymeasure_bundle_bytes, judge_bundle_size Raw UTF-8 bytes and level-6 gzip, against Cloudflare's 64 MiB uncompressed limit and our compressed cap
Split on overflow cloudflare_wfp_runtime.py_build_shard_with_split Halve an oversized multi-function shard deterministically and recompile; fail the build when one function alone is too big
Whole-build validation apper #23460 Reject a partial result: every declared function in exactly one successful shard, or no build at all

Only 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_override ratchet, 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:

  • The single-shard path builds in caller order while the multi-shard path sorts by name. Function order changes the emitted bytes, so both branches carry over as they are — normalising them is a byte change dressed as a cleanup.
  • Capacity is judged at the global shard size while packing may use a smaller ratcheted one, so a legal plan can hold more shards than 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.

yurynix and others added 5 commits September 10, 2026 17:05
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>
@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.14-pr.621.b7a8af7

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.14-pr.621.b7a8af7"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "base44": "npm:@base44-preview/cli@0.1.14-pr.621.b7a8af7"
  }
}

Preview published to npm registry — try new features instantly!

yurynix and others added 3 commits September 14, 2026 09:20
…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>
@yurynix
yurynix merged commit 7fe50db into main Sep 14, 2026
16 checks passed
@yurynix
yurynix deleted the feat/functions-compiler-package branch September 14, 2026 08:01
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