From 44d4bf04f9da28d6a75b0f4ab6e400f4127fb89e Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Thu, 10 Sep 2026 17:05:32 +0300 Subject: [PATCH 1/8] feat(functions-compiler): extract the production function compiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/functions-compiler.yml | 55 + .npmrc | 5 + bun.lock | 47 +- docs/AGENTS.md | 1 + knip.json | 48 +- packages/functions-compiler/.gitignore | 3 + packages/functions-compiler/README.md | 72 ++ packages/functions-compiler/package.json | 41 + .../functions-compiler/scripts/build-shim.ts | 74 ++ .../functions-compiler/scripts/copy-assets.ts | 29 + .../scripts/verify-package.ts | 80 ++ .../functions-compiler/src/actor-compat.ts | 334 +++++ packages/functions-compiler/src/bundler.ts | 458 +++++++ .../src/cloudflare-workers.d.ts | 19 + packages/functions-compiler/src/contracts.ts | 50 + .../functions-compiler/src/deno-bundle.ts | 180 +++ packages/functions-compiler/src/errors.ts | 21 + .../src/esbuild/deno-resolver.ts | 534 ++++++++ .../src/esbuild/node-builtin-require.ts | 39 + .../esbuild/private-data-sources-virtual.ts | 133 ++ .../src/esbuild/runtime-context-virtual.ts | 52 + .../src/esbuild/runtime-virtual.ts | 54 + .../src/esbuild/user-files.ts | 93 ++ .../functions-compiler/src/fetch-guard.ts | 82 ++ packages/functions-compiler/src/index.ts | 36 + packages/functions-compiler/src/log.ts | 52 + .../src/private-data-sources/build-http.ts | 51 + .../src/private-data-sources/elasticsearch.ts | 8 + .../src/private-data-sources/http-url.ts | 23 + .../src/private-data-sources/http.ts | 6 + .../src/private-data-sources/hyperdrive.ts | 48 + .../private-data-sources/ioredis-adapter.ts | 229 ++++ .../src/private-data-sources/manifest.ts | 150 +++ .../src/private-data-sources/mariadb.ts | 8 + .../src/private-data-sources/mongodb.ts | 70 ++ .../src/private-data-sources/mysql.ts | 8 + .../private-data-sources/node-net-adapter.ts | 244 ++++ .../src/private-data-sources/postgres.ts | 8 + .../src/private-data-sources/redis-auth.ts | 83 ++ .../src/private-data-sources/redis.ts | 47 + .../runtime-environment.ts | 4 + .../runtime-manifest-store.ts | 32 + .../src/private-data-sources/sqlserver.ts | 22 + .../src/private-data-sources/tcp.ts | 86 ++ .../private-data-sources/tedious-adapter.ts | 67 + .../src/private-data-sources/types.ts | 29 + .../functions-compiler/src/runtime-context.ts | 29 + .../functions-compiler/src/runtime/index.ts | 30 + .../functions-compiler/src/shim/activation.ts | 408 ++++++ packages/functions-compiler/src/shim/actor.ts | 602 +++++++++ packages/functions-compiler/src/shim/entry.ts | 117 ++ .../functions-compiler/src/shim/tick-loop.ts | 95 ++ .../src/static-egress-marker.ts | 7 + .../functions-compiler/src/static-egress.ts | 177 +++ packages/functions-compiler/src/telemetry.ts | 85 ++ packages/functions-compiler/src/tracing.ts | 34 + .../functions-compiler/src/worker-entry.ts | 370 ++++++ .../test/actor-bundle.e2e.test.ts | 316 +++++ .../test/actor-compat.test.ts | 208 +++ .../test/actor-schedule.test.ts | 280 +++++ .../test/assembly-conflict.test.ts | 93 ++ .../test/base44-sdk-stub.ts | 6 + .../test/classify-app-errors.test.ts | 37 + .../test/deno-shim-apis.e2e.test.ts | 137 ++ .../test/fetch-guard.test.ts | 79 ++ packages/functions-compiler/test/helpers.ts | 40 + .../test/ioredis-adapter.test.ts | 39 + .../test/package-matrix-data.ts | 160 +++ .../test/package-matrix.e2e.test.ts | 75 ++ .../private-data-sources-http-url.test.ts | 44 + .../private-data-sources-manifest.test.ts | 127 ++ ...private-data-sources-request-scope.test.ts | 141 +++ .../test/redis-auth.test.ts | 60 + .../test/runtime-secrets.e2e.test.ts | 600 +++++++++ .../test/shared-dep-conflict.e2e.test.ts | 112 ++ .../test/static-egress.test.ts | 286 +++++ .../functions-compiler/test/tick-loop.test.ts | 166 +++ .../test/worker-entry.test.ts | 235 ++++ .../test/workerd-runtime.e2e.test.ts | 1114 +++++++++++++++++ packages/functions-compiler/test/workerd.ts | 46 + .../functions-compiler/tsconfig.build.json | 25 + packages/functions-compiler/tsconfig.json | 17 + packages/functions-compiler/vitest.config.ts | 23 + 83 files changed, 10224 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/functions-compiler.yml create mode 100644 packages/functions-compiler/.gitignore create mode 100644 packages/functions-compiler/README.md create mode 100644 packages/functions-compiler/package.json create mode 100644 packages/functions-compiler/scripts/build-shim.ts create mode 100644 packages/functions-compiler/scripts/copy-assets.ts create mode 100644 packages/functions-compiler/scripts/verify-package.ts create mode 100644 packages/functions-compiler/src/actor-compat.ts create mode 100644 packages/functions-compiler/src/bundler.ts create mode 100644 packages/functions-compiler/src/cloudflare-workers.d.ts create mode 100644 packages/functions-compiler/src/contracts.ts create mode 100644 packages/functions-compiler/src/deno-bundle.ts create mode 100644 packages/functions-compiler/src/errors.ts create mode 100644 packages/functions-compiler/src/esbuild/deno-resolver.ts create mode 100644 packages/functions-compiler/src/esbuild/node-builtin-require.ts create mode 100644 packages/functions-compiler/src/esbuild/private-data-sources-virtual.ts create mode 100644 packages/functions-compiler/src/esbuild/runtime-context-virtual.ts create mode 100644 packages/functions-compiler/src/esbuild/runtime-virtual.ts create mode 100644 packages/functions-compiler/src/esbuild/user-files.ts create mode 100644 packages/functions-compiler/src/fetch-guard.ts create mode 100644 packages/functions-compiler/src/index.ts create mode 100644 packages/functions-compiler/src/log.ts create mode 100644 packages/functions-compiler/src/private-data-sources/build-http.ts create mode 100644 packages/functions-compiler/src/private-data-sources/elasticsearch.ts create mode 100644 packages/functions-compiler/src/private-data-sources/http-url.ts create mode 100644 packages/functions-compiler/src/private-data-sources/http.ts create mode 100644 packages/functions-compiler/src/private-data-sources/hyperdrive.ts create mode 100644 packages/functions-compiler/src/private-data-sources/ioredis-adapter.ts create mode 100644 packages/functions-compiler/src/private-data-sources/manifest.ts create mode 100644 packages/functions-compiler/src/private-data-sources/mariadb.ts create mode 100644 packages/functions-compiler/src/private-data-sources/mongodb.ts create mode 100644 packages/functions-compiler/src/private-data-sources/mysql.ts create mode 100644 packages/functions-compiler/src/private-data-sources/node-net-adapter.ts create mode 100644 packages/functions-compiler/src/private-data-sources/postgres.ts create mode 100644 packages/functions-compiler/src/private-data-sources/redis-auth.ts create mode 100644 packages/functions-compiler/src/private-data-sources/redis.ts create mode 100644 packages/functions-compiler/src/private-data-sources/runtime-environment.ts create mode 100644 packages/functions-compiler/src/private-data-sources/runtime-manifest-store.ts create mode 100644 packages/functions-compiler/src/private-data-sources/sqlserver.ts create mode 100644 packages/functions-compiler/src/private-data-sources/tcp.ts create mode 100644 packages/functions-compiler/src/private-data-sources/tedious-adapter.ts create mode 100644 packages/functions-compiler/src/private-data-sources/types.ts create mode 100644 packages/functions-compiler/src/runtime-context.ts create mode 100644 packages/functions-compiler/src/runtime/index.ts create mode 100644 packages/functions-compiler/src/shim/activation.ts create mode 100644 packages/functions-compiler/src/shim/actor.ts create mode 100644 packages/functions-compiler/src/shim/entry.ts create mode 100644 packages/functions-compiler/src/shim/tick-loop.ts create mode 100644 packages/functions-compiler/src/static-egress-marker.ts create mode 100644 packages/functions-compiler/src/static-egress.ts create mode 100644 packages/functions-compiler/src/telemetry.ts create mode 100644 packages/functions-compiler/src/tracing.ts create mode 100644 packages/functions-compiler/src/worker-entry.ts create mode 100644 packages/functions-compiler/test/actor-bundle.e2e.test.ts create mode 100644 packages/functions-compiler/test/actor-compat.test.ts create mode 100644 packages/functions-compiler/test/actor-schedule.test.ts create mode 100644 packages/functions-compiler/test/assembly-conflict.test.ts create mode 100644 packages/functions-compiler/test/base44-sdk-stub.ts create mode 100644 packages/functions-compiler/test/classify-app-errors.test.ts create mode 100644 packages/functions-compiler/test/deno-shim-apis.e2e.test.ts create mode 100644 packages/functions-compiler/test/fetch-guard.test.ts create mode 100644 packages/functions-compiler/test/helpers.ts create mode 100644 packages/functions-compiler/test/ioredis-adapter.test.ts create mode 100644 packages/functions-compiler/test/package-matrix-data.ts create mode 100644 packages/functions-compiler/test/package-matrix.e2e.test.ts create mode 100644 packages/functions-compiler/test/private-data-sources-http-url.test.ts create mode 100644 packages/functions-compiler/test/private-data-sources-manifest.test.ts create mode 100644 packages/functions-compiler/test/private-data-sources-request-scope.test.ts create mode 100644 packages/functions-compiler/test/redis-auth.test.ts create mode 100644 packages/functions-compiler/test/runtime-secrets.e2e.test.ts create mode 100644 packages/functions-compiler/test/shared-dep-conflict.e2e.test.ts create mode 100644 packages/functions-compiler/test/static-egress.test.ts create mode 100644 packages/functions-compiler/test/tick-loop.test.ts create mode 100644 packages/functions-compiler/test/worker-entry.test.ts create mode 100644 packages/functions-compiler/test/workerd-runtime.e2e.test.ts create mode 100644 packages/functions-compiler/test/workerd.ts create mode 100644 packages/functions-compiler/tsconfig.build.json create mode 100644 packages/functions-compiler/tsconfig.json create mode 100644 packages/functions-compiler/vitest.config.ts diff --git a/.github/workflows/functions-compiler.yml b/.github/workflows/functions-compiler.yml new file mode 100644 index 000000000..480430d05 --- /dev/null +++ b/.github/workflows/functions-compiler.yml @@ -0,0 +1,55 @@ +name: Functions Compiler + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + functions-compiler: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/functions-compiler + + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + + - name: Setup Bun + id: setup-bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-version }}-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-version }}- + + - name: Install dependencies + run: bun install --frozen-lockfile + working-directory: . + + - name: Run typecheck + run: bun run typecheck + + # The suite compiles real npm packages and executes the output in workerd + # (miniflare), so it needs the registry and a few minutes. + - name: Run tests + run: bun run test + + # Proves the published tarball carries every runtime asset: the shims and + # the .ts modules the esbuild plugins read as text. + - name: Build the publishable package + run: bun run build + + - name: Verify packaged assets + run: bun run scripts/verify-package.ts diff --git a/.npmrc b/.npmrc index 214c29d13..fce1be97f 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,6 @@ registry=https://registry.npmjs.org/ + +# JSR packages (consumed via npm-compat) resolve from JSR's own registry. +# Used by @base44/functions-compiler for @deno/loader, the same way apper's +# bundler service resolves it. +@jsr:registry=https://npm.jsr.io diff --git a/bun.lock b/bun.lock index d7e535e73..a5378fbbb 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ }, "packages/cli": { "name": "base44", - "version": "0.1.7", + "version": "0.1.14", "bin": { "base44": "./bin/run.js", }, @@ -75,6 +75,23 @@ "zod": "^4.3.5", }, }, + "packages/functions-compiler": { + "name": "@base44/functions-compiler", + "version": "0.1.0", + "dependencies": { + "@deno/loader": "npm:@jsr/deno__loader@0.5.0", + "esbuild": "0.28.0", + "zod": "3.25.76", + }, + "devDependencies": { + "@deno/shim-deno": "0.19.2", + "@types/node": "^22.10.0", + "miniflare": "^4.20260529.0", + "partyserver": "0.0.56", + "typescript": "^5.9.3", + "vitest": "^4.0.16", + }, + }, "packages/logger": { "name": "@base44-cli/logger", "version": "0.0.1", @@ -89,6 +106,8 @@ "@base44-cli/logger": ["@base44-cli/logger@workspace:packages/logger"], + "@base44/functions-compiler": ["@base44/functions-compiler@workspace:packages/functions-compiler"], + "@base44/sdk": ["@base44/sdk@0.8.23", "", { "dependencies": { "axios": "^1.6.2", "socket.io-client": "^4.7.5", "uuid": "^13.0.0" } }, "sha512-udQwd9VikwsUjstf+Af4dr9rhMww9F8WExccnKihPX0V9Tk3SPvWvnhujMwWdjT8g2vy2j9rP4jIT76Yn+OKcA=="], "@biomejs/biome": ["@biomejs/biome@2.4.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.6", "@biomejs/cli-darwin-x64": "2.4.6", "@biomejs/cli-linux-arm64": "2.4.6", "@biomejs/cli-linux-arm64-musl": "2.4.6", "@biomejs/cli-linux-x64": "2.4.6", "@biomejs/cli-linux-x64-musl": "2.4.6", "@biomejs/cli-win32-arm64": "2.4.6", "@biomejs/cli-win32-x64": "2.4.6" }, "bin": { "biome": "bin/biome" } }, "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ=="], @@ -123,9 +142,15 @@ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260722.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260702.1", "", {}, "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - "@deno/loader": ["@jsr/deno__loader@https://npm.jsr.io/~/11/@jsr/deno__loader/0.5.0.tgz", {}, "sha512-sf/YBwnyAsbyeYYB71Zdj2Ca2Q9tt25EZpAiZdDA9W7Mm3GcpjA2WMeqj19xPIurZK12G3OAsa5yrtPB7E+gvA=="], + "@deno/loader": ["@jsr/deno__loader@0.5.0", "https://npm.jsr.io/~/11/@jsr/deno__loader/0.5.0.tgz", {}, "sha512-sf/YBwnyAsbyeYYB71Zdj2Ca2Q9tt25EZpAiZdDA9W7Mm3GcpjA2WMeqj19xPIurZK12G3OAsa5yrtPB7E+gvA=="], + + "@deno/shim-deno": ["@deno/shim-deno@0.19.2", "", { "dependencies": { "@deno/shim-deno-test": "^0.5.0", "which": "^4.0.0" } }, "sha512-q3VTHl44ad8T2Tw2SpeAvghdGOjlnLPDNO2cpOxwMrBE/PVas6geWpbpIgrM+czOCH0yejp0yi8OaTuB+NU40Q=="], + + "@deno/shim-deno-test": ["@deno/shim-deno-test@0.5.0", "", {}, "sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w=="], "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -733,7 +758,7 @@ "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], @@ -845,6 +870,8 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "partyserver": ["partyserver@0.0.56", "", { "dependencies": { "nanoid": "^5.0.7" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0" } }, "sha512-6zdoS/0iBbYatSJe4WtMoCGWDL1I+pGdVlaHdME/TNBv0592Io0AGKWkEQCutHCkIht32AeNdUR66VpsXBaB/w=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], @@ -1033,7 +1060,7 @@ "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], @@ -1071,14 +1098,20 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "base44/@deno/loader": ["@jsr/deno__loader@https://npm.jsr.io/~/11/@jsr/deno__loader/0.5.0.tgz", {}, "sha512-sf/YBwnyAsbyeYYB71Zdj2Ca2Q9tt25EZpAiZdDA9W7Mm3GcpjA2WMeqj19xPIurZK12G3OAsa5yrtPB7E+gvA=="], + + "base44/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "engine.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "engine.io/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], @@ -1091,6 +1124,8 @@ "json-schema-to-typescript/@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="], + "knip/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "msw/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -1121,6 +1156,8 @@ "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "engine.io/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "engine.io/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 9257f724f..45504ec80 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -22,6 +22,7 @@ The codebase has two layers with a clear separation of concerns: - **`packages/cli/bin/`** - Entry points: `run.js` (production, Node.js) and `dev.ts` (development, Bun runs TypeScript directly). - **`packages/cli/templates/`** - Project scaffolding templates for `base44 create`. - **`packages/cli/tests/`** - CLI integration tests (`cli/`), core unit tests (`core/`), and test fixtures (`fixtures/`). +- **`packages/functions-compiler/`** - `@base44/functions-compiler`: the production compiler that turns backend-function sources into a single Cloudflare Workers module. Extracted from apper's `base44-userapp-bundler` so the CLI and that HTTP service run one engine. Owns its own `bun run test` / `typecheck` / `build`; see its README before editing — several files under `src/` are compile-time **assets** read as text, not modules. ``` packages/cli/src/ diff --git a/knip.json b/knip.json index a80f92655..63caa03ac 100644 --- a/knip.json +++ b/knip.json @@ -1,15 +1,51 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "include": ["classMembers"], + "include": [ + "classMembers" + ], "workspaces": { "packages/cli": { - "entry": ["src/cli/index.ts", "bin/binary-entry.ts", "tests/**/testkit/index.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignore": ["tests/fixtures/**"], - "ignoreDependencies": ["@types/deno"] + "entry": [ + "src/cli/index.ts", + "bin/binary-entry.ts", + "tests/**/testkit/index.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ], + "ignore": [ + "tests/fixtures/**" + ], + "ignoreDependencies": [ + "@types/deno" + ] + }, + "packages/functions-compiler": { + "entry": [ + "scripts/*.ts", + "test/**/*.test.ts" + ], + "project": [ + "src/**/*.ts", + "test/**/*.ts", + "scripts/**/*.ts" + ], + "ignore": [ + "src/shim/**", + "src/runtime/**", + "src/runtime-context.ts", + "src/static-egress.ts", + "src/private-data-sources/**" + ], + "ignoreDependencies": [ + "@deno/shim-deno" + ] }, "packages/logger": { - "project": ["src/**/*.ts"] + "project": [ + "src/**/*.ts" + ] } } } diff --git a/packages/functions-compiler/.gitignore b/packages/functions-compiler/.gitignore new file mode 100644 index 000000000..e97a7fb75 --- /dev/null +++ b/packages/functions-compiler/.gitignore @@ -0,0 +1,3 @@ +# Generated shims (`bun run build:shim`) and the published build output. +dist/ +lib/ diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md new file mode 100644 index 000000000..69d26b41c --- /dev/null +++ b/packages/functions-compiler/README.md @@ -0,0 +1,72 @@ +# @base44/functions-compiler + +Production compiler for Base44 backend functions. Takes function sources as +data and returns a single Cloudflare Workers module — the generated worker +entry, the Deno shim, the runtime and private-data-source modules, and npm/jsr +dependency resolution through `@deno/loader` + esbuild. + +Two consumers share this one engine: + +- the **Base44 CLI**, which compiles locally (and inside the platform's build + sandbox) with no network service in the path; +- apper's **`base44-userapp-bundler`** HTTP service, which keeps its own + endpoints, auth, worker pool and telemetry around it. + +## Using it + +```ts +import { bundle, bundleApp } from "@base44/functions-compiler"; + +const result = await bundle({ + entry: "main.ts", + files: { "main.ts": "export default { fetch: () => new Response('hi') }" }, +}); +if (result.ok) console.log(result.module); +``` + +`bundleApp` compiles several functions into one combined module and reports +per-function status; a successful response can still contain failed functions, +so a caller that needs a whole-app build must reject those itself. + +### Process isolation + +`installFetchGuard()` blocks outbound `fetch` other than the dependency +resolution the compiler itself performs. Install it in the worker or child +process that runs a compile — not in a process that later needs the network. + +### Diagnostics + +The compiler ships no tracer and no service credentials. Hosts that want spans +or structured logs register their own, in the thread that runs the compile: + +```ts +import { setCompilerTracer, setLogSink } from "@base44/functions-compiler"; + +setCompilerTracer({ withSpan, setSpanTags }); // e.g. a dd-trace adapter +setLogSink((level, event, fields) => myLogger[level](event, fields)); +``` + +Without a sink, `logEvent` writes the Datadog-shaped JSON line it always has. + +## Layout + +| Path | What it is | +|---|---| +| `src/bundler.ts` | Single-function and combined-app compilation, error attribution | +| `src/deno-bundle.ts`, `src/esbuild/` | esbuild execution and the resolver/virtual-module plugins | +| `src/worker-entry.ts`, `src/actor-compat.ts` | Generated worker entry and the Actor wrapper | +| `src/shim/`, `src/static-egress.ts` | Shim sources — esbuild inputs for `build:shim`, never imported | +| `src/runtime/`, `src/runtime-context.ts`, `src/private-data-sources/` | Compile-time assets read as **text** and injected into the user bundle | + +The compile-time assets must stay TypeScript: the virtual plugins load them +with esbuild's `ts` loader. `scripts/copy-assets.ts` copies them into `lib/` +next to the compiled JS so the published package resolves them the same way. + +## Commands + +```bash +bun run build:shim # regenerate dist/{deno-shim,activation-shim,actor}.mjs +bun run test # vitest (builds the shims first) +bun run typecheck # tsc --noEmit over src/, test/, scripts/ +bun run build # shims + tsc -> lib/ + assets; what gets published +``` diff --git a/packages/functions-compiler/package.json b/packages/functions-compiler/package.json new file mode 100644 index 000000000..d7605d170 --- /dev/null +++ b/packages/functions-compiler/package.json @@ -0,0 +1,41 @@ +{ + "name": "@base44/functions-compiler", + "version": "0.1.0", + "description": "Production compiler for Base44 backend functions — turns function sources into a single Cloudflare Workers module.", + "license": "MIT", + "type": "module", + "exports": { + ".": { + "bun": "./src/index.ts", + "types": "./lib/src/index.d.ts", + "default": "./lib/src/index.js" + } + }, + "files": [ + "lib", + "README.md" + ], + "scripts": { + "build": "bun run clean && bun run build:shim && tsc -p tsconfig.build.json && bun run scripts/copy-assets.ts", + "build:shim": "bun run scripts/build-shim.ts", + "clean": "rm -rf dist lib", + "typecheck": "tsc --noEmit", + "test": "bun run build:shim && vitest run" + }, + "dependencies": { + "@deno/loader": "npm:@jsr/deno__loader@0.5.0", + "esbuild": "0.28.0", + "zod": "3.25.76" + }, + "devDependencies": { + "@deno/shim-deno": "0.19.2", + "@types/node": "^22.10.0", + "miniflare": "^4.20260529.0", + "partyserver": "0.0.56", + "typescript": "^5.9.3", + "vitest": "^4.0.16" + }, + "engines": { + "node": ">=20.19.0" + } +} diff --git a/packages/functions-compiler/scripts/build-shim.ts b/packages/functions-compiler/scripts/build-shim.ts new file mode 100644 index 000000000..8fe098807 --- /dev/null +++ b/packages/functions-compiler/scripts/build-shim.ts @@ -0,0 +1,74 @@ +// Generates dist/deno-shim.mjs, dist/activation-shim.mjs, and dist/actor.mjs +// (the injected shims, build artifacts — not committed). Re-run after changing +// entry.ts / activation.ts / actor.ts or bumping @deno/shim-deno. + +import { fileURLToPath } from "node:url"; + +import { build, type BuildOptions } from "esbuild"; + +import { nodeBuiltinRequirePlugin } from "../src/esbuild/node-builtin-require.js"; +import { RUNTIME_CONTEXT_SPECIFIER } from "../src/esbuild/runtime-context-virtual.js"; + +const resolve = (rel: string) => fileURLToPath(new URL(rel, import.meta.url)); + +const shared: BuildOptions = { + bundle: true, + format: "esm", + target: "es2022", + platform: "node", + mainFields: ["module", "main"], + conditions: ["import", "node"], + // Keep the internal manifest-store + runtime-context imports external here so + // they survive into the dist shims as bare specifiers; the FINAL app compile's + // virtual plugins resolve them (the manifest store deduped with manifest.ts to + // a single module instance — see runtime-manifest-store). + external: [ + "cloudflare:workers", + "base44:private-data-sources/runtime-manifest-store", + RUNTIME_CONTEXT_SPECIFIER, + ], + treeShaking: true, + minify: false, + legalComments: "none", + plugins: [nodeBuiltinRequirePlugin()], +}; + +await build({ + ...shared, + entryPoints: [resolve("../src/shim/entry.ts")], + outfile: resolve("../dist/deno-shim.mjs"), + banner: { + js: "// GENERATED by scripts/build-shim.ts from src/shim/entry.ts — do not edit.\n// Regenerate with `npm run build:shim`.", + }, +}); + +await build({ + ...shared, + entryPoints: [resolve("../src/shim/activation.ts")], + outfile: resolve("../dist/activation-shim.mjs"), + banner: { + js: "// GENERATED by scripts/build-shim.ts from src/shim/activation.ts — do not edit.\n// Regenerate with `npm run build:shim`.", + }, +}); + +// Actor shim targets CF Workers (browser-like, no Node.js APIs). +await build({ + entryPoints: [resolve("../src/shim/actor.ts")], + outfile: resolve("../dist/actor.mjs"), + bundle: true, + format: "esm", + target: "es2022", + platform: "browser", + // Any @base44/sdk version stays external — the Deno resolver bundles it at + // user-function-bundle time. Version-agnostic so this list never needs a bump; + // the actual version is the pin on the shim's own import in src/shim/actor.ts. + external: ["cloudflare:workers", "npm:@base44/sdk*"], + treeShaking: true, + minify: false, + legalComments: "none", + banner: { + js: "// GENERATED by scripts/build-shim.ts from src/shim/actor.ts — do not edit.\n// Regenerate with `npm run build:shim`.", + }, +}); + +console.log("built dist/deno-shim.mjs + dist/activation-shim.mjs + dist/actor.mjs"); diff --git a/packages/functions-compiler/scripts/copy-assets.ts b/packages/functions-compiler/scripts/copy-assets.ts new file mode 100644 index 000000000..d8f91e7e2 --- /dev/null +++ b/packages/functions-compiler/scripts/copy-assets.ts @@ -0,0 +1,29 @@ +// The published package mirrors the source layout under lib/, so every +// `new URL("../", import.meta.url)` in the compiled plugins resolves the +// same way it does from src/. Two families land here: +// - .ts modules the esbuild plugins read as TEXT and hand to esbuild with a +// "ts" loader (runtime-context, runtime/index, private-data-sources/*). +// They must stay TypeScript — tsc must never compile them. +// - the generated shims from build-shim.ts (dist/*.mjs), injected verbatim. + +import { cp, mkdir, readdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const root = (rel: string) => fileURLToPath(new URL(`../${rel}`, import.meta.url)); + +const TEXT_ASSETS = [ + "src/runtime-context.ts", + "src/runtime", + "src/private-data-sources", +]; + +for (const asset of TEXT_ASSETS) { + await cp(root(asset), root(`lib/${asset}`), { recursive: true }); +} + +await mkdir(root("lib/dist"), { recursive: true }); +for (const shim of await readdir(root("dist"))) { + if (shim.endsWith(".mjs")) await cp(root(`dist/${shim}`), root(`lib/dist/${shim}`)); +} + +console.log(`copied ${TEXT_ASSETS.length} text assets + shims into lib/`); diff --git a/packages/functions-compiler/scripts/verify-package.ts b/packages/functions-compiler/scripts/verify-package.ts new file mode 100644 index 000000000..f7367967f --- /dev/null +++ b/packages/functions-compiler/scripts/verify-package.ts @@ -0,0 +1,80 @@ +// Packaging proof: pack the package, install the tarball into a throwaway +// directory that can see neither this repo nor apper, and run a real compile +// there on the host `node`. Catches the failure a source-tree test cannot — +// a runtime asset (a shim, or a .ts module the plugins read as text) that the +// build never copied into lib/, or a native/WASM dependency that does not load. +// +// bun run scripts/verify-package.ts # uses `node` from PATH +// NODE_BIN=/path/to/node20 bun run ... # pin the toolchain under test + +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const nodeBin = process.env.NODE_BIN ?? "node"; + +if (!existsSync(path.join(packageRoot, "lib", "src", "index.js"))) { + throw new Error("lib/ is missing — run `bun run build` first."); +} + +const run = (cmd: string, args: string[], cwd: string) => + execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }); + +// A function that exercises every asset family at once: the injected Deno shim +// and worker entry, the base44:runtime virtual module, and npm resolution. +const PROBE_FUNCTION = ` +import { createHash } from "node:crypto"; +import slugify from "npm:slugify@1.6.6"; + +Deno.serve(() => { + const tag = slugify("Packaging Proof"); + const digest = createHash("sha256").update(tag).digest("hex").slice(0, 8); + return new Response(JSON.stringify({ tag, digest })); +}); +`; + +const PROBE = ` +import assert from "node:assert/strict"; +import { bundle } from "@base44/functions-compiler"; + +const result = await bundle({ + entry: "main.ts", + files: { "main.ts": ${JSON.stringify(PROBE_FUNCTION)} }, +}); +assert.equal(result.ok, true, "compile failed: " + JSON.stringify(result.errors ?? [])); +assert.equal(result.main_module, "_bundled.mjs"); +// The Deno shim and the resolved npm dependency both have to be inlined; a +// missing asset would otherwise surface only at runtime in workerd. +assert.match(result.module, /globalThis\\.Deno/, "deno shim missing from output"); +assert.match(result.module, /slugify/i, "npm dependency missing from output"); +console.log("compiled " + result.module.length + " bytes on node " + process.version); +`; + +const work = await mkdtemp(path.join(tmpdir(), "b44-compiler-pack-")); +try { + const packed = run("npm", ["pack", "--silent", "--pack-destination", work], packageRoot).trim(); + const tarball = path.join(work, packed.split("\n").at(-1)!); + + const consumer = path.join(work, "consumer"); + await writeFile( + path.join(work, ".npmrc"), + "registry=https://registry.npmjs.org/\n@jsr:registry=https://npm.jsr.io\n", + ); + run("mkdir", ["-p", consumer], work); + await writeFile( + path.join(consumer, "package.json"), + JSON.stringify({ name: "compiler-package-probe", private: true, type: "module" }), + ); + await writeFile(path.join(consumer, ".npmrc"), "registry=https://registry.npmjs.org/\n@jsr:registry=https://npm.jsr.io\n"); + run("npm", ["install", "--silent", "--no-audit", "--no-fund", tarball], consumer); + + await writeFile(path.join(consumer, "probe.mjs"), PROBE); + process.stdout.write(run(nodeBin, ["probe.mjs"], consumer)); + console.log(`packaged compiler works from ${tarball}`); +} finally { + await rm(work, { recursive: true, force: true }); +} diff --git a/packages/functions-compiler/src/actor-compat.ts b/packages/functions-compiler/src/actor-compat.ts new file mode 100644 index 000000000..01444d731 --- /dev/null +++ b/packages/functions-compiler/src/actor-compat.ts @@ -0,0 +1,334 @@ +/** + * The FOLDER is the actor's identity: an entry at base44/actors//entry.ts + * IS an actor named — no source inspection. The generated DO wrapper + * re-exports the entry's DEFAULT export under the folder name, so the user's + * class name is cosmetic (a missing default export fails the esbuild compile). + * A generated prelude installs the shared request-scoped runtime before the + * user module is evaluated. + */ + +import { RUNTIME_CONTEXT_SPECIFIER } from "./esbuild/runtime-context-virtual.js"; +import { + assertNoReservedFilenames, + CONSOLE_PATCH, + SHIM_FILENAME, + workerRuntimeFiles, +} from "./worker-entry.js"; + +const ACTOR_ENTRY_FILENAME = "__base44_actor_entry.mjs"; +const ACTOR_PRELUDE_FILENAME = "__base44_actor_prelude.mjs"; +const ACTOR_AUTH_FILENAME = "__base44_actor_auth.mjs"; + +const ACTORS_PREFIX = "base44/actors/"; +const ENTRY_SUFFIX = "/entry.ts"; + +/** The actor (folder) name for a canonical entry path, else null. */ +function actorNameFromEntry(entry: string): string | null { + if (!entry.startsWith(ACTORS_PREFIX) || !entry.endsWith(ENTRY_SUFFIX)) + return null; + const name = entry.slice(ACTORS_PREFIX.length, -ENTRY_SUFFIX.length); + return name && !name.includes("/") ? name : null; +} + +export interface ActorCompat { + /** New entry filename (the generated DO wrapper). */ + entry: string; + /** Files dict with the generated DO wrapper added (user source unchanged). */ + files: Record; + /** PascalCase class name, e.g. "GameRoom". */ + handlerName: string; + /** Exported DO class name, same as handlerName. */ + doClassName: string; +} + +/** + * If the entry is a canonical actor path (the folder is the identity), return + * a bundle input with the partyserver DO entry wrapper generated. Returns + * `null` for normal (non-realtime) functions. + */ +export function applyActorCompat( + entry: string, + files: Record, +): ActorCompat | null { + const actorName = actorNameFromEntry(entry); + // Key presence, not truthiness: an EMPTY canonical entry is still an actor — + // falling through would deploy a plain HTTP Worker under the actor identity. + // The wrapper's default-export import fails the bundle loudly instead. + if (!actorName || files[entry] === undefined) return null; + + // Actor bundles bypass prepareFunction(), so enforce the same complete + // generated-filename reservation before adding the wrapper and runtime shim. + assertNoReservedFilenames(files); + if (ACTOR_PRELUDE_FILENAME in files) { + throw new Error( + `Reserved filename "${ACTOR_PRELUDE_FILENAME}" in actor bundle — ` + + "it is generated by the bundler. Rename the file.", + ); + } + if (ACTOR_AUTH_FILENAME in files) { + throw new Error( + `Reserved filename "${ACTOR_AUTH_FILENAME}" in actor bundle — ` + + "it is generated by the bundler. Rename the file.", + ); + } + + const handlerName = actorName; + const doClassName = handlerName; + const prelude = buildPrelude(); + const entryWrapper = buildEntryWrapper(doClassName, entry); + + return { + entry: ACTOR_ENTRY_FILENAME, + files: { + ...files, + ...workerRuntimeFiles(), + [ACTOR_AUTH_FILENAME]: buildActorAuth(), + [ACTOR_PRELUDE_FILENAME]: prelude, + [ACTOR_ENTRY_FILENAME]: entryWrapper, + }, + handlerName, + doClassName, + }; +} + +function buildActorAuth(): string { + return `// Auto-generated by the base44 bundler. Do not edit. +const TOKEN_HEADER = "X-Base44-Actor-Token"; +const IDENTITY_HEADER = "X-Base44-Actor-Identity"; +const CLOCK_SKEW_SECONDS = 30; +const TOKEN_TTL_SECONDS = 300; + +const decodeSegment = (segment) => { + const normalized = segment.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4); + const binary = atob(padded); + return new Uint8Array([...binary].map((character) => character.charCodeAt(0))); +}; + +const decodeJsonSegment = (segment) => + JSON.parse(new TextDecoder().decode(decodeSegment(segment))); + +const unauthorized = () => ({ + ok: false, + response: new Response("Unauthorized", { status: 401 }), +}); + +const validPrincipal = (principal) => { + if (principal === null || typeof principal !== "object" || Array.isArray(principal)) return false; + if (principal.type === "authenticated") { + return typeof principal.userId === "string" && principal.userId.length > 0 && principal.userId.length <= 128; + } + if (principal.type === "anonymous") { + return typeof principal.anonymousId === "string" && principal.anonymousId.length > 0 && principal.anonymousId.length <= 128; + } + return false; +}; + +export async function verifyActorConnection(request, env, actorName, room) { + const publicKey = env.BASE44_ACTOR_PUBLIC_KEY; + const scriptId = env.BASE44_ACTOR_SCRIPT_ID; + const token = request.headers.get(TOKEN_HEADER) ?? new URL(request.url).searchParams.get("token"); + if ( + typeof publicKey !== "string" || !publicKey || + typeof scriptId !== "string" || !scriptId || + typeof token !== "string" || token.length > 4096 + ) return unauthorized(); + + const parts = token.split("."); + if (parts.length !== 3) return unauthorized(); + try { + const header = decodeJsonSegment(parts[0]); + const claims = decodeJsonSegment(parts[1]); + if (header?.alg !== "EdDSA" || (header.typ !== undefined && header.typ !== "JWT")) { + return unauthorized(); + } + const key = await crypto.subtle.importKey( + "raw", + decodeSegment(publicKey), + { name: "Ed25519" }, + false, + ["verify"], + ); + const validSignature = await crypto.subtle.verify( + { name: "Ed25519" }, + key, + decodeSegment(parts[2]), + new TextEncoder().encode(parts[0] + "." + parts[1]), + ); + if (!validSignature) return unauthorized(); + + const now = Math.floor(Date.now() / 1000); + const times = [claims.iat, claims.nbf, claims.exp]; + if (!times.every(Number.isInteger)) return unauthorized(); + if ( + claims.iss !== "base44" || + claims.aud !== "base44-actor-connect" || + claims.purpose !== "actor-connect" || + claims.v !== 1 || + typeof claims.jti !== "string" || !claims.jti || claims.jti.length > 64 || + claims.iat > now + CLOCK_SKEW_SECONDS || + claims.nbf > now + CLOCK_SKEW_SECONDS || + claims.exp < now - CLOCK_SKEW_SECONDS || + claims.exp <= claims.iat || claims.exp - claims.iat > TOKEN_TTL_SECONDS || + claims.app_id !== env.BASE44_APP_ID || + claims.actor_name !== actorName || + claims.actor_script_id !== scriptId || + claims.room !== room || + claims.connection_id !== new URL(request.url).searchParams.get("_pk") || + claims.runtime_mode !== (env.BASE44_FUNCTIONS_VERSION === "prod" ? "prod" : "preview") || + !validPrincipal(claims.principal) + ) return unauthorized(); + + return { + ok: true, + identity: Object.freeze({ ...claims.principal }), + runtimeMode: claims.runtime_mode, + }; + } catch { + return unauthorized(); + } +} + +export function authorizedActorRequest(request, identity, actorName, room, runtimeMode) { + const url = new URL(request.url); + url.searchParams.delete("token"); + url.pathname = "/parties/" + encodeURIComponent(actorName) + "/" + encodeURIComponent(room); + const headers = new Headers(request.headers); + headers.delete(TOKEN_HEADER); + headers.set(IDENTITY_HEADER, JSON.stringify(identity)); + headers.set("base44-functions-version", runtimeMode === "prod" ? "prod" : "preview"); + return new Request(new Request(url.toString(), request), { headers }); +} +`; +} + +function buildPrelude(): string { + return `// Auto-generated by the base44 bundler. Do not edit. +import { currentWorkerRuntimeContext as _b44Context } from ${JSON.stringify(RUNTIME_CONTEXT_SPECIFIER)}; +import { installStaticEgressFetch } from "./${SHIM_FILENAME}"; + +${CONSOLE_PATCH} +installStaticEgressFetch(); +`; +} + +function buildEntryWrapper(doClassName: string, userEntry: string): string { + const userImport = "./" + userEntry.replace(/^\.\//, ""); + // ponytail: skip routePartykitRequest (uses Object.entries(env) which doesn't enumerate + // DO namespace bindings in WfP dispatch context). Access env["ChatRoom"] directly instead. + return `// Auto-generated by the base44 bundler. Do not edit. +// The prelude is intentionally the first dependency: it installs the fetch +// shim before the user module evaluates while this static default import keeps +// missing-default actors as bundle-time errors. +import "./${ACTOR_PRELUDE_FILENAME}"; +import Base44UserActor from ${JSON.stringify(userImport)}; +import { runWithWorkerEnvironment as _b44Run } from ${JSON.stringify(RUNTIME_CONTEXT_SPECIFIER)}; +import { authorizedActorRequest as _b44AuthorizedActorRequest, verifyActorConnection as _b44VerifyActorConnection } from "./${ACTOR_AUTH_FILENAME}"; + +const _b44ActorState = new WeakMap(); +const _b44RunWithActorEnvironment = (ctx, env, runtimeEnv, callback) => _b44Run({ + env: runtimeEnv, + secrets: env, + workerEnv: env, + waitUntil: (promise) => ctx.waitUntil(promise), +}, callback); +const _b44ConstructActor = (ctx, env, newTarget) => _b44RunWithActorEnvironment( + ctx, + env, + "prod", + () => { + // A derived constructor may return an explicitly constructed instance. + // This keeps the user's constructor and class-field initializers inside the + // request-scoped Worker environment without changing the exported subclass. + const actor = Reflect.construct(Base44UserActor, [ctx, env], newTarget); + _b44ActorState.set(actor, { ctx, env, runtimeEnv: null }); + return actor; + }, +); +const _b44RunActor = (actor, request, callback) => { + const state = _b44ActorState.get(actor); + const runtimeEnv = request + ? ((request.headers.get("base44-functions-version") ?? "") === "prod" ? "prod" : "preview") + : (state.runtimeEnv ?? "prod"); + state.runtimeEnv = runtimeEnv; + return _b44RunWithActorEnvironment(state.ctx, state.env, runtimeEnv, callback); +}; + +export class ${doClassName} extends Base44UserActor { + // Read by the shim's service-token exchange: the assertion's actor_name claim + // selects the verification key on the platform side. A static (not .name) so + // minification can't change it. + static _b44ActorName = ${JSON.stringify(doClassName)}; + + constructor(ctx, env) { + return _b44ConstructActor(ctx, env, new.target); + } + + fetch(request) { + return _b44RunActor(this, request, () => super.fetch(request)); + } + + onConnect(...args) { + return _b44RunActor(this, null, () => super.onConnect(...args)); + } + + onMessage(...args) { + return _b44RunActor(this, null, () => super.onMessage(...args)); + } + + onClose(...args) { + return _b44RunActor(this, null, () => super.onClose(...args)); + } + + onAlarm(...args) { + return _b44RunActor(this, null, () => super.onAlarm(...args)); + } + + onStart(...args) { + return _b44RunActor(this, null, () => super.onStart(...args)); + } + + onError(...args) { + return _b44RunActor(this, null, () => super.onError(...args)); + } +} +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + const parts = url.pathname.split("/"); + const direct = parts[1] === "rooms" && parts.length === 3; + const legacy = parts[1] === "parties" && parts.length === 4; + if (direct || legacy) { + // Decode the room: the token was verified against the decoded instance id, + // so an escaped room must map to the same Durable Object. + const namespace = direct ? "${doClassName}" : parts[2]; + const encodedRoom = direct ? parts[2] : parts[3]; + let room; + try { room = decodeURIComponent(encodedRoom); } catch { return new Response("Invalid room", { status: 400 }); } + let actorRequest = request; + if (direct || typeof env.BASE44_ACTOR_PUBLIC_KEY === "string") { + const verification = await _b44VerifyActorConnection(request, env, "${doClassName}", room); + if (!verification.ok) return verification.response; + actorRequest = _b44AuthorizedActorRequest( + request, + verification.identity, + "${doClassName}", + room, + verification.runtimeMode, + ); + } + const doNs = env["${doClassName}"]; + if (doNs && typeof doNs.idFromName === "function") { + const stub = doNs.get(doNs.idFromName(room)); + const req = new Request(actorRequest); + req.headers.set("x-partykit-room", room); + req.headers.set("x-partykit-namespace", namespace); + return stub.fetch(req); + } + return new Response("DO binding not found", { status: 503 }); + } + return new Response("Not found", { status: 404 }); + }, +}; +`; +} diff --git a/packages/functions-compiler/src/bundler.ts b/packages/functions-compiler/src/bundler.ts new file mode 100644 index 000000000..a40ed1528 --- /dev/null +++ b/packages/functions-compiler/src/bundler.ts @@ -0,0 +1,458 @@ +import type { BundleAppRequest, BundleRequest } from "./contracts.js"; +import { bundleToModule, type NodeModulesMode } from "./deno-bundle.js"; +import { DenoCompatError, type BundleErrorItem } from "./errors.js"; +import { applyActorCompat, type ActorCompat } from "./actor-compat.js"; +import { setSpanTags, withSpan } from "./tracing.js"; +import { + type AppFunctionEntry, + type PreparedWorker, + prepareApp, + prepareFunction, +} from "./worker-entry.js"; + +export type { BundleErrorItem }; + +// `deno_compat` = the function couldn't even be assembled (reserved filenames); +// `esbuild` = resolution/compile/load failure from the Deno resolver or esbuild. +export type BundleErrorStage = "deno_compat" | "esbuild"; + +// Response for /v1/bundle (single function). +// handler_name / do_class_name are set only when the entry extends Actor. +export type BundleResponse = + | { + ok: true; + module: string; + main_module: string; + warnings: string[]; + handler_name?: string; + do_class_name?: string; + } + | { + ok: false; + stage: BundleErrorStage; + errors: BundleErrorItem[]; + }; + +export type AppFunctionStatus = + | { name: string; ok: true } + | { name: string; ok: false; errors: BundleErrorItem[] }; + +// Response for /v1/bundle-app. `module` carries the assembled Worker (only the +// functions that compiled); `functions` reports per-function status so the +// backend deploys the good ones and attributes the failures. +export type BundleAppResponse = + | { + ok: true; + module: string; + main_module: string; + functions: AppFunctionStatus[]; + } + | { + ok: false; + module: null; + functions: AppFunctionStatus[]; + }; + +// The single-module output is uploaded to Workers-for-Platforms under this +// name. Keep stable: the Python `BundlerClient`'s caller persists this in +// the script metadata's `main_module`. +const NORMALIZED_MAIN_MODULE = "_bundled.mjs"; + +// Per-function compiles run concurrently; each materializes its own temp dir +// and esbuild build, so peak memory scales with how many run at once — bound it. +const MAX_PARALLEL_COMPILES = 4; + +export async function bundle(req: BundleRequest): Promise { + // Check for Actor before the Deno-shim path. + let actor: ActorCompat | null; + try { + actor = applyActorCompat(req.entry, req.files); + } catch (e) { + return denoCompatErrorOrThrow(e); + } + if (actor) { + if (req.runtimeSecrets) { + // Actors are binding-mode by design: their code runs in a Durable Object + // that reads secrets from the DO env, and actor connect dials the + // dispatcher without a handshake — an activation wrapper would leave them + // with no secrets at all. The backend already forces binding mode for + // actors, so this combination is a caller bug. Fail loud instead of + // returning a bundle that silently ignored the flag. + return denoCompatErrorOrThrow( + new DenoCompatError( + "runtimeSecrets is not supported for Actor entries: an Actor reads secrets " + + "from its Durable Object env, so it must be deployed in binding mode.", + ), + ); + } + const outcome = await installAndCompile({ + entry: actor.entry, + files: actor.files, + }); + if (outcome.ok) { + return { + ok: true, + module: outcome.module, + main_module: NORMALIZED_MAIN_MODULE, + warnings: outcome.warnings, + handler_name: actor.handlerName, + do_class_name: actor.doClassName, + }; + } + return { ok: false, stage: outcome.stage, errors: outcome.errors }; + } + + let prepared: PreparedWorker; + try { + prepared = await prepareFunction( + req.entry, + req.files, + req.postResponseTelemetry, + req.runtimeSecrets, + ); + } catch (e) { + return denoCompatErrorOrThrow(e); + } + const outcome = await installAndCompile(prepared); + if (outcome.ok) { + return { + ok: true, + module: outcome.module, + main_module: NORMALIZED_MAIN_MODULE, + warnings: outcome.warnings, + }; + } + return { ok: false, stage: outcome.stage, errors: outcome.errors }; +} + +// One combined build resolves npm deps once and dedupes them across functions. +// esbuild fails the whole build on any unresolved import, so on failure we map +// each error to the function it came from, drop those, and rebuild the rest. +export async function bundleApp( + req: BundleAppRequest, +): Promise { + const telemetry = req.postResponseTelemetry ?? false; + const runtimeSecrets = req.runtimeSecrets ?? false; + const entries: AppFunctionEntry[] = req.functions.map((fn, index) => ({ + index, + fn, + })); + + let combined: CombinedOutcome; + try { + combined = await compileApp(entries, telemetry, runtimeSecrets); + } catch (e) { + if (e instanceof DenoCompatError) return bundleAppPerFunction(entries, telemetry, runtimeSecrets); + throw e; + } + if (combined.ok) { + return appResponse(combined.module, allOk(entries)); + } + + const { byIndex, unattributable } = classifyAppErrors(combined.errors); + // An error we can't pin to one function (deep in a shared dep, or no location) + // means the combined build can't tell us what to drop — isolate per function. + if (unattributable.length > 0) { + return bundleAppPerFunction(entries, telemetry, runtimeSecrets); + } + + const functions: AppFunctionStatus[] = entries.map((e) => + byIndex.has(e.index) + ? { name: e.fn.name, ok: false, errors: byIndex.get(e.index)! } + : { name: e.fn.name, ok: true }, + ); + const survivors = entries.filter((e) => !byIndex.has(e.index)); + if (survivors.length === 0) { + return { ok: false, module: null, functions }; + } + + let rebuilt: CombinedOutcome; + try { + rebuilt = await compileApp(survivors, telemetry, runtimeSecrets); + } catch (e) { + if (e instanceof DenoCompatError) return bundleAppPerFunction(entries, telemetry, runtimeSecrets); + throw e; + } + if (rebuilt.ok) return appResponse(rebuilt.module, functions); + // Survivors still don't build — fall back rather than fail the whole app. + return bundleAppPerFunction(entries, telemetry, runtimeSecrets); +} + +type CombinedOutcome = + | { ok: true; module: string } + | { ok: false; errors: BundleErrorItem[] }; + +async function compileApp( + entries: AppFunctionEntry[], + telemetry = false, + runtimeSecrets = false, +): Promise { + const outcome = await installAndCompile(prepareApp(entries, telemetry, runtimeSecrets)); + return outcome.ok + ? { ok: true, module: outcome.module } + : { ok: false, errors: outcome.errors }; +} + +function allOk(entries: AppFunctionEntry[]): AppFunctionStatus[] { + return entries.map((e) => ({ name: e.fn.name, ok: true })); +} + +function appResponse( + module: string, + functions: AppFunctionStatus[], +): BundleAppResponse { + return { ok: true, module, main_module: NORMALIZED_MAIN_MODULE, functions }; +} + +export interface AppErrorClassification { + byIndex: Map; + unattributable: BundleErrorItem[]; +} + +/** Map each combined-build error to the function whose `fn_/` subtree it + * came from, stripping that prefix so the user sees their own path. Errors with + * no `fn_/` prefix (a shared dep deep in node_modules, or no location) + * can't be pinned to one function. */ +export function classifyAppErrors( + errors: BundleErrorItem[], +): AppErrorClassification { + const byIndex = new Map(); + const unattributable: BundleErrorItem[] = []; + for (const err of errors) { + const match = err.file?.match(/^fn_(\d+)\/(.*)$/); + if (!match) { + unattributable.push(err); + continue; + } + const index = Number(match[1]); + const list = byIndex.get(index) ?? []; + list.push({ ...err, file: match[2] }); + byIndex.set(index, list); + } + return { byIndex, unattributable }; +} + +/** Fallback for the rare error the combined build can't attribute to one + * function: compile each function alone — where every error is unambiguously + * its own — to learn which build, then build the survivors together. Reuses + * the same combined-build path, so there's no second bundling engine. */ +async function bundleAppPerFunction( + entries: AppFunctionEntry[], + telemetry = false, + runtimeSecrets = false, +): Promise { + const probes = await mapWithConcurrency( + entries, + MAX_PARALLEL_COMPILES, + (entry) => probeFunction(entry, runtimeSecrets), + ); + + const functions: AppFunctionStatus[] = probes.map((p) => + p.ok + ? { name: p.entry.fn.name, ok: true } + : { name: p.entry.fn.name, ok: false, errors: p.errors }, + ); + const survivors = probes.filter((p) => p.ok).map((p) => p.entry); + if (survivors.length === 0) { + return { ok: false, module: null, functions }; + } + + const combined = await compileApp(survivors, telemetry, runtimeSecrets); + if (combined.ok) return appResponse(combined.module, functions); + // Each survivor built alone but not together: the combined graph resolved a + // shared npm package onto a version some importer can't use. Deterministic + // user-dep breakage — a 500 here gets retried by the platform and surfaced + // as an infrastructure error, hiding the diagnostics the agent needs. + return assembleWithoutConflicting(survivors, functions, combined.errors, telemetry, runtimeSecrets); +} + +/** Assembly failed on errors originating inside shared npm deps. Blame the + * functions whose source imports the package (and major) the errors come + * from, rebuild the rest, and report the blamed ones with the real + * diagnostics. When nothing can be blamed — or the rebuild still fails — + * every remaining function reports the diagnostics instead of a crash. */ +async function assembleWithoutConflicting( + survivors: AppFunctionEntry[], + functions: AppFunctionStatus[], + errors: BundleErrorItem[], + telemetry: boolean, + runtimeSecrets = false, +): Promise { + const failWith = (entries: AppFunctionEntry[], items: BundleErrorItem[]) => { + for (const e of entries) { + functions[e.index] = { name: e.fn.name, ok: false, errors: items }; + } + }; + const conflictErrors: BundleErrorItem[] = [ + { + message: + "npm dependency version conflict: this function's dependencies resolve " + + "differently when bundled with the app's other functions. Align the " + + "conflicting package versions across functions.", + }, + ...errors, + ]; + + const blamed = survivors.filter((e) => importsConflictingPackage(e, errors)); + const rest = survivors.filter((e) => !blamed.includes(e)); + if (blamed.length > 0 && rest.length > 0) { + const rebuilt = await compileApp(rest, telemetry, runtimeSecrets); + if (rebuilt.ok) { + failWith(blamed, conflictErrors); + return appResponse(rebuilt.module, functions); + } + // Removing the blamed functions exposed a further failure — no module was + // produced, so every survivor must report it (ok:false means all failed). + failWith(blamed, conflictErrors); + failWith(rest, [conflictErrors[0], ...rebuilt.errors]); + return { ok: false, module: null, functions }; + } + failWith(blamed.length > 0 ? blamed : survivors, conflictErrors); + return { ok: false, module: null, functions }; +} + +// `imported from '…registry.npmjs.org///…'` — the package whose +// own imports the combined graph broke, i.e. the one to trace back to a function. +const IMPORTED_FROM_PACKAGE = + /imported from '[^']*registry\.npmjs\.org\/((?:@[^/]+\/)?[^/@]+)\/(\d+)/g; + +/** Does this function's source import one of the packages the assembly errors + * originate from, at (or possibly at) the failing major? A specifier pinned to + * a different major is exonerated — its copy of the package is the one that + * works; an unpinned specifier stays blamed. */ +export function importsConflictingPackage( + entry: AppFunctionEntry, + errors: BundleErrorItem[], +): boolean { + const culprits = new Map>(); + for (const err of errors) { + for (const m of err.message.matchAll(IMPORTED_FROM_PACKAGE)) { + (culprits.get(m[1]) ?? culprits.set(m[1], new Set()).get(m[1])!).add(m[2]); + } + } + const sources = Object.values(entry.fn.files); + for (const [pkg, majors] of culprits) { + const spec = new RegExp( + `['"](?:npm:)?(${escapeRegExp(pkg)})(@[^'"]*)?['"/]`, + ); + for (const source of sources) { + const m = source.match(spec); + if (!m) continue; + const pinnedMajor = m[2]?.match(/\d+/)?.[0]; + if (!pinnedMajor || majors.has(pinnedMajor)) return true; + } + } + return false; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +type FunctionProbe = + | { ok: true; entry: AppFunctionEntry } + | { ok: false; entry: AppFunctionEntry; errors: BundleErrorItem[] }; + +/** Compile one function alone. With only its files in the build, every error is + * its own — the `fn_/` ones stripped, the rest (deep deps) kept as-is. + * Compiles in the real `runtimeSecrets` mode so a mode-dependent failure (a + * user file named the reserved activation filename) is attributed to its + * function instead of leaking out of the final assembly as a 500. */ +async function probeFunction( + entry: AppFunctionEntry, + runtimeSecrets = false, +): Promise { + let outcome: CombinedOutcome; + try { + outcome = await compileApp([entry], false, runtimeSecrets); + } catch (e) { + if (e instanceof DenoCompatError) { + return { ok: false, entry, errors: [denoCompatErrorItem(e)] }; + } + throw e; + } + if (outcome.ok) return { ok: true, entry }; + const { byIndex, unattributable } = classifyAppErrors(outcome.errors); + return { + ok: false, + entry, + errors: [...(byIndex.get(entry.index) ?? []), ...unattributable], + }; +} + +/** Map with a bounded number of concurrent workers; results keep input order. */ +async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let cursor = 0; + const worker = async (): Promise => { + while (cursor < items.length) { + const index = cursor++; + results[index] = await fn(items[index]); + } + }; + const size = Math.min(limit, items.length); + await Promise.all(Array.from({ length: size }, () => worker())); + return results; +} + +type CompileOutcome = + | { ok: true; module: string; warnings: string[] } + | { ok: false; stage: BundleErrorStage; errors: BundleErrorItem[] }; + +// The resolution failures that only happen in "none" mode and that "auto" fixes. +const NODE_MODULES_FALLBACK = + /ERR_MODULE_NOT_FOUND|Could not find referrer npm package/; + +/** Compile the prepared tree to a single module via the Deno resolver engine. + * Shared by the single-function path and the combined app build. */ +async function installAndCompile( + prepared: PreparedWorker, +): Promise { + return withSpan("base44.bundler.compile", async () => { + // The problem this solves: we resolve npm from the shared cache without a + // per-build node_modules ("none") because it's fast. But Deno sometimes splits + // a package into a deduplicated "phantom" copy — e.g. two resend versions both + // pull react-dom, so it creates react-dom@18.2.0 AND react-dom@18.2.0_1 — and + // that "_1" copy has no folder on disk, so "none" can't load its files and the + // whole app fails to bundle. Retry just that failure with "auto", which writes + // a real node_modules where the phantom copy becomes a real directory. + let mode: NodeModulesMode = "none"; + let result = await bundleToModule(prepared, mode); + if ( + !result.ok && + result.errors.some((e) => NODE_MODULES_FALLBACK.test(e.message)) + ) { + mode = "auto"; + result = await bundleToModule(prepared, mode); + } + // node_modules_mode:auto marks the fallback firing; with outcome it shows how + // often the edge case hits and whether "auto" then rescued it. + await setSpanTags({ + node_modules_mode: mode, + outcome: result.ok ? "ok" : "failed", + }); + if (result.ok) { + return { ok: true, module: result.module, warnings: result.warnings }; + } + return { ok: false, stage: "esbuild", errors: result.errors }; + }); +} + +function denoCompatErrorOrThrow(e: unknown): BundleResponse { + if (e instanceof DenoCompatError) { + return { + ok: false, + stage: "deno_compat", + errors: [denoCompatErrorItem(e)], + }; + } + throw e; +} + +function denoCompatErrorItem(e: DenoCompatError): BundleErrorItem { + return { message: e.message, file: e.file }; +} diff --git a/packages/functions-compiler/src/cloudflare-workers.d.ts b/packages/functions-compiler/src/cloudflare-workers.d.ts new file mode 100644 index 000000000..93e769daf --- /dev/null +++ b/packages/functions-compiler/src/cloudflare-workers.d.ts @@ -0,0 +1,19 @@ +declare module "cloudflare:workers" { + export const env: Record; +} + +// Internal virtual specifier: the activation shim imports the manifest store +// through this so build-shim leaves it external and the final app compile +// resolves it (deduped with manifest.ts's relative import). Shapes must match +// src/private-data-sources/runtime-manifest-store.ts. +declare module "base44:private-data-sources/runtime-manifest-store" { + export function setRuntimeManifest(raw: string | undefined): void; + export function getRuntimeManifest(): string | undefined; +} +declare module "base44:internal/runtime-context" { + export { + currentWorkerRuntimeContext, + runWithWorkerEnvironment, + workerEnvironment, + } from "./runtime-context"; +} diff --git a/packages/functions-compiler/src/contracts.ts b/packages/functions-compiler/src/contracts.ts new file mode 100644 index 000000000..18778fb99 --- /dev/null +++ b/packages/functions-compiler/src/contracts.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +const fileMapSchema = z + .record(z.string().min(1), z.string()) + .refine((files) => Object.keys(files).length > 0, { + message: "must contain at least one file", + }); + +export const bundleRequestSchema = z + .object({ + entry: z.string().min(1), + files: fileMapSchema, + // Backend-evaluated post-response-telemetry flag: bake the + // detached-work telemetry prelude into the generated Worker entry. + postResponseTelemetry: z.boolean().optional(), + // Backend-evaluated runtime-secrets flag: bake the encrypted activation + // handshake into the generated Worker entry (secrets arrive per isolate + // instead of as secret_text bindings). + runtimeSecrets: z.boolean().optional(), + }) + .refine((body) => body.entry in body.files, { + message: "`entry` must be a key in `files`", + }); + +export const appFunctionSchema = z + .object({ + name: z.string().min(1), + entry: z.string().min(1), + files: fileMapSchema, + }) + .refine((fn) => fn.entry in fn.files, { + message: "`entry` must be a key in `files`", + }); + +export const bundleAppRequestSchema = z + .object({ + functions: z.array(appFunctionSchema).min(1), + postResponseTelemetry: z.boolean().optional(), + runtimeSecrets: z.boolean().optional(), + }) + .refine( + (body) => + new Set(body.functions.map((fn) => fn.name)).size === + body.functions.length, + { message: "function names must be unique" }, + ); + +export type BundleRequest = z.infer; +export type AppFunctionInput = z.infer; +export type BundleAppRequest = z.infer; diff --git a/packages/functions-compiler/src/deno-bundle.ts b/packages/functions-compiler/src/deno-bundle.ts new file mode 100644 index 000000000..c8cb399ab --- /dev/null +++ b/packages/functions-compiler/src/deno-bundle.ts @@ -0,0 +1,180 @@ +/** + * Bundle engine. Compiles a prepared worker tree (user files + injected shim + + * generated entry) into a single workerd ESM module using Deno's own resolver + * via the `deno-resolver` adapter over `@deno/loader`: `npm:`, `jsr:`, and + * `https:` specifiers resolve exactly as Deno resolves them (multiple versions, + * nested deps, correct exports). + * + * The loader reads source from a real filesystem, so the tree is materialized + * into a per-request temp dir, compiled, and removed. `node:` builtins stay + * external (workerd's `nodejs_compat` provides them) and `cloudflare:*` stays + * external (provided by the runtime). + */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { build, type BuildFailure } from "esbuild"; + +import type { BundleErrorItem } from "./errors.js"; +import { denoResolverPlugin } from "./esbuild/deno-resolver.js"; +import { nodeBuiltinRequirePlugin } from "./esbuild/node-builtin-require.js"; +import { privateDataSourcesVirtualPlugin } from "./esbuild/private-data-sources-virtual.js"; +import { runtimeContextVirtualPlugin } from "./esbuild/runtime-context-virtual.js"; +import { runtimeVirtualPlugin } from "./esbuild/runtime-virtual.js"; +import { USER_NAMESPACE, userFilesPlugin } from "./esbuild/user-files.js"; +import type { PreparedWorker } from "./worker-entry.js"; + +// Whether the build writes a node_modules folder. See installAndCompile for why +// "none" is the fast default and "auto" the fallback. +export type NodeModulesMode = "none" | "auto"; + +type EngineResult = + | { ok: true; module: string; warnings: string[] } + | { ok: false; errors: BundleErrorItem[] }; + +/** + * Compile `prepared` to a single ESM module string. Resolution/compile failures + * (user-attributable) return `{ ok: false }`; infrastructure failures (temp dir, + * missing output) throw. + */ +export async function bundleToModule( + prepared: PreparedWorker, + nodeModulesDir: NodeModulesMode = "none", +): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "b44-bundle-")); + try { + // Only deno.json (the loader's config) and the materialized node_modules + // touch disk — user source is served from memory by userFilesPlugin, so a + // malicious import can't traverse to another build's files. + await writeFile( + path.join(dir, "deno.json"), + JSON.stringify({ nodeModulesDir }), + ); + + let result; + try { + result = await build({ + entryPoints: [prepared.entry], + absWorkingDir: dir, + bundle: true, + write: false, + format: "esm", + platform: "browser", + target: "es2022", + // "node" is active because workerd runs with nodejs_compat: packages + // like unicorn-magic gate their full API behind the "node" exports + // condition and ship a stripped default entry, so without it consumers + // (npm-run-path) import names that don't exist and the bundle fails. + // Conditions are a set — when a package lists both "workerd" and + // "node", its own exports-map key order still decides which wins. + conditions: ["workerd", "worker", "browser", "module", "node"], + external: ["cloudflare:*"], + // workerd ESM lacks __dirname/__filename; define rewrites only free refs + // (npm/Emscripten glue), leaving loader-bound CJS locals intact. + define: { __dirname: '"/"', __filename: '"/index.js"' }, + minify: true, + sourcemap: false, + // Errors are surfaced structurally (return value / BuildFailure); keep + // esbuild from dumping diagnostics to the service's stderr. + logLevel: "silent", + plugins: [ + // CJS require() of a node builtin → static re-export (workerd throws + // on esbuild's lowered __require). Runs before the Deno resolver. + nodeBuiltinRequirePlugin(), + userFilesPlugin(prepared.files, prepared.entry), + privateDataSourcesVirtualPlugin(), + runtimeContextVirtualPlugin(), + runtimeVirtualPlugin(), + denoResolverPlugin({ configPath: path.join(dir, "deno.json") }), + ], + }); + } catch (e) { + const errors = buildFailureErrors(e, dir); + if (errors) return { ok: false, errors }; + throw e; + } + + const outputs = result.outputFiles ?? []; + if (outputs.length !== 1) { + // bundle:true with code-splitting off yields exactly one module; anything + // else means a plugin claimed the entry or emitted extra files — a bug. + throw new Error( + `expected single-module output, got ${outputs.length} file(s)`, + ); + } + + return { + ok: true, + module: outputs[0].text, + warnings: result.warnings.map((w) => w.text), + }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** Flatten an esbuild `BuildFailure` into compile diagnostics, rewriting the + * temp-dir-absolute file paths back to the paths the user typed. Returns null + * if `e` is not an esbuild failure (so the caller rethrows it). */ +function buildFailureErrors( + e: unknown, + dir: string, +): BundleErrorItem[] | null { + if (typeof e !== "object" || e === null || !("errors" in e)) return null; + const raw = (e as BuildFailure).errors; + if (!Array.isArray(raw)) return null; + return raw.map((m) => normalizeError(m, dir)); +} + +function normalizeError( + m: { text?: string; location?: unknown }, + dir: string, +): BundleErrorItem { + const message = m.text ?? "bundle error"; + const item: BundleErrorItem = { message }; + + // The Deno loader transpiles before esbuild sees the source, so a syntax + // error's real location lives in the message text + // (`… at file:////main.ts:LINE:COL`) while esbuild's structured + // `location` points at the importing entry. Prefer the embedded one so the + // diagnostic points at the user's file. + const embedded = message.match(/file:\/\/(\/[^\s:]+):(\d+):(\d+)/); + if (embedded) { + item.file = relativizeFile(embedded[1], dir); + item.line = Number(embedded[2]); + item.column = Number(embedded[3]); + } + + const loc = m.location as { + file?: string; + line?: number; + column?: number; + lineText?: string; + suggestion?: string; + } | null; + if (item.file === undefined && loc?.file) { + item.file = relativizeFile(loc.file, dir); + } + if (item.line === undefined && typeof loc?.line === "number") { + item.line = loc.line; + } + if (item.column === undefined && typeof loc?.column === "number") { + item.column = loc.column; + } + if (loc?.lineText) item.lineText = loc.lineText; + if (loc?.suggestion) item.suggestion = loc.suggestion; + return item; +} + +/** Normalize the file in a diagnostic to the path the user typed. User files are + * virtual keys esbuild prefixes with the namespace (`user:main.ts`); deps are + * temp-dir-absolute (`/node_modules/foo/…`). Strip either prefix. */ +function relativizeFile(file: string, dir: string): string { + if (file.startsWith(`${USER_NAMESPACE}:`)) { + return file.slice(USER_NAMESPACE.length + 1); + } + const prefix = dir.endsWith(path.sep) ? dir : dir + path.sep; + return file.startsWith(prefix) ? file.slice(prefix.length) : file; +} diff --git a/packages/functions-compiler/src/errors.ts b/packages/functions-compiler/src/errors.ts new file mode 100644 index 000000000..9f6ea90d2 --- /dev/null +++ b/packages/functions-compiler/src/errors.ts @@ -0,0 +1,21 @@ +/** A user-code problem the bundler can't translate; `file` is attached to the + * diagnostic when known. Surfaced to the caller as an `ok:false` compile error. */ +export class DenoCompatError extends Error { + readonly file?: string; + constructor(message: string, file?: string) { + super(message); + this.name = "DenoCompatError"; + this.file = file; + } +} + +/** One flattened compile diagnostic. Shape is part of the HTTP contract — the + * Python `BundlerClient` and the builder agent read these fields. */ +export interface BundleErrorItem { + message: string; + file?: string; + line?: number; + column?: number; + lineText?: string; + suggestion?: string; +} diff --git a/packages/functions-compiler/src/esbuild/deno-resolver.ts b/packages/functions-compiler/src/esbuild/deno-resolver.ts new file mode 100644 index 000000000..626387cf8 --- /dev/null +++ b/packages/functions-compiler/src/esbuild/deno-resolver.ts @@ -0,0 +1,534 @@ +/** + * esbuild resolver/loader over `@deno/loader` (Deno's own resolver crates, + * compiled to WASM). A thin in-repo adapter — the published + * `@deno/esbuild-plugin` is the same idea, but it throws the build away on + * unresolved *optional* dependencies and reports transpile errors against the + * importer. We own the glue so we can fix both: + * + * - Optional deps that aren't installed are left external (the import survives + * to runtime, where the author's try/catch handles the miss) instead of + * failing the bundle. This covers declared optional deps such as axios's + * `follow-redirects` -> `debug` and undeclared guarded requires such as + * mysql2 -> `cardinal`, matching the old resolver's leniency. + * - Transpile/syntax errors are reported with the offending file + position, + * not the importing entry. + * + * The heavy lifting (npm/jsr/https resolution, multi-version, exports maps) stays + * in `@deno/loader`. + */ + +import { readFileSync, realpathSync, statSync } from "node:fs"; +import { isBuiltin } from "node:module"; +import { homedir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { + MediaType, + RequestedModuleType, + ResolutionMode, + Workspace, + ResolveError, +} from "@deno/loader"; +import type { + Loader, + OnLoadArgs, + OnLoadResult, + OnResolveArgs, + OnResolveResult, + Plugin, +} from "esbuild"; + +import { logEvent } from "../log.js"; +import { USER_NAMESPACE } from "./user-files.js"; + +// Schemes the loader resolves to; each becomes an esbuild namespace so imports +// inside a loaded module re-enter resolution with the scheme as context. +const NAMESPACES = ["file", "http", "https", "data", "npm", "jsr"]; + +// We also resolve imports *from* in-memory user files (served by the user-files +// plugin) but never load them — that plugin owns their contents. +const RESOLVE_NAMESPACES = [...NAMESPACES, USER_NAMESPACE]; + +interface DenoResolverOptions { + /** Path to the deno.json controlling resolution (we write one per bundle with + * `nodeModulesDir: auto` so the loader auto-fetches npm deps under Node). */ + configPath?: string; +} + +export function denoResolverPlugin(options: DenoResolverOptions = {}): Plugin { + return { + name: "deno-resolver", + async setup(build) { + // Security: stop a dependency or data: module from reading host files. + // Allow file: reads only from where deps live — the global cache, or this + // build's temp node_modules in "auto" mode — and resolve symlinks first so + // a crafted link can't point out of those folders. + const baseDir = options.configPath + ? path.dirname(options.configPath) + : undefined; + const depRoots = [denoCacheRoot(), baseDir] + .filter((d): d is string => Boolean(d)) + .map(canonicalPath); + const pathUnderDeps = (absPath: string): boolean => { + const p = canonicalPath(absPath); + return depRoots.some((root) => { + const rel = path.relative(root, p); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); + }); + }; + + const workspace = new Workspace({ + platform: "browser", + nodeConditions: build.initialOptions.conditions, + configPath: options.configPath, + }); + // Free both WASM objects when the build settles. They're separate + // allocations and a missed free() only reclaims via GC, growing the + // long-lived worker's WASM heap. Loader before workspace; cast since the + // .d.ts hides the member. + type DenoLoader = Awaited>; + const dispose = (createdLoader?: DenoLoader): void => { + (createdLoader as unknown as Disposable | undefined)?.[ + Symbol.dispose + ]?.(); + (workspace as unknown as Disposable)[Symbol.dispose]?.(); + }; + + let loader: DenoLoader; + try { + // createLoader() is async and can reject; the WASM can also trap with + // "unreachable" (a Rust panic in the loader). esbuild does NOT run + // onDispose for a setup() that rejected, so on any failure here we must + // dispose the Workspace ourselves — otherwise its WASM allocation stays + // resident in the still-alive worker until GC. + loader = await workspace.createLoader(); + } catch (err) { + dispose(); + logEvent("error", "base44.bundler.workspace_disposed_on_setup_failure", { + phase: "create_loader", + wasm_trap: errMessage(err) === "unreachable", + }); + throw err; + } + build.onDispose(() => dispose(loader)); + + const externals = (build.initialOptions.external ?? []).map(toRegex); + + const onResolve = async ( + args: OnResolveArgs, + ): Promise => { + // node: builtins (and configured externals) stay external — workerd's + // nodejs_compat provides the builtins. require()-of-builtin is claimed + // earlier by node-builtin-require, so this is the import-kind path. + if ( + isBuiltin(args.path) || + externals.some((re) => re.test(args.path)) + ) { + return { path: args.path, external: true }; + } + + const mode = + args.kind === "require-call" || args.kind === "require-resolve" + ? ResolutionMode.Require + : ResolutionMode.Import; + + const importer = + args.namespace === USER_NAMESPACE && baseDir + ? path.join(baseDir, args.importer) + : args.importer; + + try { + const resolved = await loader.resolve(args.path, importer, mode); + // A non-builtin specifier that still resolves to a builtin (rare) — + // externalize it like any other node: import. + if (resolved.startsWith("node:")) { + return { path: resolved, external: true }; + } + if ( + resolved.startsWith("file:") && + !pathUnderDeps(fileURLToPath(resolved)) + ) { + return { errors: [fileOutsideCacheError(args.path)] }; + } + return toEsbuildPath(resolved); + } catch (err) { + // DELTA: an uninstalled *optional* dependency is not a build failure. + // Leave it external so the import survives to runtime (where the + // author's try/catch turns the missing module into a no-op). + if (isOptionalDependency(err)) { + return { path: args.path, external: true }; + } + + // Restore the main/module lookup the loader skips (see + // resolveEntryFromPackageJson). Before the externalize branch: a + // resolvable package must be bundled, not left as a broken require(). + const fallbackUrl = resolveEntryFromPackageJson( + err, + args.path, + mode, + pathUnderDeps, + ); + if (fallbackUrl) { + logEvent("info", "base44.bundler.package_entry_fallback", { + specifier: args.path, + }); + if ( + fallbackUrl.startsWith("file:") && + !pathUnderDeps(fileURLToPath(fallbackUrl)) + ) { + return { errors: [fileOutsideCacheError(args.path)] }; + } + return toEsbuildPath(fallbackUrl); + } + + // Some published packages guard undeclared optional deps with + // try/catch instead of listing them in package.json. The loader + // cannot identify those as optional, so preserve the old resolver's + // runtime fallback only for catchable references originating inside + // deps. `dynamic-import` is the ESM spelling of the same guarded + // pattern (`await import("x")`, often tagged webpackIgnore/ + // @vite-ignore — pragmas esbuild does not honor). Static imports must + // still fail the build. + // + // Two shapes of "package not found": a typed ResolveError with + // ERR_MODULE_NOT_FOUND, or a bare Error reading "Could not find + // package X from referrer Y" (what @mastra/core -> @ast-grep/napi + // hits — neither the type nor the code). Match exactly those; other + // ResolveError codes (exports-map, version conflicts) must keep + // failing the build with their attributed diagnostic. + if ( + (isMissingDependency(err) || + MISSING_PACKAGE.test(errMessage(err))) && + (args.kind === "require-call" || + args.kind === "require-resolve" || + args.kind === "dynamic-import") && + isBareSpecifier(args.path) && + args.namespace !== USER_NAMESPACE && + pathUnderDeps(importer) + ) { + logEvent( + "info", + "base44.bundler.undeclared_optional_externalized", + { + specifier: args.path, + referrer_package: packageFromImporter(importer), + }, + ); + return { path: args.path, external: true }; + } + + // These mean "genuinely not a dependency" — let esbuild report a + // normal "could not resolve" error with the importer location. + if (NOT_A_DEP.test(errMessage(err))) { + return null; + } + + throw err; + } + }; + + build.onResolve({ filter: /.*/ }, onResolve); + for (const namespace of RESOLVE_NAMESPACES) { + build.onResolve({ filter: /.*/, namespace }, onResolve); + } + + const onLoad = async ( + args: OnLoadArgs, + ): Promise => { + // Defense in depth if a file: path slips past onResolve. + if (args.namespace === "file" && !pathUnderDeps(args.path)) { + return { errors: [fileOutsideCacheError(args.path)] }; + } + const url = isUrlScheme(args.path) + ? args.path + : pathToFileURL(args.path).toString(); + + try { + const res = await loader.load(url, moduleType(args)); + if (res.kind === "external") { + return undefined; + } + + return { contents: res.code, loader: mediaToLoader(res.mediaType) }; + } catch (err) { + // DELTA: the loader transpiles here, so syntax errors surface as a + // throw. Report a located error against the file being loaded rather + // than letting esbuild attribute it to the importing entry. + // + // A WASM "unreachable" trap (a Rust panic in @deno/loader) also + // surfaces here as a throw and would otherwise be indistinguishable + // from a user syntax error — swallowed into a compile_error and never + // counted as a trap. Emit an observability signal for it (the + // onResolve path rethrows, so resolve-phase traps already surface as + // base44.bundler.crash with wasm_trap:true) while keeping the + // user-facing located-error return unchanged. + if (errMessage(err) === "unreachable") { + logEvent("error", "base44.bundler.load_wasm_trap", { + phase: "load", + }); + } + return { errors: [locatedError(err, args.path)] }; + } + }; + + for (const namespace of NAMESPACES) { + build.onLoad({ filter: /.*/, namespace }, onLoad); + } + }, + }; +} + +function toEsbuildPath(resolved: string): { + path: string; + namespace?: string; +} { + if (resolved.startsWith("file:")) { + return { path: fileURLToPath(resolved), namespace: "file" }; + } + for (const scheme of ["http", "https", "data", "npm", "jsr"]) { + if (resolved.startsWith(`${scheme}:`)) { + return { path: resolved, namespace: scheme }; + } + } + return { path: resolved }; +} + +function isUrlScheme(p: string): boolean { + return ["http:", "https:", "data:", "npm:", "jsr:"].some((s) => + p.startsWith(s), + ); +} + +function moduleType(args: OnLoadArgs): RequestedModuleType { + switch (args.with?.type) { + case "text": + return RequestedModuleType.Text; + case "bytes": + return RequestedModuleType.Bytes; + case "json": + return RequestedModuleType.Json; + default: + return args.path.endsWith(".json") + ? RequestedModuleType.Json + : RequestedModuleType.Default; + } +} + +function mediaToLoader(type: MediaType): Loader { + switch (type) { + case MediaType.Jsx: + return "jsx"; + case MediaType.Tsx: + return "tsx"; + case MediaType.TypeScript: + case MediaType.Mts: + case MediaType.Cts: + return "ts"; + case MediaType.Json: + return "json"; + case MediaType.Css: + return "css"; + case MediaType.Wasm: + return "binary"; + case MediaType.JavaScript: + case MediaType.Mjs: + case MediaType.Cjs: + return "js"; + default: + return "js"; + } +} + +// esbuild passes configured externals to plugins; match them here too. +function toRegex(external: string): RegExp { + return new RegExp( + "^" + + external.replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&").replace(/\*/g, ".*") + + "$", + ); +} + +const NOT_A_DEP = + /not a dependency and not in import map|Relative import path ".*?" not prefixed with/; + +// An npm package the loader could not locate at all. Some shapes arrive as a +// typed ResolveError; this family arrives as a bare Error carrying only text. +const MISSING_PACKAGE = /Could not find package .*? from referrer/; + +function isBareSpecifier(specifier: string): boolean { + return ( + !specifier.startsWith(".") && + !specifier.startsWith("/") && + !specifier.startsWith("#") && + !specifier.includes("\\") && + !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier) + ); +} + +function packageFromImporter(importer: string): string | undefined { + const parts = canonicalPath(importer).split(path.sep); + const nodeModulesIndex = parts.lastIndexOf("node_modules"); + const registryIndex = parts.lastIndexOf("registry.npmjs.org"); + const markerIndex = Math.max(nodeModulesIndex, registryIndex); + if (markerIndex < 0) return undefined; + const packageIndex = markerIndex + 1; + const first = parts[packageIndex]; + if (!first) return undefined; + return first.startsWith("@") && parts[packageIndex + 1] + ? `${first}/${parts[packageIndex + 1]}` + : first; +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +// Same wording as the user-files plugin so the message is consistent. +function fileOutsideCacheError(spec: string): { text: string } { + return { text: `Cannot import "${spec}": filesystem imports are not allowed` }; +} + +// Where the loader caches npm; prod sets DENO_DIR, else Deno's per-OS default. +function denoCacheRoot(): string { + if (process.env.DENO_DIR) return path.resolve(process.env.DENO_DIR); + const home = homedir(); + switch (process.platform) { + case "darwin": + return path.join(home, "Library", "Caches", "deno"); + case "win32": + return path.join( + process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), + "deno", + ); + default: + return path.join( + process.env.XDG_CACHE_HOME ?? path.join(home, ".cache"), + "deno", + ); + } +} + +// Resolve symlinks before the containment check so a link can't escape an +// allowed root; fall back to the normalized path when it doesn't exist yet. +function canonicalPath(p: string): string { + try { + return realpathSync(path.resolve(p)); + } catch { + return path.resolve(p); + } +} + +// `@deno/loader` sets `isOptionalDependency` on ResolveError when an optional +// npm dependency can't be found (ERR_MODULE_NOT_FOUND). +function isOptionalDependency(err: unknown): boolean { + return err instanceof ResolveError && err.isOptionalDependency === true; +} + +function isMissingDependency(err: unknown): boolean { + return err instanceof ResolveError && err.code === "ERR_MODULE_NOT_FOUND"; +} + +function locatedError(err: unknown, file: string) { + const text = errMessage(err); + // The loader embeds the position in the message (`… :LINE:COL`). + const m = text.match(/:(\d+):(\d+)\b/); + return { + text, + location: m ? { file, line: Number(m[1]), column: Number(m[2]) } : { file }, + }; +} + +const ENTRY_EXTENSIONS = [".js", ".mjs", ".cjs", ".json"]; +const INDEX_BASENAMES = ["index.js", "index.mjs", "index.cjs", "index.json"]; + +function isFile(p: string): boolean { + try { + return statSync(p).isFile(); + } catch { + return false; + } +} + +/** Resolve a package.json entry (a module ID) as Node does: literal path, then + * appended extensions, then directory index. Returns the file or null. */ +function resolveNodeEntry(pkgDir: string, entry: string): string | null { + const target = path.resolve(pkgDir, entry); + if (isFile(target)) return target; + for (const ext of ENTRY_EXTENSIONS) { + if (isFile(target + ext)) return target + ext; + } + for (const index of INDEX_BASENAMES) { + const candidate = path.join(target, index); + if (isFile(candidate)) return candidate; + } + return null; +} + +/** @deno/loader hardcodes IsCjsResolutionMode::ExplicitTypeCommonJs, so a + * package with no `"type"` (CommonJS by Node's default) is treated as ESM; + * lacking an `exports` map, its `main` field is skipped and it resolves to a + * non-existent `/index.js`. The Deno CLI (ImplicitTypeCommonJs) reads + * `main` instead. On that ERR_MODULE_NOT_FOUND, read package.json and return + * the real entry as a `file://` URL, or null when not this case / unrecoverable. */ +function resolveEntryFromPackageJson( + err: unknown, + specifier: string, + mode: ResolutionMode, + pathUnderDeps: (absPath: string) => boolean, +): string | null { + if (!(err instanceof ResolveError) || err.code !== "ERR_MODULE_NOT_FOUND") { + return null; + } + const msg = errMessage(err); + const m = msg.match(/Cannot find module '(file:\/\/\/.+?)'/); + if (!m) return null; + + let attempted: string; + try { + attempted = fileURLToPath(m[1]); + } catch { + return null; + } + + const base = path.basename(attempted); + if (base !== "index.js" && base !== "index.mjs") return null; + + // Only recover the loader's *synthesized* package-root probe. An explicit + // `pkg/index.js` (or `./index.js`) import that resolves to this same path was + // a deliberate request for that file — redirecting it to main/module would + // silently load a different entry, so let the missing-file error stand. + if (specifier.endsWith("/" + base)) return null; + + const pkgDir = path.dirname(attempted); + // Fail closed: never touch a package.json (or its entries) outside the cache. + if (!pathUnderDeps(pkgDir)) return null; + const pkgJsonPath = path.join(pkgDir, "package.json"); + + try { + const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); + // An `exports` map governs resolution; the loader would have used it, so a + // miss here is a genuine error — don't override the contract via legacy fields. + if (pkg.exports != null) return null; + + // Deno's ImplicitTypeCommonJs classifies these type-less packages as CJS and + // runs `main`, so prefer it for parity. A string `browser` entry wins first + // (the workerd/browser-targeted build, avoids bundling a Node entry). require() + // never falls through to `module` — an ESM/browser entry is not require-safe. + const browser = typeof pkg.browser === "string" ? pkg.browser : undefined; + const fields = + mode === ResolutionMode.Require + ? [browser, pkg.main] + : [browser, pkg.main, pkg.module]; + for (const entry of fields) { + if (typeof entry !== "string" || !entry) continue; + const resolved = resolveNodeEntry(pkgDir, entry); + if (resolved && pathUnderDeps(resolved)) { + return pathToFileURL(resolved).toString(); + } + } + return null; + } catch { + return null; + } +} diff --git a/packages/functions-compiler/src/esbuild/node-builtin-require.ts b/packages/functions-compiler/src/esbuild/node-builtin-require.ts new file mode 100644 index 000000000..c303f332e --- /dev/null +++ b/packages/functions-compiler/src/esbuild/node-builtin-require.ts @@ -0,0 +1,39 @@ +import { isBuiltin } from "node:module"; + +import type { OnResolveArgs, Plugin } from "esbuild"; + +import { USER_NAMESPACE } from "./user-files.js"; + +const REEXPORT_NAMESPACE = "node-builtin-reexport"; + +// Default export so `require("stream")` is the Stream class, not the namespace. +function reexportStub(specifier: string): string { + return `import * as builtin from "${specifier}";\nmodule.exports = builtin.default ?? builtin;\n`; +} + +// esbuild lowers a CJS `require()` of an external builtin to a `__require()` that +// throws on workerd, so rewrite those to a static import. `isBuiltin` on the full +// specifier rejects `require("string_decoder/")` (the npm package, not the builtin). +export function nodeBuiltinRequirePlugin(): Plugin { + const onResolve = (args: OnResolveArgs) => { + if (args.kind !== "require-call" || !isBuiltin(args.path)) { + return null; + } + return { path: args.path, namespace: REEXPORT_NAMESPACE }; + }; + return { + name: "node-builtin-require", + setup(build) { + const filter = /^(node:)?[a-z][a-z0-9._/-]*$/; + // Default (file) namespace covers deps; user namespace covers the shim and + // user files now served from memory. + build.onResolve({ filter }, onResolve); + build.onResolve({ filter, namespace: USER_NAMESPACE }, onResolve); + + build.onLoad({ filter: /.*/, namespace: REEXPORT_NAMESPACE }, (args) => ({ + contents: reexportStub(args.path), + loader: "js", + })); + }, + }; +} diff --git a/packages/functions-compiler/src/esbuild/private-data-sources-virtual.ts b/packages/functions-compiler/src/esbuild/private-data-sources-virtual.ts new file mode 100644 index 000000000..73597905a --- /dev/null +++ b/packages/functions-compiler/src/esbuild/private-data-sources-virtual.ts @@ -0,0 +1,133 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import type { Plugin } from "esbuild"; + +import { ACTIVATION_FILENAME } from "../worker-entry.js"; +import { USER_NAMESPACE } from "./user-files.js"; + +const PREFIX = "base44:private-data-sources"; +export const PRIVATE_DATA_SOURCES_NAMESPACE = + "base44-private-data-sources"; +const MODULE_DIR = new URL("../private-data-sources/", import.meta.url); +const PUBLIC_MODULES = new Set([ + "elasticsearch", + "http", + "mariadb", + "mongodb", + "mysql", + "postgres", + "redis", + "sqlserver", +]); + +function publicModulePath(specifier: string): string | null { + if (specifier === PREFIX) return null; + if (!specifier.startsWith(`${PREFIX}/`)) return null; + const moduleName = specifier.slice(PREFIX.length + 1); + if (!PUBLIC_MODULES.has(moduleName)) return null; + return `${moduleName}.ts`; +} + +// Internal, NOT user-importable: the activation shim writes the handshake +// manifest into this store and manifest.ts reads it. Resolving it for user code +// would let it read/forge the manifest (plaintext VPC/DB credentials). Gated by +// an ALLOW-list — the ONLY legitimate importer is the injected activation shim. +// +// The shim is always injected at the bundle-ROOT key ACTIVATION_FILENAME (see +// prepareFunction/prepareApp), so its esbuild importer is EXACTLY that string in +// the user namespace — the same trusted-platform-basename shape main uses for +// base44:internal/runtime-context (see runtime-context-virtual). Match the +// namespace AND the exact key: a suffix/segment match (`.../x/__base44_activation.mjs`, +// `fn_3/.../__base44_activation.mjs`) would let a NESTED user file with that +// basename pose as the shim and read the manifest. `assertNoReservedFilenames` +// rejects that basename at any depth in every mode (as it does for the sibling +// __base44_* platform files), so no user file can ever hold it. The store shares +// PRIVATE_DATA_SOURCES_NAMESPACE with manifest.ts so both dedupe to one module +// instance in the final bundle. +const INTERNAL_STORE_SPECIFIER = `${PREFIX}/runtime-manifest-store`; + +function isActivationShimImporter(namespace: string, importer: string): boolean { + return namespace === USER_NAMESPACE && importer === ACTIVATION_FILENAME; +} + +function relativeModulePath(importer: string, specifier: string): string | null { + if (!specifier.startsWith("./") && !specifier.startsWith("../")) return null; + const importerDir = importer.includes("/") ? importer.slice(0, importer.lastIndexOf("/")) : ""; + const resolved = path.posix.normalize(path.posix.join(importerDir, specifier)); + if (resolved.startsWith("../") || resolved === ".." || path.posix.isAbsolute(resolved)) return null; + return resolved.endsWith(".ts") ? resolved : `${resolved}.ts`; +} + +function loadModule(modulePath: string) { + return readFileSync(new URL(modulePath, MODULE_DIR), "utf8"); +} + +export function privateDataSourcesVirtualPlugin(): Plugin { + return { + name: "base44-private-data-sources-virtual", + setup(build) { + build.onResolve({ filter: /^base44:private-data-sources(?:\/.*)?$/ }, (args) => { + if (args.path === INTERNAL_STORE_SPECIFIER) { + if (!isActivationShimImporter(args.namespace, args.importer)) { + return { + errors: [{ + text: + `"${args.path}" is internal to the Base44 runtime and cannot be imported ` + + "by backend function code.", + }], + }; + } + return { path: "runtime-manifest-store.ts", namespace: PRIVATE_DATA_SOURCES_NAMESPACE }; + } + const modulePath = publicModulePath(args.path); + if (!modulePath) { + return { + errors: [ + { + text: + `Unsupported import "${args.path}". Use a type-specific private data source import, ` + + 'for example "base44:private-data-sources/postgres".', + }, + ], + }; + } + return { + path: modulePath, + namespace: PRIVATE_DATA_SOURCES_NAMESPACE, + }; + }); + + build.onResolve( + { + filter: /^\.\.?\//, + namespace: PRIVATE_DATA_SOURCES_NAMESPACE, + }, + (args) => { + const modulePath = relativeModulePath(args.importer, args.path); + if (!modulePath) { + return { + errors: [ + { + text: `Invalid private data source module import "${args.path}"`, + }, + ], + }; + } + return { + path: modulePath, + namespace: PRIVATE_DATA_SOURCES_NAMESPACE, + }; + }, + ); + + build.onLoad( + { filter: /.*/, namespace: PRIVATE_DATA_SOURCES_NAMESPACE }, + (args) => ({ + contents: loadModule(args.path), + loader: "ts", + }), + ); + }, + }; +} diff --git a/packages/functions-compiler/src/esbuild/runtime-context-virtual.ts b/packages/functions-compiler/src/esbuild/runtime-context-virtual.ts new file mode 100644 index 000000000..7c876534b --- /dev/null +++ b/packages/functions-compiler/src/esbuild/runtime-context-virtual.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; + +import type { Plugin } from "esbuild"; + +import { PRIVATE_DATA_SOURCES_NAMESPACE } from "./private-data-sources-virtual.js"; +import { USER_NAMESPACE } from "./user-files.js"; + +export const RUNTIME_CONTEXT_SPECIFIER = "base44:internal/runtime-context"; + +const NAMESPACE = "base44-runtime-context"; +const MODULE_URL = new URL("../runtime-context.ts", import.meta.url); +const TRUSTED_USER_IMPORTERS = new Set([ + "__base44_actor_entry.mjs", + "__base44_actor_prelude.mjs", + "__base44_deno_shim.mjs", + "__base44_entry.mjs", +]); + +export function runtimeContextVirtualPlugin(): Plugin { + return { + name: "base44-runtime-context-virtual", + setup(build) { + build.onResolve( + { filter: /^base44:internal\/runtime-context$/ }, + (args) => { + const trusted = + args.namespace === PRIVATE_DATA_SOURCES_NAMESPACE || + (args.namespace === USER_NAMESPACE && + TRUSTED_USER_IMPORTERS.has(args.importer)); + if (!trusted) { + return { + errors: [ + { + text: `Unsupported internal runtime import "${args.path}".`, + }, + ], + }; + } + return { + path: RUNTIME_CONTEXT_SPECIFIER, + namespace: NAMESPACE, + }; + }, + ); + + build.onLoad({ filter: /.*/, namespace: NAMESPACE }, () => ({ + contents: readFileSync(MODULE_URL, "utf8"), + loader: "ts", + })); + }, + }; +} diff --git a/packages/functions-compiler/src/esbuild/runtime-virtual.ts b/packages/functions-compiler/src/esbuild/runtime-virtual.ts new file mode 100644 index 000000000..ff3e36d0a --- /dev/null +++ b/packages/functions-compiler/src/esbuild/runtime-virtual.ts @@ -0,0 +1,54 @@ +import { existsSync, readFileSync } from "node:fs"; + +import type { Plugin } from "esbuild"; + +// Keep this allowlist in sync with +// backend/app/cloudflare_functions/code_scan.py (_BASE44_RUNTIME_IMPORT / +// _BASE44_RUNTIME_ACTORS_IMPORT). +const SPECIFIER = "base44:runtime"; +// The Actor base class, served for `import { Actor } from "base44:runtime/actors"`. +const ACTORS_SPECIFIER = "base44:runtime/actors"; +const NAMESPACE = "base44-runtime"; +const MODULE_URL = new URL("../runtime/index.ts", import.meta.url); +// Prebuilt partyserver-backed shim (built by `npm run build:shim`). This file +// lives in src/esbuild/, so dist/ is two levels up (../../), unlike runtime/. +const ACTOR_SHIM_URL = new URL("../../dist/actor.mjs", import.meta.url); + +export function runtimeVirtualPlugin(): Plugin { + return { + name: "base44-runtime-virtual", + setup(build) { + build.onResolve({ filter: /^base44:runtime(?:\/.*)?$/ }, (args) => { + if (args.path === SPECIFIER || args.path === ACTORS_SPECIFIER) { + return { path: args.path, namespace: NAMESPACE }; + } + return { + errors: [ + { + text: `Unsupported import "${args.path}". Supported: "${SPECIFIER}" and "${ACTORS_SPECIFIER}".`, + }, + ], + }; + }); + + build.onLoad({ filter: /.*/, namespace: NAMESPACE }, (args) => { + if (args.path === ACTORS_SPECIFIER) { + // A missing shim would silently bundle a bindingless plain function — fail loud. + if (!existsSync(ACTOR_SHIM_URL)) { + return { + errors: [ + { + text: + 'Import "base44:runtime/actors" requires dist/actor.mjs — ' + + "run `npm run build:shim` (with partyserver installed) before bundling.", + }, + ], + }; + } + return { contents: readFileSync(ACTOR_SHIM_URL, "utf8"), loader: "js" }; + } + return { contents: readFileSync(MODULE_URL, "utf8"), loader: "ts" }; + }); + }, + }; +} diff --git a/packages/functions-compiler/src/esbuild/user-files.ts b/packages/functions-compiler/src/esbuild/user-files.ts new file mode 100644 index 000000000..28842fbf7 --- /dev/null +++ b/packages/functions-compiler/src/esbuild/user-files.ts @@ -0,0 +1,93 @@ +/** + * Serves the prepared worker tree (user sources + injected shim + generated + * entry) from memory instead of disk. User code is never written to the shared + * temp filesystem, so a malicious import can't traverse to another build's + * source — and this plugin is the one place that decides what user code may + * import: relative paths must stay inside the in-memory keyspace; `npm:`/`jsr:`/ + * `http(s):`/bare specifiers are handed to the Deno resolver; absolute and + * `file:` imports are refused (the FS-read vector the on-disk layout allowed). + * + * Registered BEFORE the Deno resolver so it claims user files first and defers + * everything else by returning null. + */ + +import path from "node:path"; + +import type { Loader, Plugin } from "esbuild"; + +export const USER_NAMESPACE = "user"; + +/** Resolve a relative specifier to the user file it names (posix, exact match — + * Deno already requires explicit extensions). Returns the file path, or null + * when it isn't in the submission (an escape or a typo). */ +function resolveUserFile( + importerPath: string, + spec: string, + files: Record, +): string | null { + const dir = importerPath.includes("/") + ? importerPath.slice(0, importerPath.lastIndexOf("/")) + : ""; + const filePath = path.posix.normalize(path.posix.join(dir, spec)); + return filePath in files ? filePath : null; +} + +function loaderForFile(filePath: string): Loader { + if (/\.(ts|mts|cts)$/.test(filePath)) return "ts"; + if (filePath.endsWith(".tsx")) return "tsx"; + if (filePath.endsWith(".jsx")) return "jsx"; + if (filePath.endsWith(".json")) return "json"; + return "js"; +} + +export function userFilesPlugin( + files: Record, + entryPath: string, +): Plugin { + return { + name: "user-files", + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (args.kind === "entry-point" && entryPath in files) { + return { path: entryPath, namespace: USER_NAMESPACE }; + } + return null; + }); + + build.onResolve({ filter: /.*/, namespace: USER_NAMESPACE }, (args) => { + if (args.path.startsWith("./") || args.path.startsWith("../")) { + const filePath = resolveUserFile(args.importer, args.path, files); + if (filePath) { + return { path: filePath, namespace: USER_NAMESPACE }; + } + + return forbidden( + args.path, + 'it must reference a file bundled with this function — check the path and include the extension (e.g. "./util.ts"). Relative imports can\'t reach outside the function; import dependencies with an npm: or jsr: specifier.', + ); + } + // Absolute paths and file: URLs would read the bundler's filesystem. + if (args.path.startsWith("/") || args.path.startsWith("file:")) { + return forbidden( + args.path, + 'absolute paths and file: URLs can\'t be imported. Use a relative path (e.g. "./util.ts") for your own files, or an npm:/jsr:/https: specifier for dependencies.', + ); + } + return null; // npm:/jsr:/http(s):/node:/bare → Deno resolver + }); + + build.onLoad({ filter: /.*/, namespace: USER_NAMESPACE }, (args) => { + const contents = files[args.path]; + if (contents === undefined) { + return null; + } + + return { contents, loader: loaderForFile(args.path) }; + }); + }, + }; +} + +function forbidden(spec: string, reason: string) { + return { errors: [{ text: `Cannot import "${spec}": ${reason}` }] }; +} diff --git a/packages/functions-compiler/src/fetch-guard.ts b/packages/functions-compiler/src/fetch-guard.ts new file mode 100644 index 000000000..b0ed002d9 --- /dev/null +++ b/packages/functions-compiler/src/fetch-guard.ts @@ -0,0 +1,82 @@ +/** + * Process-level guard on `globalThis.fetch`. The Deno resolver (`@deno/loader`) + * routes every dependency download — npm metadata, npm tarballs, jsr, and any + * `https:` import — through the global `fetch`, so wrapping it is the single + * enforcement point that replaces the per-filesystem caps the old virtual-FS + * installer provided: + * + * - Host allowlist: the npm + jsr registries plus the esm.sh and deno.land + * CDNs (so `https:` imports from those work). Every other host is blocked, + * so user code can't pull from arbitrary origins (the supply-chain vector + * the old compat layer rejected by scanning specifiers). + * - Per-response byte cap via the `Content-Length` header. The body itself is + * never touched: rewrapping the response stream (an earlier TransformStream + * approach) corrupts delivery to the loader once a live `node:http` server + * is handling requests, so the original `Response` is always returned as-is. + * Downloads without a length header rely on the container memory limit, disk + * (deps land in DENO_DIR, not the heap), and the per-request deadline. + * + * Install once at startup, before the server begins handling requests. + */ + +import { logEvent } from "./log.js"; + +const ALLOWED_HOST_SUFFIXES = ["npmjs.org", "jsr.io", "esm.sh", "deno.land"]; + +// Mirrors the old per-tarball compressed cap (installer.ts MAX_TARBALL_BYTES). +const MAX_RESPONSE_BYTES = 30 * 1024 * 1024; + +let installed = false; + +/** Install the guard over `globalThis.fetch`. Idempotent. */ +export function installFetchGuard(): void { + if (installed) return; + installed = true; + globalThis.fetch = createGuardedFetch(globalThis.fetch.bind(globalThis)); +} + +/** Wrap a fetch implementation with the host allowlist + per-response size cap. + * Exposed (with an injectable cap) so the policy is testable without mutating + * the global. */ +export function createGuardedFetch( + originalFetch: typeof fetch, + maxBytes = MAX_RESPONSE_BYTES, +): typeof fetch { + return async (input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input); + if (!isAllowedHost(url.hostname)) { + logEvent("warn", "base44.bundler.fetch_blocked", { host: url.hostname }); + throw new Error( + `Blocked fetch to disallowed host "${url.hostname}". Allowed: the npm and jsr registries and the esm.sh / deno.land CDNs — import dependencies via npm:, jsr:, or an https: URL on one of those hosts.`, + ); + } + + const response = await originalFetch(input, init); + + // Size cap via Content-Length only — never read or rewrap the body, so the + // original Response reaches the loader untouched. + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) { + logEvent("warn", "base44.bundler.fetch_too_large", { + host: url.hostname, + bytes: declared, + }); + throw new Error( + `Dependency download is ${declared} bytes, over the ${maxBytes}-byte limit.`, + ); + } + return response; + }; +} + +function requestUrl(input: RequestInfo | URL): URL { + if (input instanceof URL) return input; + if (typeof input === "string") return new URL(input); + return new URL(input.url); +} + +function isAllowedHost(hostname: string): boolean { + return ALLOWED_HOST_SUFFIXES.some( + (suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`), + ); +} diff --git a/packages/functions-compiler/src/index.ts b/packages/functions-compiler/src/index.ts new file mode 100644 index 000000000..54a04aae0 --- /dev/null +++ b/packages/functions-compiler/src/index.ts @@ -0,0 +1,36 @@ +// Public surface of the production function compiler. Everything else under +// src/ is either an internal engine module or a compile-time asset read as text +// by the esbuild plugins. + +export { bundle, bundleApp, classifyAppErrors, importsConflictingPackage } from "./bundler.js"; +export type { + AppErrorClassification, + AppFunctionStatus, + BundleAppResponse, + BundleErrorStage, + BundleResponse, +} from "./bundler.js"; + +export { + appFunctionSchema, + bundleAppRequestSchema, + bundleRequestSchema, +} from "./contracts.js"; +export type { + AppFunctionInput, + BundleAppRequest, + BundleRequest, +} from "./contracts.js"; + +export { DenoCompatError } from "./errors.js"; +export type { BundleErrorItem } from "./errors.js"; + +export { createGuardedFetch, installFetchGuard } from "./fetch-guard.js"; + +export { STATIC_EGRESS_ARTIFACT_MARKER } from "./static-egress-marker.js"; + +export { setCompilerTracer } from "./tracing.js"; +export type { CompilerTracer } from "./tracing.js"; + +export { setLogSink } from "./log.js"; +export type { Field, Level, LogSink } from "./log.js"; diff --git a/packages/functions-compiler/src/log.ts b/packages/functions-compiler/src/log.ts new file mode 100644 index 000000000..b6e9c0196 --- /dev/null +++ b/packages/functions-compiler/src/log.ts @@ -0,0 +1,52 @@ +// One JSON object per line — Datadog auto-parses it into facets (logs arrive +// via Render Log Stream; no agent). `status` is Datadog's reserved level attribute. +// A host that owns its own output (the CLI) replaces the writer via `setLogSink`. + +const SERVICE = process.env.DD_SERVICE; +const ENV = process.env.DD_ENV; +const VERSION = process.env.DD_VERSION; + +export type Level = "info" | "warn" | "error"; +export type Field = string | number | boolean | undefined; +export type LogSink = ( + level: Level, + event: string, + fields: Record, +) => void; + +/** `undefined` fields are dropped so absent dimensions don't create empty facets. */ +const jsonLineSink: LogSink = (level, event, fields) => { + const line: Record = { + status: level, + service: SERVICE, + env: ENV, + version: VERSION, + event, + }; + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined) line[key] = value; + } + if (level === "error") { + console.error(JSON.stringify(line)); + } else if (level === "warn") { + console.warn(JSON.stringify(line)); + } else { + console.log(JSON.stringify(line)); + } +}; + +let sink: LogSink = jsonLineSink; + +/** Route compiler diagnostics somewhere else; `null` restores the JSON writer. + * Must be called in the same thread that runs the compile. */ +export function setLogSink(next: LogSink | null): void { + sink = next ?? jsonLineSink; +} + +export function logEvent( + level: Level, + event: string, + fields: Record = {}, +): void { + sink(level, event, fields); +} diff --git a/packages/functions-compiler/src/private-data-sources/build-http.ts b/packages/functions-compiler/src/private-data-sources/build-http.ts new file mode 100644 index 000000000..0118e80f1 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/build-http.ts @@ -0,0 +1,51 @@ +import { resolveHttpPrivateDataSourceUrl } from "./http-url"; +import { + currentPrivateDataSource, + privateDataSourceBinding, +} from "./manifest"; +import type { FetchPrivateDataSourceBinding, PrivateDataSourceManifestEntry } from "./types"; + +// Internal, NOT a public virtual module: app code cannot import this builder to +// hand-forge a manifest entry. The only public entrypoints — http()/elasticsearch() +// — resolve their entry from the immutable runtime manifest via lookupPrivateDataSource. + +function hasFetch(binding: unknown): binding is FetchPrivateDataSourceBinding { + return ( + binding !== null && + typeof binding === "object" && + typeof (binding as { fetch?: unknown }).fetch === "function" + ); +} + +export function buildHttpPrivateDataSource(entry: PrivateDataSourceManifestEntry) { + return Object.freeze({ + name: entry.name, + type: entry.type, + get bindingName() { + return currentPrivateDataSource(entry).bindingName; + }, + fetch(resource: RequestInfo | URL, init?: RequestInit) { + const current = currentPrivateDataSource(entry); + const binding = privateDataSourceBinding(current); + if (!hasFetch(binding)) { + throw new Error( + `Private data source "${current.name}" does not expose fetch()`, + ); + } + return binding.fetch( + resolveHttpPrivateDataSourceUrl(current, resource), + init, + ); + }, + // Fixed services expose the raw binding (Cloudflare pins their single + // target). Deferred Actor handles decide this from the request manifest, + // after their request environment exists. + get binding() { + const current = currentPrivateDataSource(entry); + return current.bindingName !== undefined + && current.networkScope !== "network" + ? privateDataSourceBinding(current) + : undefined; + }, + }); +} diff --git a/packages/functions-compiler/src/private-data-sources/elasticsearch.ts b/packages/functions-compiler/src/private-data-sources/elasticsearch.ts new file mode 100644 index 000000000..024caed44 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/elasticsearch.ts @@ -0,0 +1,8 @@ +import { buildHttpPrivateDataSource } from "./build-http"; +import { privateDataSourceReference } from "./manifest"; + +export function elasticsearch(name: unknown) { + return buildHttpPrivateDataSource( + privateDataSourceReference(name, "elasticsearch"), + ); +} diff --git a/packages/functions-compiler/src/private-data-sources/http-url.ts b/packages/functions-compiler/src/private-data-sources/http-url.ts new file mode 100644 index 000000000..dddb2dd45 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/http-url.ts @@ -0,0 +1,23 @@ +import type { PrivateDataSourceManifestEntry } from "./types"; + +const FALLBACK_BASE_URL = "http://private-data-source.local"; + +/** + * Resolve a fetch target for an HTTP private data source. Relative paths resolve + * against the source's base URL; absolute URLs pass through. There is no host + * allowlist: for a fixed VPC service Cloudflare pins the single target, and for a + * network binding the reachable set is the customer's tunnel scope — the app can + * fetch any host behind it (e.g. Trino router + cluster nextUri hosts). + */ +export function resolveHttpPrivateDataSourceUrl( + entry: PrivateDataSourceManifestEntry, + resource: RequestInfo | URL, +): RequestInfo | URL { + if (resource instanceof Request) return resource; + const raw = resource instanceof URL ? resource.toString() : String(resource); + try { + return new URL(raw).toString(); + } catch { + return new URL(raw || "/", entry.baseUrl || FALLBACK_BASE_URL).toString(); + } +} diff --git a/packages/functions-compiler/src/private-data-sources/http.ts b/packages/functions-compiler/src/private-data-sources/http.ts new file mode 100644 index 000000000..42b942d29 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/http.ts @@ -0,0 +1,6 @@ +import { buildHttpPrivateDataSource } from "./build-http"; +import { privateDataSourceReference } from "./manifest"; + +export function http(name: unknown) { + return buildHttpPrivateDataSource(privateDataSourceReference(name, "http")); +} diff --git a/packages/functions-compiler/src/private-data-sources/hyperdrive.ts b/packages/functions-compiler/src/private-data-sources/hyperdrive.ts new file mode 100644 index 000000000..f0d4612f7 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/hyperdrive.ts @@ -0,0 +1,48 @@ +import { + currentPrivateDataSource, + privateDataSourceReference, + privateDataSourceBinding, +} from "./manifest"; +import type { PrivateDataSourceManifestEntry } from "./types"; + +export function buildHyperdrivePrivateDataSource(entry: PrivateDataSourceManifestEntry) { + const binding = () => + privateDataSourceBinding(entry) as Record; + return Object.freeze({ + name: entry.name, + type: entry.type, + get bindingName() { + return currentPrivateDataSource(entry).bindingName; + }, + get connectionString() { + return binding().connectionString; + }, + get host() { + return binding().host; + }, + get port() { + return binding().port; + }, + get user() { + return binding().user; + }, + get username() { + return binding().user; + }, + get password() { + return binding().password; + }, + get database() { + return binding().database; + }, + get binding() { + return binding(); + }, + }); +} + +export function hyperdrive(name: unknown) { + return buildHyperdrivePrivateDataSource( + privateDataSourceReference(name, "hyperdrive"), + ); +} diff --git a/packages/functions-compiler/src/private-data-sources/ioredis-adapter.ts b/packages/functions-compiler/src/private-data-sources/ioredis-adapter.ts new file mode 100644 index 000000000..fc94599a1 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/ioredis-adapter.ts @@ -0,0 +1,229 @@ +import { Buffer } from "node:buffer"; +import { EventEmitter } from "node:events"; + +import { isCloudflareTcpSocket } from "./tcp"; + +type IoredisErrorEmitter = (type: string, err: Error) => void; + +interface IoredisNetStream extends EventEmitter { + connecting: boolean; + destroyed: boolean; + readable: boolean; + writable: boolean; + remoteAddress?: string; + remotePort?: number; + _writableState: { ended: boolean }; + write(data: string | Uint8Array, callback?: (err?: Error | null) => void): boolean; + end(data?: string | Uint8Array | (() => void), callback?: () => void): IoredisNetStream; + destroy(error?: Error): IoredisNetStream; + setNoDelay(noDelay?: boolean): IoredisNetStream; + setKeepAlive(enable?: boolean, initialDelay?: number): IoredisNetStream; + setTimeout(timeout: number, callback?: () => void): IoredisNetStream; + resume(): IoredisNetStream; + pause(): IoredisNetStream; +} + +function exactNodeBuffer(value: Uint8Array): Buffer { + const bytes = new Uint8Array(value.byteLength); + bytes.set(value); + return Buffer.from(bytes.buffer); +} + +function buildIoredisNetStream( + socket: unknown, + errorEmitter?: IoredisErrorEmitter, +): IoredisNetStream { + if (!isCloudflareTcpSocket(socket)) { + throw new Error("Redis private data source did not return a readable/writable TCP socket"); + } + const cloudflareSocket = socket; + + const encoder = new TextEncoder(); + const stream = new EventEmitter() as IoredisNetStream; + let reader: ReadableStreamDefaultReader | null = null; + let writer: WritableStreamDefaultWriter | null = null; + let readStarted = false; + let connectEmitted = false; + let closed = false; + let timeout: ReturnType | null = null; + let writeChain = Promise.resolve(); + + function setClosed() { + if (closed) return; + closed = true; + stream.destroyed = true; + stream.readable = false; + stream.writable = false; + stream._writableState.ended = true; + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + try { + reader?.releaseLock(); + } catch { + // Ignore release errors when the stream is already closed. + } + reader = null; + try { + writer?.releaseLock(); + } catch { + // Ignore release errors when the stream is already closed. + } + writer = null; + stream.emit("close"); + } + + function emitError(error: unknown) { + const err = error instanceof Error ? error : new Error(String(error)); + errorEmitter?.("error", err); + stream.emit("error", err); + } + + function markConnected() { + if (closed || connectEmitted) return; + connectEmitted = true; + stream.connecting = false; + stream.emit("connect"); + } + + async function readLoop() { + const activeReader = reader ?? cloudflareSocket.readable.getReader(); + reader = activeReader; + try { + while (!closed) { + const { value, done } = await activeReader.read(); + if (done) break; + if (value) stream.emit("data", exactNodeBuffer(value)); + } + setClosed(); + } catch (error) { + if (!closed) { + emitError(error); + setClosed(); + } + } + } + + function writeBytes(bytes: Uint8Array) { + if (closed) return Promise.reject(new Error("Redis connection is closed")); + const activeWriter = writer ?? cloudflareSocket.writable.getWriter(); + writer = activeWriter; + return activeWriter.write(bytes); + } + + stream.connecting = true; + stream.destroyed = false; + stream.readable = true; + stream.writable = true; + stream._writableState = { ended: false }; + stream.write = (data, callback) => { + const bytes = typeof data === "string" ? encoder.encode(data) : data; + writeChain = writeChain + .then(() => writeBytes(bytes)) + .then( + () => callback?.(), + (error) => { + const err = error instanceof Error ? error : new Error(String(error)); + callback?.(err); + emitError(err); + }, + ); + return true; + }; + stream.end = (data?: string | Uint8Array | (() => void), callback?: () => void) => { + const done = typeof data === "function" ? data : callback; + if (typeof data === "string" || data instanceof Uint8Array) { + stream.write(data); + } + writeChain + .finally(() => { + done?.(); + try { + cloudflareSocket.close?.(); + } finally { + setClosed(); + } + }) + .catch(() => {}); + return stream; + }; + stream.destroy = (error?: Error) => { + if (error) emitError(error); + try { + cloudflareSocket.close?.(); + } finally { + setClosed(); + } + return stream; + }; + stream.setNoDelay = () => stream; + stream.setKeepAlive = () => stream; + stream.setTimeout = (duration, callback) => { + if (timeout) clearTimeout(timeout); + timeout = null; + if (duration > 0) { + timeout = setTimeout(() => { + callback?.(); + stream.emit("timeout"); + }, duration); + } + return stream; + }; + stream.resume = () => { + if (!readStarted) { + readStarted = true; + readLoop(); + } + return stream; + }; + stream.pause = () => stream; + + if (cloudflareSocket.opened) { + cloudflareSocket.opened.then(markConnected, (error) => { + emitError(error); + stream.destroy(error instanceof Error ? error : new Error(String(error))); + }); + } else { + queueMicrotask(markConnected); + } + cloudflareSocket.closed?.finally(setClosed).catch(() => {}); + return stream; +} + +export function buildIoredisConnectorFactory(connect: () => unknown) { + return class Base44IoredisConnector { + firstError?: Error; + private stream?: IoredisNetStream; + private disconnectTimeout: number; + + constructor(options?: { disconnectTimeout?: number }) { + this.disconnectTimeout = + typeof options?.disconnectTimeout === "number" ? options.disconnectTimeout : 2000; + } + + check() { + return true; + } + + connect(errorEmitter?: IoredisErrorEmitter) { + return Promise.resolve(connect()).then((socket) => { + try { + this.stream = buildIoredisNetStream(socket, errorEmitter); + return this.stream; + } catch (error) { + this.firstError = error instanceof Error ? error : new Error(String(error)); + throw this.firstError; + } + }); + } + + disconnect() { + const stream = this.stream; + if (!stream || stream.destroyed) return; + const timeout = setTimeout(() => stream.destroy(), this.disconnectTimeout); + stream.once("close", () => clearTimeout(timeout)); + stream.end(); + } + }; +} diff --git a/packages/functions-compiler/src/private-data-sources/manifest.ts b/packages/functions-compiler/src/private-data-sources/manifest.ts new file mode 100644 index 000000000..fa0fd42a9 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/manifest.ts @@ -0,0 +1,150 @@ +import { + currentWorkerRuntimeContext, + workerEnvironment, +} from "./runtime-environment"; +import { getRuntimeManifest } from "./runtime-manifest-store"; +import type { PrivateDataSourceManifestEntry } from "./types"; + +const PRIVATE_DATA_SOURCES_MANIFEST_ENV = "BASE44_PRIVATE_DATA_SOURCES"; + +function readStringVar(key: string): string | undefined { + const value = workerEnvironment()[key]; + return typeof value === "string" ? value : undefined; +} + +// Pin a HANDSHAKE-DELIVERED manifest to the deployed binding set: it is resolved +// fresh per activation, but the script's Hyperdrive/VPC bindings are frozen at +// upload — a source added/renamed since then must not surface an entry whose +// binding this script does not have (its lookup would pass and then fail deeper +// at privateDataSourceBinding). A manifest that exists as a Worker binding was +// frozen together with the live bindings, so it is served unfiltered. +function entryBindingIsDeployed(entry: PrivateDataSourceManifestEntry): boolean { + return typeof entry.bindingName === "string" && workerEnvironment()[entry.bindingName] !== undefined; +} + +function parsePrivateDataSourceManifest(): PrivateDataSourceManifestEntry[] { + // Runtime-secrets bundles receive the manifest through the private, single- + // instance store the activation shim writes (see runtime-manifest-store) — NOT + // the Worker binding, process.env, or any user-reachable global. Old mode has + // no runtime manifest and falls through to the immutable Worker binding. + const runtimeRaw = getRuntimeManifest(); + const fromHandshake = runtimeRaw !== undefined; + const raw = fromHandshake ? runtimeRaw : readStringVar(PRIVATE_DATA_SOURCES_MANIFEST_ENV); + if (!raw) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + const entries = Array.isArray(parsed) + ? parsed.filter( + (entry): entry is PrivateDataSourceManifestEntry => + entry !== null && typeof entry === "object", + ) + : []; + return fromHandshake ? entries.filter(entryBindingIsDeployed) : entries; +} + +export function normalizePrivateDataSourceKey(value: unknown): string { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +export function privateDataSourceReference( + requestedName: unknown, + expectedType: string, +): PrivateDataSourceManifestEntry { + if (currentWorkerRuntimeContext()) { + return lookupPrivateDataSource(requestedName, expectedType); + } + return { + name: String(requestedName || ""), + type: expectedType, + }; +} + +function privateDataSourceMatchRank( + entry: PrivateDataSourceManifestEntry, + requestedName: unknown, +): number | null { + const requested = String(requestedName || ""); + const normalizedRequested = normalizePrivateDataSourceKey(requested); + const candidates = [ + { value: entry.name, rank: 0 }, + { value: entry.bindingName, rank: 1 }, + { value: entry.name, rank: 2, normalized: true }, + { value: entry.bindingName, rank: 3, normalized: true }, + ]; + let bestRank: number | null = null; + for (const candidate of candidates) { + if (typeof candidate.value !== "string") continue; + const matches = candidate.normalized + ? normalizePrivateDataSourceKey(candidate.value) === normalizedRequested + : candidate.value === requested; + if (matches && (bestRank === null || candidate.rank < bestRank)) { + bestRank = candidate.rank; + } + } + return bestRank; +} + +function privateDataSourceMatchesExpectedType( + entry: PrivateDataSourceManifestEntry, + expectedType: string, +): boolean { + return entry.type === expectedType || (expectedType === "hyperdrive" && entry.bindingKind === "hyperdrive"); +} + +export function lookupPrivateDataSource( + requestedName: unknown, + expectedType?: string, +): PrivateDataSourceManifestEntry { + const matches = parsePrivateDataSourceManifest() + .map((entry) => ({ entry, rank: privateDataSourceMatchRank(entry, requestedName) })) + .filter((match): match is { entry: PrivateDataSourceManifestEntry; rank: number } => match.rank !== null) + .sort((a, b) => a.rank - b.rank); + if (matches.length === 0) { + throw new Error(`Private data source "${requestedName}" is not bound to this backend function`); + } + + const typeMatches = expectedType + ? matches.filter((candidate) => privateDataSourceMatchesExpectedType(candidate.entry, expectedType)) + : matches; + if (typeMatches.length === 0) { + throw new Error(`Private data source "${requestedName}" is not a ${expectedType} data source`); + } + + const bestRank = typeMatches[0].rank; + const bestMatches = typeMatches.filter((candidate) => candidate.rank === bestRank); + if (bestMatches.length > 1) { + const kind = expectedType ? ` ${expectedType}` : ""; + throw new Error( + `Private data source "${requestedName}" is ambiguous: ${bestMatches.length}${kind} data sources match this name`, + ); + } + return bestMatches[0].entry; +} + +export function privateDataSourceBinding(entry: PrivateDataSourceManifestEntry): unknown { + const current = lookupPrivateDataSource(entry.name, entry.type); + if (!current.bindingName) { + throw new Error(`Private data source "${current.name}" has no Worker binding name`); + } + const binding = workerEnvironment()[current.bindingName]; + if (!binding) { + throw new Error( + `Private data source binding "${current.bindingName}" is missing from the Worker environment`, + ); + } + return binding; +} + +export function currentPrivateDataSource( + entry: PrivateDataSourceManifestEntry, +): PrivateDataSourceManifestEntry { + return lookupPrivateDataSource(entry.name, entry.type); +} diff --git a/packages/functions-compiler/src/private-data-sources/mariadb.ts b/packages/functions-compiler/src/private-data-sources/mariadb.ts new file mode 100644 index 000000000..0995bd987 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/mariadb.ts @@ -0,0 +1,8 @@ +import { buildHyperdrivePrivateDataSource } from "./hyperdrive"; +import { privateDataSourceReference } from "./manifest"; + +export function mariadb(name: unknown) { + return buildHyperdrivePrivateDataSource( + privateDataSourceReference(name, "mariadb"), + ); +} diff --git a/packages/functions-compiler/src/private-data-sources/mongodb.ts b/packages/functions-compiler/src/private-data-sources/mongodb.ts new file mode 100644 index 000000000..1f897f8de --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/mongodb.ts @@ -0,0 +1,70 @@ +import { + currentPrivateDataSource, + normalizePrivateDataSourceKey, + privateDataSourceReference, +} from "./manifest"; +import { buildTcpPrivateDataSource } from "./tcp"; +import { installPrivateNodeNetAdapter, registerPrivateNodeNetRoute } from "./node-net-adapter"; +import type { PrivateDataSourceManifestEntry } from "./types"; + +function encodeMongoConnectionPart(value: unknown): string { + return encodeURIComponent(String(value ?? "")); +} + +function buildMongoConnectionString(entry: PrivateDataSourceManifestEntry, hostname: string): string { + const username = entry.username ? encodeMongoConnectionPart(entry.username) : ""; + const password = entry.password ? encodeMongoConnectionPart(entry.password) : ""; + const auth = username ? `${username}${password ? `:${password}` : ""}@` : ""; + const database = entry.database ? `/${encodeMongoConnectionPart(entry.database)}` : ""; + const port = typeof entry.port === "number" ? entry.port : 27017; + return `mongodb://${auth}${hostname}:${port}${database}?directConnection=true`; +} + +export function mongodb(name: unknown) { + installPrivateNodeNetAdapter(); + const entry = privateDataSourceReference(name, "mongodb"); + const source = buildTcpPrivateDataSource(entry) as ReturnType & { + connect: (options?: unknown) => unknown; + }; + const driverHostname = `base44-private-${normalizePrivateDataSourceKey( + entry.bindingName || entry.name || "mongodb", + )}`; + + function registerRoute() { + const current = currentPrivateDataSource(entry); + const port = typeof current.port === "number" ? current.port : 27017; + const label = `MongoDB private data source "${current.name || current.bindingName || driverHostname}"`; + registerPrivateNodeNetRoute(driverHostname, port, { + label, + connect: () => source.connect(), + }); + } + + const extension = { + driverHost: driverHostname, + get connectionString() { + const current = currentPrivateDataSource(entry); + registerRoute(); + return buildMongoConnectionString(current, driverHostname); + }, + get uri() { + const current = currentPrivateDataSource(entry); + registerRoute(); + return buildMongoConnectionString(current, driverHostname); + }, + mongoClientOptions(options?: Record) { + registerRoute(); + return Object.freeze({ + directConnection: true, + serverSelectionTimeoutMS: 5000, + ...options, + }); + }, + }; + return Object.freeze( + Object.defineProperties( + extension, + Object.getOwnPropertyDescriptors(source), + ) as typeof source & typeof extension, + ); +} diff --git a/packages/functions-compiler/src/private-data-sources/mysql.ts b/packages/functions-compiler/src/private-data-sources/mysql.ts new file mode 100644 index 000000000..10fc6f25a --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/mysql.ts @@ -0,0 +1,8 @@ +import { buildHyperdrivePrivateDataSource } from "./hyperdrive"; +import { privateDataSourceReference } from "./manifest"; + +export function mysql(name: unknown) { + return buildHyperdrivePrivateDataSource( + privateDataSourceReference(name, "mysql"), + ); +} diff --git a/packages/functions-compiler/src/private-data-sources/node-net-adapter.ts b/packages/functions-compiler/src/private-data-sources/node-net-adapter.ts new file mode 100644 index 000000000..69847b41f --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/node-net-adapter.ts @@ -0,0 +1,244 @@ +import { Buffer } from "node:buffer"; +import nodeNet from "node:net"; +import { Duplex } from "node:stream"; + +import { isCloudflareTcpSocket } from "./tcp"; + +interface NodeNetSocket extends Duplex { + connecting: boolean; + remoteAddress?: string; + remotePort?: number; + setNoDelay(noDelay?: boolean): NodeNetSocket; + setKeepAlive(enable?: boolean, initialDelay?: number): NodeNetSocket; + setTimeout(timeout: number, callback?: () => void): NodeNetSocket; +} + +interface PrivateNodeNetRoute { + label: string; + connect: () => unknown; +} + +const privateNodeNetRoutes = new Map(); + +function exactNodeBuffer(value: Uint8Array): Buffer { + const bytes = new Uint8Array(value.byteLength); + bytes.set(value); + return Buffer.from(bytes.buffer); +} + +function nodeNetRouteKey(hostname: string, port: number): string { + return `${hostname.toLowerCase()}:${port}`; +} + +export function registerPrivateNodeNetRoute(hostname: string, port: number, route: PrivateNodeNetRoute) { + privateNodeNetRoutes.set(nodeNetRouteKey(hostname, port), route); +} + +function parseNodeNetCreateConnectionAddress(args: unknown[]) { + const [first, second] = args; + if (first && typeof first === "object") { + const options = first as { host?: unknown; hostname?: unknown; port?: unknown; path?: unknown }; + if (typeof options.path === "string") return null; + const port = typeof options.port === "number" ? options.port : Number(options.port); + if (!Number.isFinite(port)) return null; + const hostname = + typeof options.host === "string" + ? options.host + : typeof options.hostname === "string" + ? options.hostname + : "localhost"; + return { hostname, port }; + } + + if (typeof first === "number") { + const hostname = typeof second === "string" ? second : "localhost"; + return { hostname, port: first }; + } + + return null; +} + +function parseNodeNetConnectCallback(args: unknown[]): (() => void) | undefined { + const callback = args.find((arg) => typeof arg === "function"); + return callback as (() => void) | undefined; +} + +export function installPrivateNodeNetAdapter() { + const netModule = nodeNet as typeof nodeNet & { + __base44PrivateDataSourceAdapterInstalled?: boolean; + }; + if (netModule.__base44PrivateDataSourceAdapterInstalled) return; + + const originalCreateConnection = netModule.createConnection.bind(netModule); + const createConnection = (...args: unknown[]) => { + const address = parseNodeNetCreateConnectionAddress(args); + const route = address + ? privateNodeNetRoutes.get(nodeNetRouteKey(address.hostname, address.port)) + : undefined; + if (!route) { + return originalCreateConnection(...(args as Parameters)); + } + + const socket = buildNodeNetSocket(route.connect(), route.label); + const callback = parseNodeNetConnectCallback(args); + if (callback) socket.once("connect", callback); + return socket; + }; + + netModule.createConnection = createConnection as typeof nodeNet.createConnection; + netModule.connect = createConnection as typeof nodeNet.connect; + netModule.__base44PrivateDataSourceAdapterInstalled = true; +} + +export function buildNodeNetSocket( + socket: unknown, + label: string, + options: { deferConnectEvent?: boolean } = {}, +): NodeNetSocket { + if (!isCloudflareTcpSocket(socket)) { + throw new Error(`${label} did not return a readable/writable TCP socket`); + } + const cloudflareSocket = socket; + + let reader: ReadableStreamDefaultReader | null = null; + let writer: WritableStreamDefaultWriter | null = null; + let closed = false; + let connectEmitted = false; + let readableEnded = false; + let timeout: ReturnType | null = null; + let writeChain = Promise.resolve(); + + function clearTimer() { + if (!timeout) return; + clearTimeout(timeout); + timeout = null; + } + + function releaseLocks() { + try { + reader?.releaseLock(); + } catch { + // Ignore release errors when the stream is already closed. + } + reader = null; + try { + writer?.releaseLock(); + } catch { + // Ignore release errors when the stream is already closed. + } + writer = null; + } + + function endReadable() { + if (readableEnded) return; + readableEnded = true; + stream.push(null); + } + + const stream = new Duplex({ + read() { + // Data is pushed from the Cloudflare readable stream below. + }, + write(chunk, _encoding, callback) { + const bytes = chunk instanceof Uint8Array ? chunk : Buffer.from(chunk); + writeChain = writeChain + .then(async () => { + if (closed) throw new Error(`${label} connection is closed`); + const activeWriter = writer ?? cloudflareSocket.writable.getWriter(); + writer = activeWriter; + await activeWriter.write(bytes); + }) + .then( + () => callback(), + (error) => callback(error instanceof Error ? error : new Error(String(error))), + ); + }, + final(callback) { + writeChain + .then(() => { + try { + cloudflareSocket.close?.(); + } finally { + callback(); + } + }) + .catch((error) => callback(error instanceof Error ? error : new Error(String(error)))); + }, + destroy(error, callback) { + closed = true; + clearTimer(); + try { + cloudflareSocket.close?.(); + } catch { + // The stream is already being destroyed; surface the original error below. + } + releaseLocks(); + callback(error); + }, + }) as NodeNetSocket; + + stream.connecting = true; + stream.setNoDelay = () => stream; + stream.setKeepAlive = () => stream; + stream.setTimeout = (duration, callback) => { + clearTimer(); + if (duration > 0) { + timeout = setTimeout(() => { + callback?.(); + stream.emit("timeout"); + }, duration); + } + return stream; + }; + + async function readLoop() { + const activeReader = reader ?? cloudflareSocket.readable.getReader(); + reader = activeReader; + try { + while (!closed) { + const { value, done } = await activeReader.read(); + if (done) break; + if (value) stream.push(exactNodeBuffer(value)); + } + endReadable(); + releaseLocks(); + } catch (error) { + if (!closed) stream.destroy(error instanceof Error ? error : new Error(String(error))); + } + } + + function markConnected(info?: unknown) { + if (closed || connectEmitted) return; + connectEmitted = true; + const socketInfo = info as { remoteAddress?: unknown; remotePort?: unknown } | null; + if (typeof socketInfo?.remoteAddress === "string") stream.remoteAddress = socketInfo.remoteAddress; + if (typeof socketInfo?.remotePort === "number") stream.remotePort = socketInfo.remotePort; + stream.connecting = false; + stream.emit("connect"); + readLoop(); + } + + function scheduleConnected(info?: unknown) { + if (options.deferConnectEvent) { + setTimeout(() => markConnected(info), 0); + } else { + markConnected(info); + } + } + + if (cloudflareSocket.opened) { + cloudflareSocket.opened.then( + (info) => scheduleConnected(info), + (error) => stream.destroy(error instanceof Error ? error : new Error(String(error))), + ); + } else { + queueMicrotask(() => scheduleConnected()); + } + cloudflareSocket.closed?.finally(() => { + closed = true; + clearTimer(); + endReadable(); + releaseLocks(); + }).catch((error) => stream.destroy(error instanceof Error ? error : new Error(String(error)))); + return stream; +} diff --git a/packages/functions-compiler/src/private-data-sources/postgres.ts b/packages/functions-compiler/src/private-data-sources/postgres.ts new file mode 100644 index 000000000..1e0e76d73 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/postgres.ts @@ -0,0 +1,8 @@ +import { buildHyperdrivePrivateDataSource } from "./hyperdrive"; +import { privateDataSourceReference } from "./manifest"; + +export function postgres(name: unknown) { + return buildHyperdrivePrivateDataSource( + privateDataSourceReference(name, "postgres"), + ); +} diff --git a/packages/functions-compiler/src/private-data-sources/redis-auth.ts b/packages/functions-compiler/src/private-data-sources/redis-auth.ts new file mode 100644 index 000000000..030f1dcfb --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/redis-auth.ts @@ -0,0 +1,83 @@ +import type { CloudflareTcpSocket } from "./types"; + +const MAX_AUTH_RESPONSE_BYTES = 4096; + +function isCloudflareTcpSocket(socket: unknown): socket is CloudflareTcpSocket { + const candidate = socket as Partial | null; + return ( + candidate !== null && + typeof candidate === "object" && + !!candidate.readable && + typeof candidate.readable.getReader === "function" && + !!candidate.writable && + typeof candidate.writable.getWriter === "function" + ); +} + +function encodeRespArray(values: string[]): Uint8Array { + const encoder = new TextEncoder(); + const chunks = [encoder.encode(`*${values.length}\r\n`)]; + for (const value of values) { + const bytes = encoder.encode(value); + chunks.push(encoder.encode(`$${bytes.byteLength}\r\n`), bytes, encoder.encode("\r\n")); + } + const result = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} + +async function readResponseLine(reader: ReadableStreamDefaultReader): Promise { + const bytes: number[] = []; + while (bytes.length <= MAX_AUTH_RESPONSE_BYTES) { + const { value, done } = await reader.read(); + if (done) break; + for (const byte of value ?? []) { + bytes.push(byte); + const length = bytes.length; + if (length >= 2 && bytes[length - 2] === 13 && bytes[length - 1] === 10) { + return new TextDecoder().decode(new Uint8Array(bytes.slice(0, -2))); + } + if (length > MAX_AUTH_RESPONSE_BYTES) break; + } + } + throw new Error("Redis authentication failed: invalid or missing server response"); +} + +async function authenticateRedisSocket( + socket: unknown, + username: string | undefined, + password: string, +): Promise { + if (!isCloudflareTcpSocket(socket)) { + throw new Error("Redis private data source did not return a readable/writable TCP socket"); + } + const reader = socket.readable.getReader(); + const writer = socket.writable.getWriter(); + try { + await socket.opened; + const auth = username ? ["AUTH", username, password] : ["AUTH", password]; + await writer.write(encodeRespArray(auth)); + const response = await readResponseLine(reader); + if (response === "+OK") return socket; + const message = response.startsWith("-") ? response.slice(1) : `unexpected response ${JSON.stringify(response)}`; + throw new Error(`Redis authentication failed: ${message}`); + } catch (error) { + socket.close?.(); + throw error; + } finally { + reader.releaseLock(); + writer.releaseLock(); + } +} + +export function authenticateRedisSocketIfNeeded( + socket: unknown, + username: string | undefined, + password: string | undefined, +): unknown | Promise { + return password ? authenticateRedisSocket(socket, username, password) : socket; +} diff --git a/packages/functions-compiler/src/private-data-sources/redis.ts b/packages/functions-compiler/src/private-data-sources/redis.ts new file mode 100644 index 000000000..85cc027fa --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/redis.ts @@ -0,0 +1,47 @@ +import { buildIoredisConnectorFactory } from "./ioredis-adapter"; +import { privateDataSourceReference } from "./manifest"; +import { authenticateRedisSocketIfNeeded } from "./redis-auth"; +import { buildTcpPrivateDataSource } from "./tcp"; + +export function redis(name: unknown) { + const source = buildTcpPrivateDataSource( + privateDataSourceReference(name, "redis"), + ) as ReturnType & { + connect: (options?: unknown) => unknown; + }; + const { connect: _connect, ...descriptors } = + Object.getOwnPropertyDescriptors(source); + const extension = { + connect(options?: unknown) { + return authenticateRedisSocketIfNeeded( + source.connect(options), + source.username, + source.password, + ); + }, + ioredisConnector() { + return buildIoredisConnectorFactory(() => + authenticateRedisSocketIfNeeded( + source.connect(), + source.username, + source.password, + ), + ); + }, + ioredisOptions() { + return Object.freeze({ + Connector: buildIoredisConnectorFactory(() => source.connect()), + ...(source.password && source.username + ? { username: source.username } + : {}), + ...(source.password ? { password: source.password } : {}), + }); + }, + }; + return Object.freeze( + Object.defineProperties( + extension, + descriptors, + ) as typeof source & typeof extension, + ); +} diff --git a/packages/functions-compiler/src/private-data-sources/runtime-environment.ts b/packages/functions-compiler/src/private-data-sources/runtime-environment.ts new file mode 100644 index 000000000..cd4c7f27a --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/runtime-environment.ts @@ -0,0 +1,4 @@ +export { + currentWorkerRuntimeContext, + workerEnvironment, +} from "base44:internal/runtime-context"; diff --git a/packages/functions-compiler/src/private-data-sources/runtime-manifest-store.ts b/packages/functions-compiler/src/private-data-sources/runtime-manifest-store.ts new file mode 100644 index 000000000..854a4620a --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/runtime-manifest-store.ts @@ -0,0 +1,32 @@ +// Private, single-instance channel for the handshake-delivered PDS manifest. +// +// The manifest carries plaintext VPC/DB credentials, so it must NOT be +// reachable by user code. It lives here in module-private state: the activation +// shim writes it (via `setRuntimeManifest`) and `manifest.ts` reads it (via +// `getRuntimeManifest`) — both resolve to THIS one module instance in the final +// bundle, so the value never touches `globalThis`, `process.env`, or any +// user-importable surface. User function code cannot import this module, via +// two independent gates: (1) the bundler's private-data-sources virtual plugin +// resolves the `base44:private-data-sources/runtime-manifest-store` specifier +// ONLY when the importer is EXACTLY the injected activation shim's bundle-root +// key (not a suffix/segment match — else a nested `x/__base44_activation.mjs` +// could pose as it); (2) `assertNoReservedFilenames` rejects that basename at +// any depth in user files. The relative `./runtime-manifest-store` path only +// resolves inside the plugin's namespace (the adapters + manifest.ts), never +// from a user file. +// +// Single instance: both importers resolve to the SAME (namespace, path) pair, +// so esbuild emits one module. The runtime-secrets e2e proves this end to end — +// if the two ever diverged into separate instances, the shim would write one +// and manifest.ts would read the other (empty), and the delivered manifest +// would resolve as "not bound". + +let runtimeManifestRaw: string | undefined; + +export function setRuntimeManifest(raw: string | undefined): void { + runtimeManifestRaw = raw; +} + +export function getRuntimeManifest(): string | undefined { + return runtimeManifestRaw; +} diff --git a/packages/functions-compiler/src/private-data-sources/sqlserver.ts b/packages/functions-compiler/src/private-data-sources/sqlserver.ts new file mode 100644 index 000000000..674b9ca99 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/sqlserver.ts @@ -0,0 +1,22 @@ +import { buildTediousConnectorFactory } from "./tedious-adapter"; +import { privateDataSourceReference } from "./manifest"; +import { buildTcpPrivateDataSource } from "./tcp"; + +export function sqlserver(name: unknown) { + const source = buildTcpPrivateDataSource( + privateDataSourceReference(name, "sqlserver"), + ) as ReturnType & { + connect: (options?: unknown) => unknown; + }; + const extension = { + tediousConnector() { + return buildTediousConnectorFactory(() => source.connect()); + }, + }; + return Object.freeze( + Object.defineProperties( + extension, + Object.getOwnPropertyDescriptors(source), + ) as typeof source & typeof extension, + ); +} diff --git a/packages/functions-compiler/src/private-data-sources/tcp.ts b/packages/functions-compiler/src/private-data-sources/tcp.ts new file mode 100644 index 000000000..e461d9964 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/tcp.ts @@ -0,0 +1,86 @@ +import { + currentPrivateDataSource, + privateDataSourceReference, + privateDataSourceBinding, +} from "./manifest"; +import type { + CloudflareTcpSocket, + PrivateDataSourceManifestEntry, + TcpPrivateDataSourceBinding, +} from "./types"; + +function hasConnect(binding: unknown): binding is TcpPrivateDataSourceBinding { + return ( + binding !== null && + typeof binding === "object" && + typeof (binding as { connect?: unknown }).connect === "function" + ); +} + +export function buildTcpPrivateDataSource(entry: PrivateDataSourceManifestEntry) { + return Object.freeze({ + name: entry.name, + type: entry.type, + get bindingName() { + return currentPrivateDataSource(entry).bindingName; + }, + get host() { + return currentPrivateDataSource(entry).host; + }, + get port() { + return currentPrivateDataSource(entry).port; + }, + get database() { + return currentPrivateDataSource(entry).database; + }, + get username() { + return currentPrivateDataSource(entry).username; + }, + get user() { + return currentPrivateDataSource(entry).username; + }, + get password() { + return currentPrivateDataSource(entry).password; + }, + connect(options?: unknown) { + const current = currentPrivateDataSource(entry); + const binding = privateDataSourceBinding(current); + if (!hasConnect(binding)) { + throw new Error( + `Private data source "${current.name}" does not expose connect()`, + ); + } + const address = + typeof current.host === "string" && typeof current.port === "number" + ? { hostname: current.host, port: current.port } + : null; + if (!address) { + throw new Error( + `Private data source "${current.name}" has no TCP address`, + ); + } + return binding.connect(address, options); + }, + get binding() { + return privateDataSourceBinding(entry); + }, + }); +} + +export function isCloudflareTcpSocket(socket: unknown): socket is CloudflareTcpSocket { + const socketObject = socket as Partial | null; + return ( + socketObject !== null && + typeof socketObject === "object" && + !!socketObject.readable && + typeof socketObject.readable.getReader === "function" && + !!socketObject.writable && + typeof socketObject.writable.getWriter === "function" + ); +} + +export function tcp(name: unknown, expectedType: string) { + return buildTcpPrivateDataSource( + privateDataSourceReference(name, expectedType), + ); +} diff --git a/packages/functions-compiler/src/private-data-sources/tedious-adapter.ts b/packages/functions-compiler/src/private-data-sources/tedious-adapter.ts new file mode 100644 index 000000000..ff630e1f3 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/tedious-adapter.ts @@ -0,0 +1,67 @@ +import { isCloudflareTcpSocket } from "./tcp"; +import { buildNodeNetSocket } from "./node-net-adapter"; + +function abortError(label: string, signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error(`${label} connection aborted`); +} + +async function waitForCloudflareTcpSocketOpen( + socket: unknown, + label: string, + signal?: AbortSignal, +) { + if (!isCloudflareTcpSocket(socket)) { + throw new Error(`${label} did not return a readable/writable TCP socket`); + } + if (!socket.opened) return; + + if (!signal) { + await socket.opened; + return; + } + + if (signal.aborted) { + try { + socket.close?.(); + } catch { + // Best-effort cleanup; surface the abort reason below. + } + throw abortError(label, signal); + } + + let onAbort: (() => void) | null = null; + try { + await Promise.race([ + socket.opened, + new Promise((_, reject) => { + onAbort = () => reject(abortError(label, signal)); + signal.addEventListener("abort", onAbort, { once: true }); + }), + ]); + } catch (error) { + try { + socket.close?.(); + } catch { + // Preserve the original connection/abort error. + } + throw error; + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +function buildTediousNetSocket(socket: unknown) { + return buildNodeNetSocket(socket, "SQL Server private data source", { + deferConnectEvent: true, + }); +} + +export function buildTediousConnectorFactory(connect: () => unknown) { + return async (_connectOptions?: unknown, _lookup?: unknown, signal?: AbortSignal) => { + const socket = connect(); + await waitForCloudflareTcpSocketOpen(socket, "SQL Server private data source", signal); + return buildTediousNetSocket(socket); + }; +} diff --git a/packages/functions-compiler/src/private-data-sources/types.ts b/packages/functions-compiler/src/private-data-sources/types.ts new file mode 100644 index 000000000..2277b1425 --- /dev/null +++ b/packages/functions-compiler/src/private-data-sources/types.ts @@ -0,0 +1,29 @@ +export interface PrivateDataSourceManifestEntry { + name?: string; + type?: string; + bindingName?: string; + bindingKind?: string; + networkScope?: string; + baseUrl?: string; + host?: string; + port?: number; + database?: string; + username?: string; + password?: string; +} + +export interface FetchPrivateDataSourceBinding { + fetch: (resource: RequestInfo | URL, init?: RequestInit) => Response | Promise; +} + +export interface TcpPrivateDataSourceBinding { + connect: (address: unknown, options?: unknown) => unknown; +} + +export interface CloudflareTcpSocket { + readable: ReadableStream; + writable: WritableStream; + opened?: Promise; + close?: () => void; + closed?: Promise; +} diff --git a/packages/functions-compiler/src/runtime-context.ts b/packages/functions-compiler/src/runtime-context.ts new file mode 100644 index 000000000..3626e3c4a --- /dev/null +++ b/packages/functions-compiler/src/runtime-context.ts @@ -0,0 +1,29 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export interface WorkerRuntimeContext { + env: "prod" | "preview"; + fn?: string; + secrets: Record; + workerEnv: Record; + waitUntil(promise: Promise): void; + [key: string]: unknown; +} + +const runtimeContext = new AsyncLocalStorage(); + +export function currentWorkerRuntimeContext(): + | WorkerRuntimeContext + | undefined { + return runtimeContext.getStore(); +} + +export function workerEnvironment(): Record { + return currentWorkerRuntimeContext()?.workerEnv ?? {}; +} + +export function runWithWorkerEnvironment( + context: WorkerRuntimeContext, + callback: () => T, +): T { + return runtimeContext.run(context, callback); +} diff --git a/packages/functions-compiler/src/runtime/index.ts b/packages/functions-compiler/src/runtime/index.ts new file mode 100644 index 000000000..9800a1626 --- /dev/null +++ b/packages/functions-compiler/src/runtime/index.ts @@ -0,0 +1,30 @@ +// Platform module served for `import { ... } from "base44:runtime"`. +// +// The generated Worker entry owns the per-request AsyncLocalStorage store +// (module-scoped there), so this module reaches request state through the +// `globalThis.Base44` bridge the entry prelude installs — same pattern as the +// private-data-sources modules reading their manifest. + +interface Base44Bridge { + waitUntil(promise: Promise): void; + secrets: { get(name: string): string | undefined }; +} + +function bridge(): Base44Bridge { + return (globalThis as { Base44?: Base44Bridge }).Base44 as Base44Bridge; +} + +/** Extend this invocation until `promise` settles — background work after the + * Response returns. Rides ctx.waitUntil; best-effort, not durable. Returns the + * same promise so it composes. */ +export function waitUntil(promise: Promise): Promise { + bridge().waitUntil(promise); + return promise; +} + +/** App secrets, read from the Worker env binding of the current request. */ +export const secrets: { get(name: string): string | undefined } = { + get(name: string): string | undefined { + return bridge().secrets.get(name); + }, +}; diff --git a/packages/functions-compiler/src/shim/activation.ts b/packages/functions-compiler/src/shim/activation.ts new file mode 100644 index 000000000..53afd956a --- /dev/null +++ b/packages/functions-compiler/src/shim/activation.ts @@ -0,0 +1,408 @@ +// Runtime-secrets activation: the platform delivers app secrets into isolate +// memory via an encrypted handshake instead of baking them into the script as +// secret_text bindings. Injected into a bundle ONLY when the backend deploys it +// in runtime-secrets mode (`runtimeSecrets` bundle flag) — old-mode bundles are +// byte-identical to before. +// +// Protocol (keep in sync with backend/app/cloudflare_functions/activation_handshake.py): +// - Cold isolate (nothing installed yet): respond 503 with +// `X-Base44-Needs-Activation: b64url(raw P-256 pubkey)` BEFORE importing any +// user code, so the backend can safely re-send the request. +// - The re-sent request carries the envelope in `Base44-Runtime-Secrets` +// (header channel ONLY — the key always rides the request that will run, +// so whichever isolate receives it is the right one by construction): +// b64url(backendPub(65) || k(1) || k*[nonce_i(12) || wrap_i(48)]) +// wrap_i is the app DATA KEY sealed to isolate key i (KEK_i = ECDH P-256 + +// HKDF-SHA256(info="base44-runtime-secrets-v1")), AES-256-GCM with +// AAD = BASE44_APP_ID. The envelope is CONSTANT SIZE: it carries a 32-byte +// key, never the secrets, so it cannot outgrow CF's header budget. +// Multiple recipients exist because the backend seals to every isolate key +// it saw while retrying: a re-send can land on a different cold isolate +// (no WfP affinity), and covering them all is what makes the loop converge +// instead of ping-ponging between two cold isolates. This isolate cannot +// know its position, so it derives its KEK once and tries every wrap block +// until one authenticates. +// - The SECRETS themselves are baked into this script as an encrypted blob, +// split across `BASE44_SECRETS_BLOB_` bindings (CF caps one value at +// 5 KB). We concatenate them in index order, then decrypt with the data +// key: keyId(1) || nonce(12) || AES-256-GCM(deflated JSON), same AAD. +// Cloudflare stores only ciphertext it has no key for. +// - Decrypted+inflated env installs into `process.env` — `Deno.env` reads it +// live (@deno/shim-deno backs env with process.env) and npm SDKs read it +// directly. +// +// The keypair exists only in this isolate's memory; nothing here is persisted. +// +// The platform scrubs client copies of these protocol headers on the way in +// (runtime_api.py's inbound scrub, next to the existing +// base44-dispatcher-authorization scrub). With the header channel alone this +// is hygiene rather than a load-bearing defense — a client-supplied +// `Base44-Runtime-Secrets` is ignored by a warm isolate (installOnce +// short-circuits) and merely re-signals on a cold one, and installing anything +// still requires ECDH + AES-GCM with the app-id AAD. Keep any new protocol +// header on that scrub list anyway. + +// A static module import, like node:async_hooks in the deno shim: external in +// the bundle, provided by workerd's nodejs_compat. Import bindings can't be +// reassigned by user code, so unlike the globals below this needs no capture. +import { inflateSync } from "node:zlib"; + +// The manifest goes into the private store shared with manifest.ts (one bundle +// instance; see runtime-manifest-store). Imported via the virtual specifier so +// build-shim leaves it external and the FINAL app compile dedupes it with +// manifest.ts's `./runtime-manifest-store` — NEVER a user-reachable global. +import { setRuntimeManifest } from "base44:private-data-sources/runtime-manifest-store"; + +export const NEEDS_ACTIVATION_HEADER = "X-Base44-Needs-Activation"; +export const RUNTIME_SECRETS_HEADER = "Base44-Runtime-Secrets"; +const HKDF_INFO = "base44-runtime-secrets-v1"; +const BACKEND_PUBKEY_LEN = 65; // uncompressed P-256 point +const NONCE_LEN = 12; +const DATA_KEY_LEN = 32; +// nonce_i(12) + AES-GCM(dataKey 32 + tag 16) +const WRAP_BLOCK_LEN = NONCE_LEN + DATA_KEY_LEN + 16; +// The at-rest blob, split across bindings under CF's 5 KB per-value limit and +// reassembled here in index order. Keep in sync with +// SECRETS_BLOB_BINDING_PREFIX in activation_handshake.py. +const BLOB_BINDING_PREFIX = "BASE44_SECRETS_BLOB"; +const BLOB_KEY_ID_LEN = 1; +// The private-data-sources manifest carries plaintext VPC DB credentials and +// selects which binding a PDS call reaches. It must reach neither process.env +// (user code could overwrite it and forge the manifest) nor any user-reachable +// global. It goes only into the private store above, which manifest.ts reads. +const MANIFEST_KEY = "BASE44_PRIVATE_DATA_SOURCES"; + +// Primordials captured at MODULE LOAD, which happens before any user code: +// this module is a static import of the generated entry, while the user module +// is imported lazily inside fetch. Every global below is mutable and shares the +// isolate's realm with user code. +// +// For install() these are belt-and-braces — it only ever runs before user code +// (see installOnce), and never re-installing is the primary defense. For the +// REQUEST/RESPONSE boundary below they are the primary defense, because that +// code runs on warm requests, after the handler has had a chance to patch +// prototypes. A handler that makes the signal strip miss gets the backend to +// seal the app data key to a keypair the handler generated, and can then read +// the envelope off the replay and decrypt the blob bindings — which would hand +// user code the private-data-source manifest this shim keeps out of its reach. +// So: no global may be resolved at call time on either path. +const _subtle = crypto.subtle; +const _subtleGenerateKey = _subtle.generateKey.bind(_subtle); +const _subtleExportKey = _subtle.exportKey.bind(_subtle); +const _subtleImportKey = _subtle.importKey.bind(_subtle); +const _subtleDeriveBits = _subtle.deriveBits.bind(_subtle); +const _subtleDecrypt = _subtle.decrypt.bind(_subtle); +const _JSONparse = JSON.parse; +const _TextDecoder = TextDecoder; +const _TextEncoder = TextEncoder; +// The PROTOTYPE METHODS too, not just the constructors: `new TextDecoder().decode(x)` +// resolves `decode` on the prototype at call time, so patching +// TextDecoder.prototype.decode would still intercept the decrypted plaintext. +const _decodeUtf8 = TextDecoder.prototype.decode; +const _encodeUtf8 = TextEncoder.prototype.encode; +const _Uint8Array = Uint8Array; +const _atob = atob; +const _btoa = btoa; +const _DOMException = DOMException; +const _ObjectEntries = Object.entries; +// The request/response boundary. Constructors, prototype methods AND the +// accessors: `response.headers` resolves a getter on Response.prototype, so +// patching that getter alone would be enough to hide a forged signal. +const _Headers = Headers; +const _Request = Request; +const _Response = Response; +const _headersGet = Headers.prototype.get; +const _headersAppend = Headers.prototype.append; +const _headersEntries = Headers.prototype.entries; +// Header iteration without the array/iterator protocols user code can patch: +// drive next() by captured reference and index step.value positionally. +const _iterNext = Object.getPrototypeOf(new Headers().entries()).next; +// Walk the chain: workerd puts `body` on Body.prototype, not on +// Request/Response.prototype, so a single getOwnPropertyDescriptor misses it. +function accessor(proto: object, name: string): ((this: unknown) => T) | undefined { + for (let o: object | null = proto; o; o = Object.getPrototypeOf(o)) { + const d = Object.getOwnPropertyDescriptor(o, name); + if (d?.get) return d.get as (this: unknown) => T; + } + return undefined; +} +const _reqHeaders = accessor(Request.prototype, "headers")!; +const _resHeaders = accessor(Response.prototype, "headers")!; +const _resStatus = accessor(Response.prototype, "status")!; +const _resStatusText = accessor(Response.prototype, "statusText")!; +const _resBody = accessor(Response.prototype, "body")!; +// workerd extension; null on a response that carries no socket. +const _resWebSocket = accessor(Response.prototype, "webSocket"); +// Lowercase: Headers.entries() yields lowercased names, and comparing them +// must not go through a patchable String.prototype.toLowerCase. +const NEEDS_ACTIVATION_HEADER_LC = "x-base44-needs-activation"; +const RUNTIME_SECRETS_HEADER_LC = "base44-runtime-secrets"; + +/** A copy of `src` minus `dropLowercased`, built only from captured references. */ +function headersWithout(src: Headers, dropLowercased: string): Headers { + const out = new _Headers(); + const it = _headersEntries.call(src); + for (;;) { + const step = _iterNext.call(it); + if (step.done) break; + // Positional, not destructured: array destructuring reads Symbol.iterator. + if (step.value[0] !== dropLowercased) _headersAppend.call(out, step.value[0], step.value[1]); + } + return out; +} + +let keyPairPromise: Promise | null = null; +// Install-once latch (see installOnce): no clock, so a patched Date.now +// cannot make stale credentials look fresh or suppress a first activation. +let installed = false; +let pendingInstall: Promise | null = null; +let installChain: Promise = Promise.resolve(); + +function getKeyPair(): Promise { + // Non-extractable private key: deriveBits only, never leaves the isolate. + // Clear a REJECTED promise: `??=` would cache the rejection, and then this + // isolate could never signal again — needsActivationResponse() would reject + // out of ensureActivation (including from its catch blocks), so every later + // request 500s instead of 503-signalling and the backend never sees a + // handshake to retry. The assignment is synchronous and this handler runs + // later, so clearing here cannot race the `??=`. + keyPairPromise ??= _subtleGenerateKey( + { name: "ECDH", namedCurve: "P-256" }, + false, + ["deriveBits"], + ).catch((e) => { + keyPairPromise = null; + throw e; + }); + return keyPairPromise; +} + +async function needsActivationResponse(): Promise { + const { publicKey } = await getKeyPair(); + const raw = await _subtleExportKey("raw", publicKey); + return new Response(null, { + status: 503, + headers: { + [NEEDS_ACTIVATION_HEADER]: toBase64Url(new _Uint8Array(raw)), + "Cache-Control": "no-store", + }, + }); +} + +async function install(envelopeB64: string, env: unknown): Promise { + const envelope = fromBase64Url(envelopeB64); + const backendPub = envelope.slice(0, BACKEND_PUBKEY_LEN); + const recipientCount = envelope[BACKEND_PUBKEY_LEN]; + + const { privateKey } = await getKeyPair(); + const peer = await _subtleImportKey( + "raw", + backendPub, + { name: "ECDH", namedCurve: "P-256" }, + false, + [], + ); + const shared = await _subtleDeriveBits({ name: "ECDH", public: peer }, privateKey, 256); + const hkdfKey = await _subtleImportKey("raw", shared, "HKDF", false, ["deriveBits"]); + const keyBits = await _subtleDeriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: new _Uint8Array(0), + info: _encodeUtf8.call(new _TextEncoder(), HKDF_INFO), + }, + hkdfKey, + 256, + ); + const kek = await _subtleImportKey("raw", keyBits, "AES-GCM", false, ["decrypt"]); + + const appId = (env as Record | undefined)?.BASE44_APP_ID; + const aad = _encodeUtf8.call(new _TextEncoder(), typeof appId === "string" ? appId : ""); + + // The KEK (above) is fixed by OUR private key; only the wrap block that was + // encrypted to this isolate's key authenticates under it. Try each — the + // envelope carries one block per isolate the backend saw across this + // request's retries, so this isolate can't know its position and must + // never assume a count bound. + let dataKeyBytes: ArrayBuffer | null = null; + for (let i = 0; i < recipientCount && dataKeyBytes === null; i++) { + const blockStart = BACKEND_PUBKEY_LEN + 1 + i * WRAP_BLOCK_LEN; + const wrapNonce = envelope.slice(blockStart, blockStart + NONCE_LEN); + const wrapped = envelope.slice(blockStart + NONCE_LEN, blockStart + WRAP_BLOCK_LEN); + try { + dataKeyBytes = await _subtleDecrypt( + { name: "AES-GCM", iv: wrapNonce, additionalData: aad }, + kek, + wrapped, + ); + } catch { + // Not our block (GCM auth failure) — try the next. + } + } + if (dataKeyBytes === null) { + // No block was sealed to this isolate's key (or wrong app AAD): throw so + // ensureActivation re-signals with OUR key and the backend adds us to the + // recipient set. + throw new _DOMException("no wrap block for this isolate", "OperationError"); + } + + // The data key opens the blob this script was DEPLOYED with — read it from + // the bindings and reassemble. A gap ends the sequence: a partially written + // set decodes to truncated ciphertext and AES-GCM fails closed below. + const bindings = (env ?? {}) as Record; + let blobB64 = ""; + for (let i = 0; ; i++) { + const part = bindings[`${BLOB_BINDING_PREFIX}_${i}`]; + if (typeof part !== "string") break; + blobB64 += part; + } + if (blobB64 === "") { + // Nothing to open. A runtime-secrets script always carries the blob, so + // this is a deploy-side defect rather than a wrong-isolate envelope — but + // treat it the same way: fail, and let the backend surface a non-billable + // 503 instead of running user code with an empty env. + throw new _DOMException("no secret blob bindings on this script", "OperationError"); + } + const blob = fromBase64Url(blobB64); + const blobNonce = blob.slice(BLOB_KEY_ID_LEN, BLOB_KEY_ID_LEN + NONCE_LEN); + const blobCiphertext = blob.slice(BLOB_KEY_ID_LEN + NONCE_LEN); + + const dataKey = await _subtleImportKey("raw", dataKeyBytes, "AES-GCM", false, ["decrypt"]); + const plaintext = await _subtleDecrypt( + { name: "AES-GCM", iv: blobNonce, additionalData: aad }, + dataKey, + blobCiphertext, + ); + + const payload: unknown = _JSONparse( + _decodeUtf8.call(new _TextDecoder(), inflateSync(new _Uint8Array(plaintext))), + ); + const secrets = + payload && typeof payload === "object" && (payload as { secrets?: unknown }).secrets + ? ((payload as { secrets: Record }).secrets) + : {}; + // The manifest goes to the private store (read only by manifest.ts), NOT + // process.env or a global — keep it off every user-reachable surface. + const manifestValue = secrets[MANIFEST_KEY]; + // `""`, never `undefined`, when the payload carries no manifest: manifest.ts + // treats any DEFINED value as handshake-delivered and returns []. `undefined` + // would fall through to the Worker BINDING — unfiltered and unpinned — so an + // app that removed its last data source could keep resolving a stale manifest + // and its plaintext credentials. + setRuntimeManifest(typeof manifestValue === "string" ? manifestValue : ""); + // No unset pass: this runs once per isolate, so there is never a previous + // payload to diff against. A deleted secret reaches the isolate by NOT being + // in this payload, and existing isolates are rolled by the generation-nonce + // version bump on secret change. + for (const [key, value] of _ObjectEntries(secrets)) { + if (key !== MANIFEST_KEY && typeof value === "string") process.env[key] = value; + } + installed = true; +} + +/** Install at most ONCE per isolate, and only before any user code has run. + * + * This is a security boundary, not an optimization. Every global `install()` + * touches is mutable and shares the isolate's realm with user code, and the + * language offers no way to make a later call immune: capturing JSON.parse + * invites patching TextDecoder.prototype.decode, capturing that invites + * patching Function.prototype.call, and so on without end. So we never install + * a second time. The first install is safe by construction — this module is a + * static import of the generated entry and the gate runs before the user module + * is imported — and after that the answer to "is a re-install safe?" is + * permanently "we don't do one". + * + * Rotation does not depend on re-installing: a secret change PUTs the + * BASE44_SECRETS_GENERATION nonce on the script (see on_secret_change), which + * bumps the Worker version, so Cloudflare rolls these isolates and the next one + * installs the new values on its own first request. + * + * Concurrent first activations still serialize through the chain so two + * in-flight envelopes can't interleave their writes, and the in-flight install + * is published as `pendingInstall` so a waiting request rides it instead of + * paying its own round trip. */ +function installOnce(envelopeB64: string, env: unknown): Promise { + if (installed) return Promise.resolve(); + const afterPrevious = installChain.then(undefined, () => {}); + const run = afterPrevious.then(() => (installed ? undefined : install(envelopeB64, env))); + installChain = run.then(undefined, () => {}); // a failed install must not wedge the chain + pendingInstall = run; + const clear = () => { + if (pendingInstall === run) pendingInstall = null; + }; + run.then(clear, clear); + return run; +} + +/** Gate a request on the activation handshake. Returns the needs-activation + * `Response` to emit immediately (no user code may run), or `null` to proceed. + * Call `withoutRuntimeSecretsHeader` on the request before user code sees it. */ +export async function ensureActivation(request: Request, env: unknown): Promise { + const header = _headersGet.call(_reqHeaders.call(request), RUNTIME_SECRETS_HEADER); + if (header) { + try { + await installOnce(header, env); + return null; + } catch (e) { + // Wrong isolate's envelope after a re-route, or a corrupt blob. Re-signal + // with OUR pubkey. Reason code only — never secret material. + console.error( + `Base44 runtime-secrets activation failed, re-signaling: ${(e as Error)?.name ?? "error"}`, + ); + return needsActivationResponse(); + } + } + if (installed) return null; + if (pendingInstall) { + // A concurrent request is mid-install — ride it instead of a needless round trip. + try { + await pendingInstall; + } catch { + // The installer re-signals on its own request; we signal for ours below. + } + if (installed) return null; + } + return needsActivationResponse(); +} + +/** Strip the encrypted envelope so user code can't capture, log, or replay it. + * Runs on warm requests too, so every operation is a captured reference. */ +export function withoutRuntimeSecretsHeader(request: Request): Request { + const raw = _reqHeaders.call(request); + if (_headersGet.call(raw, RUNTIME_SECRETS_HEADER) === null) return request; + return new _Request(request, { headers: headersWithout(raw, RUNTIME_SECRETS_HEADER_LC) }); +} + +/** Strip the activation signal from user-handler responses. The genuine signal + * is only ever emitted BEFORE user code runs, and this wraps the handler + * response only — so a copy reaching here is forged, and letting it through + * would make the backend re-execute a request whose side effects already ran + * AND seal the app data key to a keypair the handler chose. The header lookup + * therefore uses a captured Headers.prototype.get on the headers object read + * through a captured accessor: a patched `has`/`get`/`headers` cannot hide it. */ +export function withoutActivationSignal(response: Response): Response { + const raw = _resHeaders.call(response); + if (_headersGet.call(raw, NEEDS_ACTIVATION_HEADER) === null) return response; + return new _Response(_resBody.call(response), { + status: _resStatus.call(response), + statusText: _resStatusText.call(response), + headers: headersWithout(raw, NEEDS_ACTIVATION_HEADER_LC), + // Preserved or a 101 upgrade would fail to reconstruct. + webSocket: _resWebSocket ? _resWebSocket.call(response) : undefined, + } as ResponseInit); +} + +function toBase64Url(bytes: Uint8Array): string { + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return _btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function fromBase64Url(value: string): Uint8Array { + const b64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const bin = _atob(padded); + const bytes = new _Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} diff --git a/packages/functions-compiler/src/shim/actor.ts b/packages/functions-compiler/src/shim/actor.ts new file mode 100644 index 000000000..98683f63b --- /dev/null +++ b/packages/functions-compiler/src/shim/actor.ts @@ -0,0 +1,602 @@ +// @ts-nocheck — targets the CF Workers runtime, not Node; esbuild compiles it. +// Base class for Actors, injected into every deployed handler bundle. + +import { Server, routePartykitRequest, type Connection, type ConnectionContext } from "partyserver"; +import { createClient } from "npm:@base44/sdk@0.8.41"; +import { TickLoop } from "./tick-loop"; + +export { routePartykitRequest }; + +export type ActorConnectionIdentity = + | Readonly<{ type: "authenticated"; userId: string }> + | Readonly<{ type: "anonymous"; anonymousId: string }>; + +export interface Conn { + /** Unique per-connection id (one per socket/tab). Identifies a distinct + * client, so multiple tabs are separate connections. */ + id: string; + /** Identity verified by the generated Actor Worker before this room was + * resolved. Legacy proxied connections may not have one. */ + identity?: ActorConnectionIdentity; + send(data: Send): void; + reject(code: number, reason: string): void; +} + +// Sentinel id for a superseded socket under the non-hibernating InMemory manager +// (its close deletes by id). The onClose skip keys off the attachment flag instead. +const SUPERSEDED_PREFIX = "__superseded__:"; + +function buildConn(ws: Connection, identity?: ActorConnectionIdentity): Conn { + return { + id: ws.id, // partyserver's per-connection id (survives hibernation) + ...(identity ? { identity } : {}), + send(data: unknown) { + ws.send(JSON.stringify(data)); + }, + reject(code: number, reason: string) { + ws.close(code, reason); + }, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export abstract class Actor extends Server { + // WebSocket Hibernation: idle rooms are evicted from memory (no duration + // billing) with sockets kept open; onStart re-runs on wake to rehydrate. A + // ticking room stays resident (its setTimeout loop blocks hibernation), so this + // only affects non-ticking occupied rooms. Statics inherit, so a handler opts + // out with `static options = { hibernate: false }`. + static options = { hibernate: true }; + + // conn.id is client-chosen and partyserver silently REPLACES a same-id socket + // on accept, so a duplicate must be resolved before super.fetch(). Liveness + // (SDK pings every 1s) distinguishes a real conflict from a reconnect. + private lastSeen = new Map(); + private identities = new Map(); + private static readonly LIVE_MS = 3_000; + private static readonly IDENTITY_HEADER = "X-Base44-Actor-Identity"; + + // Captured at construction so `client` reads this DO's own env, not the shared + // globalThis.Base44 bridge (which the last-constructed DO in the isolate wins). + private _b44Env: Record; + private _b44Client: ReturnType | undefined; + + protected get client(): ReturnType { + return (this._b44Client ??= new Proxy( + createClient({ + appId: this._b44Env.BASE44_APP_ID as string, + serverUrl: this._b44Env.BASE44_API_URL as string, + // "prod" on the published script, "preview" on the draft — set at deploy. + functionsVersion: this._b44Env.BASE44_FUNCTIONS_VERSION as string, + }), + { + // The SDK bakes serviceToken into axios defaults at createClient time and + // its own asServiceRole getter throws without one, so that one property is + // answered by the lazy exchange below instead. + get: (target, prop) => + prop === "asServiceRole" + ? this._b44ServiceRolePath([]) + : Reflect.get(target, prop, target), + }, + )); + } + + // ── Service role ────────────────────────────────────────────────────── + // The token is fetched on the first privileged call by trading an assertion + // signed with the actor's private key, cached in memory, and re-fetched + // after expiry. Hibernation wiping the cache just means the next call + // exchanges again — nothing durable is stored. + private _b44Service: { client: ReturnType; expiresAtMs: number } | null = null; + private _b44ServicePending: Promise> | null = null; + + private async _b44SignServiceAssertion(privateKey: string, publicKey: string): Promise { + const b64url = (bin: string) => + btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + const now = Math.floor(Date.now() / 1000); + // Wire format pinned by backend/tests/unit/test_actor_service_token.py. + const claims = { + iss: "base44-actor", + aud: "base44-actor-service-token", + purpose: "actor-service-token", + v: 1, + iat: now, + exp: now + 60, + app_id: this._b44Env.BASE44_APP_ID, + actor_name: (this.constructor as { _b44ActorName?: string })._b44ActorName, + }; + const input = `${b64url(JSON.stringify({ alg: "EdDSA", typ: "JWT" }))}.${b64url(JSON.stringify(claims))}`; + // The env values are the raw keys as unpadded base64url — exactly the JWK + // d/x fields (WebCrypto refuses a raw import of an Ed25519 PRIVATE key). + const key = await crypto.subtle.importKey( + "jwk", + { kty: "OKP", crv: "Ed25519", d: privateKey, x: publicKey }, + { name: "Ed25519" }, + false, + ["sign"], + ); + const signature = new Uint8Array( + await crypto.subtle.sign({ name: "Ed25519" }, key, new TextEncoder().encode(input)), + ); + return `${input}.${b64url(String.fromCharCode(...signature))}`; + } + + private async _b44FetchServiceClient(): Promise> { + const privateKey = this._b44Env.BASE44_ACTOR_PRIVATE_KEY; + const publicKey = this._b44Env.BASE44_ACTOR_PUBLIC_KEY; + if (typeof privateKey !== "string" || !privateKey || typeof publicKey !== "string" || !publicKey) { + throw new Error( + "asServiceRole is unavailable on this actor: it predates direct connections. " + + "Delete the actor and deploy it again (room storage is not preserved).", + ); + } + const response = await fetch( + `${this._b44Env.BASE44_API_URL}/api/apps/${this._b44Env.BASE44_APP_ID}/actors/service-token`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ assertion: await this._b44SignServiceAssertion(privateKey, publicKey) }), + }, + ); + if (!response.ok) { + throw new Error(`Actor service token exchange failed (${response.status})`); + } + const { access_token, expires_in } = (await response.json()) as { + access_token: string; + expires_in: number; + }; + const client = createClient({ + appId: this._b44Env.BASE44_APP_ID as string, + serverUrl: this._b44Env.BASE44_API_URL as string, + functionsVersion: this._b44Env.BASE44_FUNCTIONS_VERSION as string, + serviceToken: access_token, + }); + // 60s skew so an in-flight call never presents a token expiring mid-request. + this._b44Service = { client, expiresAtMs: Date.now() + (expires_in - 60) * 1000 }; + return client; + } + + private _b44ServiceClient(): Promise> { + if (this._b44Service && Date.now() < this._b44Service.expiresAtMs) { + return Promise.resolve(this._b44Service.client); + } + // Single-flight: concurrent privileged calls share one exchange. + return (this._b44ServicePending ??= this._b44FetchServiceClient().finally(() => { + this._b44ServicePending = null; + })); + } + + // asServiceRole is a sync property chain ending in an async call, so proxies + // record the chain and the exchange awaits inside the final call — actor code + // reads identically to backend functions. + private _b44ServiceRolePath(path: PropertyKey[]): unknown { + const self = this; + return new Proxy(function () {}, { + get(_target, prop) { + // Not thenable, or `await client.asServiceRole` would descend forever. + if (typeof prop === "symbol" || prop === "then") return undefined; + return self._b44ServiceRolePath([...path, prop]); + }, + async apply(_target, _thisArg, args) { + const serviceClient = await self._b44ServiceClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let parent: any = serviceClient.asServiceRole; + for (const prop of path.slice(0, -1)) parent = parent[prop]; + return parent[path[path.length - 1]](...args); + }, + }); + } + + // Reserved platform keys — the private-data-sources manifest carries + // plaintext VPC DB credentials. Keep in sync with worker-entry.ts. + private static readonly RESERVED_SECRETS = new Set([ + "BASE44_ACTOR_PRIVATE_KEY", + "BASE44_ACTOR_PUBLIC_KEY", + "BASE44_ACTOR_SCRIPT_ID", + "BASE44_PRIVATE_DATA_SOURCES", + ]); + + constructor(ctx: DurableObjectState, env: Record) { + super(ctx, env); + this._b44Env = env; + // base44:runtime's secrets.get()/waitUntil read the globalThis.Base44 + // bridge. Functions get it from the generated Worker entry per request; a + // DO's env is fixed at construction, so install it once here — that covers + // every wake path, including an alarm after eviction. Shape/filters stay + // in sync with worker-entry.ts (its comment points back here). + (globalThis as { Base44?: object }).Base44 = Object.assign( + (globalThis as { Base44?: object }).Base44 ?? {}, + { + // DOs have no request-scoped waitUntil (they stay alive while + // connections exist) — absorb so shared function code doesn't crash. + waitUntil: (p: Promise) => { + Promise.resolve(p).catch(() => {}); + }, + secrets: { + // String(n) BEFORE the reserved check: a boxed String fails Set.has + // yet coerces back to the reserved key on the env lookup. + get: (n: unknown): string | undefined => { + const key = String(n); + if (Actor.RESERVED_SECRETS.has(key)) return undefined; + const v = env[key]; + return typeof v === "string" ? v : undefined; + }, + }, + }, + ); + } + + override async onStart(): Promise { + // Answer the SDK's 1s __ping at the edge so it can't keep a hibernatable room + // resident. hibernate:false actors fall back to the onMessage ping branch. + try { + this.ctx.setWebSocketAutoResponse( + new WebSocketRequestResponsePair( + JSON.stringify({ type: "__ping" }), + JSON.stringify({ type: "__pong" }), + ), + ); + } catch { + // Runtime without the API — pings fall through to onMessage. + } + await this.handleStart(); + await this.maintainTicker(); // pings no longer drive loop self-heal; re-check on wake + } + + private livenessMs(ws: Connection): number { + let autoResp = 0; + try { + autoResp = this.ctx.getWebSocketAutoResponseTimestamp(ws)?.getTime() ?? 0; + } catch { + // Runtime without the API — rely on lastSeen (its onMessage __ping fallback fires). + } + return Math.max(this.lastSeen.get(ws.id) ?? 0, autoResp); + } + + private static parseIdentity(value: string | null): ActorConnectionIdentity | undefined { + if (!value || value.length > 512) return undefined; + try { + const identity = JSON.parse(value) as Record; + if ( + identity.type === "authenticated" && + typeof identity.userId === "string" && + identity.userId.length > 0 && + identity.userId.length <= 128 + ) { + return Object.freeze({ type: "authenticated", userId: identity.userId }); + } + if ( + identity.type === "anonymous" && + typeof identity.anonymousId === "string" && + identity.anonymousId.length > 0 && + identity.anonymousId.length <= 128 + ) { + return Object.freeze({ type: "anonymous", anonymousId: identity.anonymousId }); + } + } catch { + return undefined; + } + return undefined; + } + + private identityFor(ws: Connection): ActorConnectionIdentity | undefined { + if (this.identities.has(ws.id)) return this.identities.get(ws.id); + let identity: ActorConnectionIdentity | undefined; + try { + identity = ( + ws.deserializeAttachment() as { __base44Identity?: ActorConnectionIdentity } | null + )?.__base44Identity; + if (identity) Object.freeze(identity); + } catch { /* hibernate:false connections have no attachment API */ } + this.identities.set(ws.id, identity); + return identity; + } + + // partyserver's fetch() catch re-throws "Network connection lost." when the + // client already went away; swallow it so it isn't logged as an exception. + override async fetch(request: Request): Promise { + if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") { + const pk = new URL(request.url).searchParams.get("_pk"); + if (pk) { + const existing = [...super.getConnections()].find((c) => c.id === pk); + if (existing !== undefined && Date.now() - this.livenessMs(existing) < Actor.LIVE_MS) { + return new Response("Connection id already in use", { status: 409 }); + } + // Reconnect of a stale holder. Flag it via the attachment (NOT the id: + // under hibernation the id is a getter-only property, so assigning throws) + // so onClose skips it. The best-effort id-rename still covers the InMemory + // manager, whose close deletes by id and would evict the new same-id socket. + if (existing !== undefined) { + try { + const att = (existing.deserializeAttachment() as Record | null) ?? {}; + existing.serializeAttachment({ ...att, __superseded: true }); + } catch { /* in-memory socket (hibernate:false): no attachment API — the id-rename below is its marker */ } + try { (existing as { id: string }).id = `${SUPERSEDED_PREFIX}${pk}`; } catch { /* hibernating: getter-only id */ } + try { existing.close(4408, "Superseded by reconnect"); } catch { /* already gone */ } + } + } + } + try { + return await super.fetch(request); + } catch (err) { + if (err instanceof Error && err.message.includes("Network connection lost")) { + return new Response("Connection lost", { status: 503 }); + } + throw err; + } + } + + override async onConnect( + ws: Connection, + ctx: ConnectionContext, + ): Promise { + const identity = Actor.parseIdentity( + ctx.request.headers.get(Actor.IDENTITY_HEADER), + ); + this.identities.set(ws.id, identity); + if (identity) { + try { + const attachment = ( + ws.deserializeAttachment() as Record | null + ) ?? {}; + ws.serializeAttachment({ ...attachment, __base44Identity: identity }); + } catch { + // hibernate:false connections keep identity in the in-memory map. + } + } + this.lastSeen.set(ws.id, Date.now()); + await this.handleConnect(buildConn(ws, identity)); + await this.maintainTicker(); + } + + override async onMessage(ws: Connection, raw: string): Promise { + this.lastSeen.set(ws.id, Date.now()); // any inbound traffic proves liveness + let msg: Message; + try { + msg = JSON.parse(raw) as Message; + } catch { + return; + } + // Ping fallback for hibernate:false actors (hibernation-accepted sockets are + // answered at the edge by onStart's auto-response and never reach here). + if ((msg as { type?: unknown })?.type === "__ping") { + ws.send(JSON.stringify({ type: "__pong" })); + await this.maintainTicker(); // restart a dead in-memory loop fast + return; + } + await this.handleMessage(buildConn(ws, this.identityFor(ws)), msg); + await this.maintainTicker(); // re-evaluate: the message may have changed shouldTick() + } + + override async onClose(ws: Connection): Promise { + // Superseded by its own reconnect → close silently. Hibernated sockets carry + // the flag on the attachment; the in-memory manager (hibernate:false) has no + // attachment API (deserializeAttachment throws) and keeps the id-rename marker. + let superseded = ws.id.startsWith(SUPERSEDED_PREFIX); + if (!superseded) { + try { + superseded = !!(ws.deserializeAttachment() as { __superseded?: boolean } | null)?.__superseded; + } catch { /* in-memory socket: no attachment API */ } + } + if (superseded) return; + const identity = this.identityFor(ws); + this.lastSeen.delete(ws.id); // a clean close frees the id for instant reuse + this.identities.delete(ws.id); + await this.handleClose(buildConn(ws, identity)); + await this.maintainTicker(); // a disconnect may drop below shouldTick() → stop + } + + // WATCHDOG, not the metronome (see tick-loop.ts): resumes the loop after + // eviction / self-heals a dead one. Never runs handleTick itself. + override async onAlarm(): Promise { + // A start/stop transition during any await below owns the loop state; a + // stale alarm resuming must not restore a loop the transition just stopped. + const gen = this.tickerGen; + // Fire due scheduled wakes first (delete-then-dispatch: a throw in user + // code must not replay the wake forever via alarm retries). + const now = Date.now(); + const due = await this.mutateSchedules((m) => { + const d = Object.keys(m).filter((k) => m[k] <= now); + for (const k of d) delete m[k]; + return d; + }); + for (const key of due) { + try { + await this.handleWake(key); + } catch (err) { + console.error("Actor.handleWake threw:", err); + } + } + const ms = this.loopMs ?? (await this.ctx.storage.get("__loop_ms")); + if (gen !== this.tickerGen) return; // the transition already rearmed + if (ms) { + this.loopMs = ms; + if (typeof this.shouldTick === "function" + && !([...super.getConnections()].length > 0 && this.shouldTick())) { + this.ticking = false; + await this.stopLoop(); + return; + } + if (!this.loop.isRunning) this.loop.start(ms); + } + await this.rearmAlarm(); + } + + protected handleWake(_key: string): void | Promise {} + + protected async schedule(key: string, at: number | Date): Promise { + const when = typeof at === "number" ? at : at.getTime(); + await this.mutateSchedules((m) => { + m[key] = when; + }); + await this.rearmAlarm(); + } + + protected async cancelSchedule(key: string): Promise { + await this.mutateSchedules((m) => { + delete m[key]; + }); + await this.rearmAlarm(); + } + + // Concurrent read-modify-writes of the map would drop each other's keys + // (two schedule() calls both reading the prior map); chain them instead. + private schedulesChain: Promise = Promise.resolve(); + + private mutateSchedules(fn: (m: Record) => T): Promise { + const run = this.schedulesChain.then(async () => { + const m = (await this.ctx.storage.get>("__schedules")) ?? {}; + const out = fn(m); + await this.ctx.storage.put("__schedules", m); + return out; + }); + this.schedulesChain = run.catch(() => {}); + return run; + } + + // The single DO alarm serves the ticker watchdog AND scheduled wakes: arm to + // whichever is earliest; clear only when neither needs it. Chained on the + // schedules queue so a rearm that read the map before a queued mutation can + // never act after that mutation's own rearm and clobber it. + private rearmAlarm(): Promise { + const run = this.schedulesChain.then(async () => { + const wakes = Object.values( + (await this.ctx.storage.get>("__schedules")) ?? {}, + ); + const nextWake = wakes.length ? Math.min(...wakes) : null; + const watchdog = this.loop.isRunning ? Date.now() + Actor.WATCHDOG_MS : null; + const next = nextWake === null ? watchdog : watchdog === null ? nextWake : Math.min(nextWake, watchdog); + if (next === null) await this.ctx.storage.deleteAlarm(); + else await this.ctx.storage.setAlarm(next); + }); + this.schedulesChain = run.catch(() => {}); + return run; + } + + abstract handleConnect(conn: Conn): void | Promise; + abstract handleMessage(conn: Conn, msg: Incoming): void | Promise; + abstract handleTick(): void | Promise; + abstract handleClose(conn: Conn): void | Promise; + // Optional wake hook: once per instance start, before any connection. + protected handleStart(): void | Promise {} + + // ─── Managed ticker (opt-in) ────────────────────────────────────────────── + // Override shouldTick() and the platform runs handleTick() on a timer while it + // returns true, stopping (the DO can idle out) when false. shouldTick() + // must be cheap and pure: it runs after every lifecycle event and tick. + protected tickIntervalMs = 100; + protected shouldTick?(): boolean; + private ticking = false; + private loopMs: number | null = null; // in-memory mirror of __loop_ms + private readonly loop = new TickLoop(() => this.runOneTick()); + private static readonly WATCHDOG_MS = 30_000; + + // One tick: a throw skips the tick, never kills the loop. + private async runOneTick(): Promise { + const gen = this.tickerGen; + try { + await this.handleTick(); + } catch (err) { + console.error("Actor.handleTick threw:", err); + } + // A transition DURING the tick (user deleteAll → stopLoop, a restart) owns + // the state — this stale run must not write `ticking` back or double-stop. + if (gen !== this.tickerGen) return false; + if (typeof this.shouldTick === "function") { + // Empty rooms force-stop regardless of the predicate ("keep warm" + // always-true predicates burn duration for an audience of zero). + const keep = [...super.getConnections()].length > 0 && this.shouldTick(); + this.ticking = keep; + if (!keep) { + await this.stopLoop(); + return false; + } + return true; + } + return this.loopMs != null; + } + + protected broadcast(data: Outgoing): void { + super.broadcast(JSON.stringify(data)); + } + + protected getConnections(): Conn[] { + return [...super.getConnections()].map((ws) => buildConn(ws, this.identityFor(ws))); + } + + // The newest start/stop owns the persisted state: a stale transition + // resuming after its await must not erase a fresh __loop_ms/watchdog. + private tickerGen = 0; + + private async startLoop(ms: number): Promise { + const gen = ++this.tickerGen; + this.loopMs = ms; + // Persisted so the watchdog can resume the loop after eviction. + await this.ctx.storage.put("__loop_ms", ms); + if (gen !== this.tickerGen) return; + this.loop.start(ms); + await this.rearmAlarm(); + } + + private async stopLoop(): Promise { + const gen = ++this.tickerGen; + this.loop.stop(); + this.loopMs = null; + // Keep the managed-ticker mirror honest: a stop not routed through + // maintainTicker (storage.deleteAll) must not leave `ticking` stuck true, + // or maintainTicker sees want===ticking and never restarts the loop. + this.ticking = false; + await this.ctx.storage.delete("__loop_ms"); + if (gen !== this.tickerGen) return; + await this.rearmAlarm(); // keeps the alarm armed for any scheduled wakes + } + + // Restart a dead in-memory loop (eviction / isolate reload). + private async ensureLoop(): Promise { + if (this.loopMs == null) { + const gen = this.tickerGen; + const ms = await this.ctx.storage.get("__loop_ms"); + // A start/stop transition during the read owns the state — a stale + // read must not resurrect a loop an explicit stop just tore down. + if (gen !== this.tickerGen) return; + if (!ms) return; // loop not started, or intentionally stopped + this.loopMs = ms; + } + if (!this.loop.isRunning) this.loop.start(this.loopMs); + } + + // Managed-ticker reconcile: run after every lifecycle event. The ticker is + // opt-in via shouldTick() — a handler that doesn't define it never ticks. + // When opted in, start/stop the loop to match, self-healing a dead loop + // while still wanted. + private async maintainTicker(): Promise { + if (typeof this.shouldTick !== "function") return; + const want = [...super.getConnections()].length > 0 && this.shouldTick(); + if (want !== this.ticking) { + this.ticking = want; + if (want) await this.startLoop(this.tickIntervalMs); + else await this.stopLoop(); + } else if (want) { + await this.ensureLoop(); // still ticking → restart if the loop died + } + } + + protected get instanceId(): string { + return this.name; + } + + protected get storage() { + const store = this.ctx.storage; + return { + get: (key: string): Promise => store.get(key), + put: (key: string, value: unknown): Promise => store.put(key, value), + delete: (key: string): Promise => store.delete(key), + deleteAll: async (): Promise => { + // The wiped __loop_ms could never resume a running loop post-eviction — + // stop it; managed rooms restart via shouldTick(). + await this.stopLoop(); + await store.deleteAll(); + }, + }; + } + +} diff --git a/packages/functions-compiler/src/shim/entry.ts b/packages/functions-compiler/src/shim/entry.ts new file mode 100644 index 000000000..bd77e796a --- /dev/null +++ b/packages/functions-compiler/src/shim/entry.ts @@ -0,0 +1,117 @@ +// Injected as `globalThis.Deno` ahead of user code. `serve` is overridden +// because the shim's own would start a real node:http server. + +import { AsyncLocalStorage } from "node:async_hooks"; + +import { Deno as ShimDeno } from "@deno/shim-deno"; +import { Buffer } from "node:buffer"; + +import { installStaticEgressFetch } from "../static-egress"; + +export { installStaticEgressFetch }; + +type DenoServeHandler = ( + request: Request, + info: unknown, +) => Response | Promise; + +// Worker-per-app runs each function's init inside its own `initContext`, so +// Deno.serve writes into that context — never a shared slot a concurrent peer's +// init could clobber. The single-function path (/v1/bundle) has no context, so +// it falls back to `registeredHandler`. +interface InitContext { + handler: DenoServeHandler | null; +} +let registeredHandler: DenoServeHandler | null = null; +const initContext = new AsyncLocalStorage(); + +function serve(arg1: unknown, arg2?: unknown) { + // Deno.serve overloads: (handler) | (options, handler) | ({ ...options, handler }). + let handler: DenoServeHandler | null = null; + if (typeof arg1 === "function") { + handler = arg1 as DenoServeHandler; + } else if (typeof arg2 === "function") { + handler = arg2 as DenoServeHandler; + } else if (arg1 && typeof (arg1 as { handler?: unknown }).handler === "function") { + handler = (arg1 as { handler: DenoServeHandler }).handler; + } + + if (!handler) { + throw new TypeError("Deno.serve: a request handler function is required"); + } + + const ctx = initContext.getStore(); + if (ctx) { + ctx.handler = handler; + } else { + registeredHandler = handler; + } + + // A Worker has nothing to listen on; return an HttpServer-shaped stub. + return { + finished: Promise.resolve(), + shutdown: async () => {}, + ref() {}, + unref() {}, + addr: { transport: "tcp", hostname: "0.0.0.0", port: 0 }, + }; +} + +const deno = Object.freeze({ + ...(ShimDeno as unknown as Record), + serve, +}); + +(globalThis as unknown as { Deno: unknown }).Deno = deno; +(globalThis as unknown as { Buffer: typeof Buffer }).Buffer = Buffer; + +export function getRegisteredHandler(): DenoServeHandler | null { + return registeredHandler; +} + +// ── Worker-per-app: lazy per-function init ────────────────────────────────── +// +// Each function registers an importer thunk instead of running its module at +// Worker startup. A function whose top-level throws, hangs, or rejects then +// affects only its OWN requests — the Worker still starts and peers serve. +// The module runs on first invocation of that function. + +type ImportThunk = () => Promise; + +const lazyImporters = new Map(); +const handlerByName = new Map>(); + +export function registerLazy( + functionName: string, + importThunk: ImportThunk, +): void { + lazyImporters.set(functionName, importThunk); +} + +// `undefined` → no such function in this app. Otherwise a promise for the +// function's handler (`null` → it neither registered a Deno.serve() handler +// nor default-exported one), which rejects if the function's module throws +// while initializing. Each init runs in its own context with no cross-function +// ordering, so one function hanging during init can't stall another's first +// request. +export function resolveHandler( + functionName: string, +): Promise | undefined { + const importThunk = lazyImporters.get(functionName); + if (importThunk === undefined) return undefined; + + let handler = handlerByName.get(functionName); + if (handler === undefined) { + const ctx: InitContext = { handler: null }; + handler = initContext.run(ctx, async () => { + const mod = await importThunk(); + // Deno.serve capture wins (legacy contract, zero behavior change); + // a default-exported handler is the new-contract fallback. + if (ctx.handler) return ctx.handler; + const def = (mod as { default?: unknown } | null)?.default; + return typeof def === "function" ? (def as DenoServeHandler) : null; + }); + handlerByName.set(functionName, handler); + } + return handler; +} diff --git a/packages/functions-compiler/src/shim/tick-loop.ts b/packages/functions-compiler/src/shim/tick-loop.ts new file mode 100644 index 000000000..378d81ae2 --- /dev/null +++ b/packages/functions-compiler/src/shim/tick-loop.ts @@ -0,0 +1,95 @@ +// Drift-corrected in-memory tick loop for the Actor shim's managed ticker. +// +// Why not alarms? Measured on real DOs (2026-07-21): an alarm-chained ticker +// requesting 33ms delivered median 127ms / p95 212ms — ~7Hz for a 30Hz ask — +// and alarms are an at-least-once durability primitive with ~2s+ retry +// backoff, so one thrown tick freezes a room for seconds. They also cost a +// billed storage write per tick. While the instance is awake this loop drives +// handleTick with setTimeout (zero storage ops per tick); the alarm survives +// as the ~30s WATCHDOG only — resume after eviction, self-heal a dead loop +// (see Actor.onAlarm). Precision from setTimeout, durability from the alarm. +// +// Semi-fixed timestep: each wake runs the tick callback once per elapsed +// interval, capped at `maxCatchup` steps — beyond the cap the remaining debt is +// DROPPED, because a visible hitch beats a burst of catch-up ticks teleporting +// every entity (the slither.io-on-DO lesson). +export class TickLoop { + private timer: ReturnType | null = null; + private nextAt = 0; + private running = false; + private intervalMs = 0; + // A run() resuming after stop()+start() must not arm a second, untracked + // timer — each run() acts only for its own generation. + private gen = 0; + + constructor( + /** Runs one tick. Return false to stop the loop (e.g. shouldTick() flipped). */ + private readonly onTick: () => Promise | boolean, + private readonly maxCatchup = 3, + ) {} + + get isRunning(): boolean { + return this.running; + } + + start(intervalMs: number): void { + if (this.running) { + if (intervalMs === this.intervalMs) return; // idempotent same-cadence start + // Interval change mid-run: re-anchor. Schedule only when a timer is + // pending — with none, the in-flight run() owns rescheduling. + this.intervalMs = intervalMs; + this.nextAt = Date.now() + intervalMs; + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + this.schedule(); + } + return; + } + this.running = true; + this.intervalMs = intervalMs; + this.nextAt = Date.now() + intervalMs; + this.schedule(); + } + + stop(): void { + this.running = false; + this.gen++; + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private schedule(): void { + this.timer = setTimeout(() => { + void this.run(); + }, Math.max(0, this.nextAt - Date.now())); + } + + private async run(): Promise { + const gen = this.gen; + this.timer = null; + let steps = 0; + while (this.running && this.gen === gen && Date.now() >= this.nextAt && steps < this.maxCatchup) { + steps++; + this.nextAt += this.intervalMs; + // A throw must not kill the loop with `running` stuck true (self-heal + // trusts isRunning); only a returned false stops it. + let keep = true; + try { + keep = await this.onTick(); + } catch (err) { + console.error("TickLoop tick threw:", err); + } + if (this.gen !== gen) return; // stopped (or stop+restarted) mid-await + if (!keep) { + this.stop(); + return; + } + } + if (!this.running || this.gen !== gen) return; + if (Date.now() >= this.nextAt) this.nextAt = Date.now() + this.intervalMs; // drop debt past the cap + this.schedule(); + } +} diff --git a/packages/functions-compiler/src/static-egress-marker.ts b/packages/functions-compiler/src/static-egress-marker.ts new file mode 100644 index 000000000..d1548e749 --- /dev/null +++ b/packages/functions-compiler/src/static-egress-marker.ts @@ -0,0 +1,7 @@ +// Leaf module: no imports, so the server graph never reaches worker-only code. +// `static-egress.ts` imports the runtime-context virtual specifier, which only +// esbuild (or the vitest alias) can resolve — importing it from a server module +// crashes Node at startup with ERR_UNSUPPORTED_ESM_URL_SCHEME. +// Keep in sync with STATIC_EGRESS_ARTIFACT_MARKER in backend/app/static_egress/config.py. +export const STATIC_EGRESS_ARTIFACT_MARKER = + "base44.static-egress.request-env.v2"; diff --git a/packages/functions-compiler/src/static-egress.ts b/packages/functions-compiler/src/static-egress.ts new file mode 100644 index 000000000..8de4b6a24 --- /dev/null +++ b/packages/functions-compiler/src/static-egress.ts @@ -0,0 +1,177 @@ +import { workerEnvironment } from "./private-data-sources/runtime-environment"; +import { STATIC_EGRESS_ARTIFACT_MARKER } from "./static-egress-marker"; + +export { STATIC_EGRESS_ARTIFACT_MARKER }; + +const STATIC_EGRESS_BINDING_NAME = "STATIC_EGRESS"; +const PRIVATE_DATA_SOURCES_MANIFEST_ENV = "BASE44_PRIVATE_DATA_SOURCES"; +const STATIC_EGRESS_EXCLUDED_HOSTS_ENV = "BASE44_STATIC_EGRESS_EXCLUDED_HOSTS"; +const STATIC_EGRESS_ENABLED_ENV = "BASE44_STATIC_EGRESS_ENABLED"; + +const STATIC_EGRESS_DIAGNOSTIC_LIMIT = 5; + +interface FetchBinding { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +} + +let staticEgressFetchInstalled = false; +let staticEgressDiagnosticCount = 0; + +type StaticEgressRoute = "dedicated" | "excluded" | "fallback"; + +function inputKind(input: RequestInfo | URL): "string" | "url" | "request" { + if (typeof input === "string") return "string"; + return input instanceof URL ? "url" : "request"; +} + +function logStaticEgressDecision( + route: StaticEgressRoute, + input: RequestInfo | URL, + binding: unknown, + enableSecret: unknown, +): void { + if (staticEgressDiagnosticCount >= STATIC_EGRESS_DIAGNOSTIC_LIMIT) return; + staticEgressDiagnosticCount += 1; + console.log(JSON.stringify({ + b44_diagnostic: "static_egress", + diagnostic_version: STATIC_EGRESS_ARTIFACT_MARKER, + event: "fetch_route", + route, + input_kind: inputKind(input), + binding_present: binding !== undefined, + binding_type: typeof binding, + enable_secret_present: enableSecret !== undefined, + enable_secret_enabled: isEnabled(enableSecret), + binding_fetch_type: + binding !== null && typeof binding === "object" + ? typeof (binding as { fetch?: unknown }).fetch + : "undefined", + })); +} + +function hasFetch(value: unknown): value is FetchBinding { + return ( + value !== null && + typeof value === "object" && + typeof (value as { fetch?: unknown }).fetch === "function" + ); +} + +function isEnabled(value: unknown): boolean { + return value === "1"; +} + +function privateDataSourceHosts(raw: unknown): Set { + if (typeof raw !== "string" || !raw) return new Set(); + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set( + parsed.flatMap((entry) => { + if (entry === null || typeof entry !== "object") return []; + const host = (entry as { host?: unknown }).host; + return typeof host === "string" && host + ? [normalizeHostname(host)] + : []; + }), + ); + } catch { + return new Set(); + } +} + +function configuredExcludedHosts(raw: unknown): Set { + if (typeof raw !== "string" || !raw) return new Set(); + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set( + parsed.flatMap((host) => + typeof host === "string" && host ? [normalizeHostname(host)] : [], + ), + ); + } catch { + return new Set(); + } +} + +function normalizeHostname(host: string): string { + return host.toLowerCase().replace(/^\[(.*)\]$/, "$1").replace(/\.$/, ""); +} + +function targetHostname(input: RequestInfo | URL): string | null { + try { + const raw = + input instanceof URL + ? input.href + : typeof input === "string" + ? input + : input.url; + return normalizeHostname(new URL(raw).hostname); + } catch { + return null; + } +} + +function isExcludedHostname( + hostname: string, + excludedHosts: ReadonlySet, +): boolean { + for (const excludedHost of excludedHosts) { + if (excludedHost.startsWith(".")) { + const apex = excludedHost.slice(1); + if (apex && (hostname === apex || hostname.endsWith(excludedHost))) { + return true; + } + } else if (hostname === excludedHost) { + return true; + } + } + return false; +} + +export function createRequestScopedStaticEgressFetch( + originalFetch: typeof fetch, + readEnvironment: () => Record, +): typeof fetch { + return (input: RequestInfo | URL, init?: RequestInit) => { + const env = readEnvironment(); + const binding = env[STATIC_EGRESS_BINDING_NAME]; + const enableSecret = env[STATIC_EGRESS_ENABLED_ENV]; + if (!hasFetch(binding) || !isEnabled(enableSecret)) { + if ( + binding !== undefined || + enableSecret !== undefined || + typeof env[STATIC_EGRESS_EXCLUDED_HOSTS_ENV] === "string" + ) { + logStaticEgressDecision("fallback", input, binding, enableSecret); + } + return originalFetch(input, init); + } + + const excludedHosts = new Set([ + ...privateDataSourceHosts(env[PRIVATE_DATA_SOURCES_MANIFEST_ENV]), + ...configuredExcludedHosts(env[STATIC_EGRESS_EXCLUDED_HOSTS_ENV]), + ]); + // A leading dot excludes both the apps-domain apex and its subdomains. + // Per-request custom-domain Base44-Api-Url values cannot be represented by + // the static workspace binding and continue through dedicated egress. + const hostname = targetHostname(input); + if (hostname !== null && isExcludedHostname(hostname, excludedHosts)) { + logStaticEgressDecision("excluded", input, binding, enableSecret); + return originalFetch(input, init); + } + logStaticEgressDecision("dedicated", input, binding, enableSecret); + return binding.fetch(input, init); + }; +} + +export function installStaticEgressFetch(): void { + if (staticEgressFetchInstalled) return; + + globalThis.fetch = createRequestScopedStaticEgressFetch( + globalThis.fetch.bind(globalThis), + workerEnvironment, + ); + staticEgressFetchInstalled = true; +} diff --git a/packages/functions-compiler/src/telemetry.ts b/packages/functions-compiler/src/telemetry.ts new file mode 100644 index 000000000..c1348b496 --- /dev/null +++ b/packages/functions-compiler/src/telemetry.ts @@ -0,0 +1,85 @@ +/** + * Post-response detached-work telemetry for generated Worker entries — a + * usage gauge for fetches that outlive the handler's Response + * (docs/features/post-response-telemetry.md). workerd cancels such fetches + * (Deno Deploy tolerated them), so the Deno→CFW migration breaks apps that + * rely on the pattern. + * + * When the bundle request carries `postResponseTelemetry: true` (backend + * evaluates the app-keyed `post-response-telemetry` flag at deploy time), + * the entry templates append this prelude and route the handler's response + * through `_b44AttachTelemetry`. Flag off appends nothing — the generated + * entry stays byte-identical to the pre-telemetry prod baseline. + * + * A request that returns with fetches pending logs one `inflight_at_response` + * line (through the console patch, so it carries function attribution) naming + * the target origins (refcounted, cap 10) and mirrors it onto the + * `X-B44-Post-Response-Telemetry` response header; the platform proxy relays + * that to Datadog and strips it before the client. Every observed fetch also + * rides ctx.waitUntil platform-side, so pending fetches complete (bounded by + * the waitUntil budget) instead of being cancelled, keeping the detached + * chain alive — its follow-up fetches log `fetch_started_post_response` + * without user code ever calling Base44.waitUntil. Clean traffic emits + * nothing and carries no header. + * + * Must be self-contained JS (no imports) — inlined into the generated entry + * after CONSOLE_PATCH. The entry provides `_b44Context` and the patched console. + */ +export const TELEMETRY_PATCH = [ + "const _b44Tele = (c, event, fields) => {", + " if (!(c.logBudget > 0)) return;", + " c.logBudget -= 1;", + " try { console.log(JSON.stringify({ b44_telemetry: 'post_response_work', event, request_id: c.reqId, ...fields })); } catch (e) {}", + "};", + // origin only — both query strings and paths can carry secrets (Slack + // webhook URLs, Telegram bot tokens); the census only needs the host. + "const _b44Target = (input) => { try { const u = typeof input === 'string' ? new URL(input) : (input instanceof URL ? input : new URL(input.url)); return u.origin; } catch (e) { return 'unknown'; } };", + "const _b44OrigFetch = globalThis.fetch.bind(globalThis);", + "globalThis.fetch = (input, init) => {", + " const c = _b44Context();", + " if (!c || c.respondedAt === undefined) return _b44OrigFetch(input, init);", + " const t = _b44Target(input);", + " if (c.respondedAt !== null) _b44Tele(c, 'fetch_started_post_response', { target: t, ms_after_response: Date.now() - c.respondedAt });", + // Refcounted (cap 10 distinct): same-target siblings must not lose the + // target when the first settles. + " const tracked = c.inflight.has(t) || c.inflight.size < 10;", + " if (tracked) c.inflight.set(t, (c.inflight.get(t) ?? 0) + 1);", + " c.inflightTotal += 1;", + " const done = () => { c.inflightTotal -= 1; if (tracked) { const n = c.inflight.get(t); if (n !== undefined) { if (n <= 1) c.inflight.delete(t); else c.inflight.set(t, n - 1); } } };", + // Ride ctx.waitUntil on every observed fetch (platform-side — user code + // needs no Base44.waitUntil): workerd then keeps the request context alive + // until the fetch settles, so a fetch pending at response completes instead + // of being cancelled, and follow-up fetches in the detached chain run and + // get logged. Bounded by Cloudflare's waitUntil budget; a no-op for fetches + // that settle in-request. + // Fail-fast discriminators: a pre-response rejection, or a pre-response + // non-ok resolution (an SDK/helper throws on !res.ok and the rejection + // fail-fasts Promise.all), marks the request as likely orphaning fallout + // rather than deliberate fire-and-forget — which has neither. + " const failed = () => { if (c.respondedAt === null) c.hadRejection = true; done(); };", + " const settled = (res) => { if (c.respondedAt === null && res && res.ok === false) c.hadNonOk = true; done(); };", + " try { const p = _b44OrigFetch(input, init); p.then(settled, failed); if (c.waitUntil) { try { c.waitUntil(p.then(() => {}, () => {})); } catch (e) {} } return p; } catch (e) { failed(); throw e; }", + "};", + "const _b44AttachTelemetry = (res) => {", + " const c = _b44Context();", + " if (!c || c.respondedAt === undefined) return res;", + " c.respondedAt = Date.now();", + // Upgrade responses can't be reconstructed; a live socket's fetches + // aren't post-response work anyway. + " if (c.inflightTotal <= 0 || res.status === 101 || res.webSocket) return res;", + " const pending = { request_id: c.reqId, inflight: c.inflightTotal, targets: [...c.inflight.keys()], rejected_pre_response: c.hadRejection, non_ok_pre_response: c.hadNonOk };", + " _b44Tele(c, 'inflight_at_response', pending);", + // Once the wrap exists the original body stream is transferred — always + // return the wrap then; fall back to res only if construction itself threw. + " let wrapped;", + " try { wrapped = new Response(res.body, res); } catch (e) { return res; }", + " try { wrapped.headers.set('X-B44-Post-Response-Telemetry', JSON.stringify({ pending })); } catch (e) {}", + " return wrapped;", + "};", +].join("\n"); + +/** Request-store fields the flag-ON entry seeds. Flag-OFF entries omit them + * and every telemetry call site — control must stay byte-identical to the + * pre-telemetry prod entry. */ +export const TELEMETRY_STORE_FIELDS = + "reqId: crypto.randomUUID(), respondedAt: null, inflight: new Map(), inflightTotal: 0, hadRejection: false, hadNonOk: false, logBudget: 5"; diff --git a/packages/functions-compiler/src/tracing.ts b/packages/functions-compiler/src/tracing.ts new file mode 100644 index 000000000..7ec060957 --- /dev/null +++ b/packages/functions-compiler/src/tracing.ts @@ -0,0 +1,34 @@ +// The compiler emits spans through whatever tracer the host registers. Apper's +// bundler service registers its dd-trace adapter at worker startup; a CLI-local +// build leaves the no-op in place. Keeps dd-trace and its initialization out of +// the compiler's own dependencies. + +export interface CompilerTracer { + withSpan(name: string, fn: () => Promise): Promise; + setSpanTags( + tags: Record, + ): Promise; +} + +const noopTracer: CompilerTracer = { + withSpan: (_name, fn) => fn(), + setSpanTags: async () => {}, +}; + +let tracer: CompilerTracer = noopTracer; + +/** Register the host's tracer; `null` restores the no-op. Must be called in the + * same thread that runs the compile — spans are process-local. */ +export function setCompilerTracer(next: CompilerTracer | null): void { + tracer = next ?? noopTracer; +} + +export function withSpan(name: string, fn: () => Promise): Promise { + return tracer.withSpan(name, fn); +} + +export function setSpanTags( + tags: Record, +): Promise { + return tracer.setSpanTags(tags); +} diff --git a/packages/functions-compiler/src/worker-entry.ts b/packages/functions-compiler/src/worker-entry.ts new file mode 100644 index 000000000..4288254a3 --- /dev/null +++ b/packages/functions-compiler/src/worker-entry.ts @@ -0,0 +1,370 @@ +/** + * Assembles user functions into the input the bundler compiles: the user + * sources (passed through verbatim — the Deno resolver understands their + * `npm:`/`jsr:`/`https:` specifiers) plus an injected shim and a generated + * Worker entry. This is the layer that understands "functions" and "an app". + */ + +import { readFileSync } from "node:fs"; + +import type { AppFunctionInput } from "./contracts.js"; +import { RUNTIME_CONTEXT_SPECIFIER } from "./esbuild/runtime-context-virtual.js"; +import { DenoCompatError } from "./errors.js"; +import { TELEMETRY_PATCH, TELEMETRY_STORE_FIELDS } from "./telemetry.js"; + +// Pre-built by scripts/build-shim.ts; regenerate it after changing the shim. +// Read LAZILY, not at module load: build-shim.ts transitively imports this +// module through the esbuild plugins, and on a fresh checkout dist/ doesn't +// exist yet — a load-time read would crash the very script that produces these +// files (and only pass locally where dist/ already exists). +let _denoShimSource: string | undefined; +function denoShimSource(): string { + return (_denoShimSource ??= readFileSync( + new URL("../dist/deno-shim.mjs", import.meta.url), + "utf8", + )); +} +let _activationShimSource: string | undefined; +function activationShimSource(): string { + return (_activationShimSource ??= readFileSync( + new URL("../dist/activation-shim.mjs", import.meta.url), + "utf8", + )); +} + +// Synthetic files injected into the bundle; asserted absent from user input. +export const SHIM_FILENAME = "__base44_deno_shim.mjs"; +const ENTRY_FILENAME = "__base44_entry.mjs"; +// Injected only for runtime-secrets bundles — old-mode output stays byte-identical. +export const ACTIVATION_FILENAME = "__base44_activation.mjs"; +// Actor shim filename is also reserved (handled by actor-compat.ts). +const ACTOR_ENTRY_FILENAME = "__base44_actor_entry.mjs"; + +export interface PreparedWorker { + entry: string; + files: Record; +} + +export function workerRuntimeFiles(): Record { + return { [SHIM_FILENAME]: denoShimSource() }; +} + +/** Shared console-patching prelude injected into every generated Worker entry. + * Exported so tests can build minimal bundles with the same patch applied. */ +// Runs at module scope before any user code so module-level logs are captured. +// Generated entries import `_b44Context` from the private runtime module. +export const CONSOLE_PATCH = [ + "const _b44Orig = console.log;", + "const _b44S = x => { if (x instanceof Error) return x.stack || x.message; try { return typeof x === 'string' ? x : (JSON.stringify(x) ?? String(x)); } catch (e) { return String(x); } };", + "const _b44Fmt = (a) => {", + " if (typeof a[0] !== 'string' || !a[0].includes('%')) return a.map(_b44S).join(' ');", + " let i = 1;", + " const s = a[0].replace(/%([sdifoOc])/g, (_, t) => {", + " if (i >= a.length) return '%' + t;", + " const v = a[i++];", + " if (t === 'd' || t === 'i') { try { return String(Math.trunc(+v)); } catch(_) { return _b44S(v); } }", + " if (t === 'f') { try { return String(+v); } catch(_) { return _b44S(v); } }", + " if (t === 'c') return '';", + " return _b44S(v);", + " });", + " const tail = a.slice(i).map(_b44S).join(' ');", + " return tail ? s + ' ' + tail : s;", + "};", + // The store holds { env, fn, secrets, workerEnv, waitUntil }; fn is set only + // by per-app bundles, where one script serves every function and log queries + // need per-function attribution. `secrets` and `workerEnv` reference the + // request's authoritative Worker env binding. + "const _b44Wrap = (lvl, a) => { const _c = _b44Context() ?? {}; _b44Orig({ _b44_env: _c.env ?? 'preview', ...(_c.fn ? { _b44_function: _c.fn } : {}), level: lvl, message: _b44Fmt(a) }); };", + "console.log = (...a) => _b44Wrap('info', a);", + "console.info = (...a) => _b44Wrap('info', a);", + "console.warn = (...a) => _b44Wrap('warn', a);", + "console.error = (...a) => _b44Wrap('error', a);", + "console.debug = (...a) => _b44Wrap('debug', a);", + // Request-scoped background-work hook. Cloudflare cancels promises left + // pending after the response unless they ride ctx.waitUntil, and user code + // has no other path to ctx — expose it via the same store as env/fn so + // functions can ack fast and finish work reliably afterwards. The global + // reads the store per call, so concurrent requests get their own ctx. + // `secrets` reads the request's Worker env binding the same way. Two filters: + // - reserved platform keys (the private-data-sources manifest, which carries + // plaintext VPC DB credentials) are denied — user code reaches data sources + // via the base44:private-data-sources/* imports, never the raw manifest; + // - only string values are returned, so non-secret bindings (Hyperdrive + // objects, etc.) stay hidden. + // This global is the internal bridge behind the "base44:runtime" virtual + // module (src/runtime/index.ts) — keep the two shapes in sync (the actor + // shim installs the same bridge from the DO env: src/shim/actor.ts). + "const _b44ReservedSecrets = new Set(['BASE44_ACTOR_PRIVATE_KEY', 'BASE44_ACTOR_PUBLIC_KEY', 'BASE44_ACTOR_SCRIPT_ID', 'BASE44_PRIVATE_DATA_SOURCES']);", + // Coerce to a primitive string BEFORE the reserved-key check: a boxed + // `new String('BASE44_PRIVATE_DATA_SOURCES')` fails Set.has (object identity) + // but would coerce back to the reserved key on the `env[...]` lookup, leaking + // the manifest. `String(n)` normalizes both the check and the lookup. + "globalThis.Base44 = Object.assign(globalThis.Base44 ?? {}, { waitUntil: (p) => { const _c = _b44Context(); if (_c?.waitUntil) { _c.waitUntil(p); } else { Promise.resolve(p).catch(() => {}); } }, secrets: { get: (n) => { const _k = String(n); if (_b44ReservedSecrets.has(_k)) return undefined; const _v = _b44Context()?.secrets?.[_k]; return typeof _v === 'string' ? _v : undefined; } } });", + // Supabase-compat alias so copy-pasted EdgeRuntime.waitUntil code works. + "globalThis.EdgeRuntime = globalThis.EdgeRuntime ?? { waitUntil: (p) => globalThis.Base44.waitUntil(p) };", +].join("\n"); + +/** Assemble a single Deno function into a bundle-ready Worker. */ +export async function prepareFunction( + entry: string, + files: Record, + postResponseTelemetry = false, + runtimeSecrets = false, +): Promise { + assertNoReservedFilenames(files); + return { + entry: ENTRY_FILENAME, + files: { + ...userFiles(files), + ...workerRuntimeFiles(), + ...(runtimeSecrets ? { [ACTIVATION_FILENAME]: activationShimSource() } : {}), + [ENTRY_FILENAME]: buildEntrySource(entry, postResponseTelemetry, runtimeSecrets), + }, + }; +} + +/** One app function paired with the stable key its files and diagnostics are + * namespaced under (`fn_`). The index is the function's original + * position so attribution stays correct across an exclude-and-rebuild. */ +export interface AppFunctionEntry { + index: number; + fn: AppFunctionInput; +} + +/** Assemble the whole app into ONE bundle-ready Worker: every function's files + * namespaced under `fn_/`, one shared shim, and a router that routes by + * function name. A single compile resolves and DEDUPES npm deps across all + * functions (vs one inlined copy per chunk) — but esbuild fails the whole build + * on any unresolved import, so the caller excludes the offending functions + * (identified by their `fn_/` path) and rebuilds the survivors. Each + * function is sealed to its own `fn_/` keyspace, so one can't import + * another's files. */ +export function prepareApp( + entries: AppFunctionEntry[], + postResponseTelemetry = false, + runtimeSecrets = false, +): PreparedWorker { + const files: Record = { + ...workerRuntimeFiles(), + ...(runtimeSecrets ? { [ACTIVATION_FILENAME]: activationShimSource() } : {}), + }; + const moduleFiles = entries.map(({ index, fn }) => { + assertNoReservedFilenames(fn.files); + const dir = `fn_${index}`; + for (const [filePath, content] of Object.entries(userFiles(fn.files))) { + files[`${dir}/${filePath}`] = content; + } + const wrapper = `__base44_fn_${index}.mjs`; + files[wrapper] = buildFunctionModuleSource(fn.name, `${dir}/${fn.entry}`); + return wrapper; + }); + files[ENTRY_FILENAME] = buildAppEntrySource(moduleFiles, postResponseTelemetry, runtimeSecrets); + return { entry: ENTRY_FILENAME, files }; +} + +// Activation prelude for runtime-secrets bundles: gate on the encrypted +// handshake BEFORE any user module is imported (so a needs-activation response +// implies no user side effect ran), then strip the envelope from the request. +const ACTIVATION_GATE = ` const _b44Activation = await ensureActivation(request, env); + if (_b44Activation) return _b44Activation; + request = withoutRuntimeSecretsHeader(request); +`; + +function activationImport(runtimeSecrets: boolean): string { + return runtimeSecrets + ? `\nimport { ensureActivation, withoutActivationSignal, withoutRuntimeSecretsHeader } from "./${ACTIVATION_FILENAME}";` + : ""; +} + +/** Build the wrapper entry that loads the shim, lazily imports the user module + * on the first request (so init logs are tagged with the real request env), + * then exports a standard Worker fetch that delegates to that handler. */ +function buildEntrySource(userEntry: string, telemetry: boolean, runtimeSecrets = false): string { + const userImport = "./" + userEntry.replace(/^\.\//, ""); + // Handler responses get the activation-signal header stripped (user code must + // not be able to forge a signal after side effects and cause a re-execution). + const handlerExpr = telemetry + ? "_b44AttachTelemetry(await handler(request, info))" + : runtimeSecrets + ? "await handler(request, info)" + : "handler(request, info)"; + const returnExpr = runtimeSecrets ? `withoutActivationSignal(${handlerExpr})` : handlerExpr; + return `// Auto-generated by the base44 bundler. Do not edit. + +import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as _b44Run } from ${JSON.stringify(RUNTIME_CONTEXT_SPECIFIER)}; +import { getRegisteredHandler, installStaticEgressFetch } from "./${SHIM_FILENAME}";${activationImport(runtimeSecrets)} + +${CONSOLE_PATCH} +// Static egress reads workerEnv from the active request store. Install it +// before telemetry so telemetry remains the outermost fetch wrapper. +installStaticEgressFetch(); +${telemetry ? TELEMETRY_PATCH : ""} + +let _b44Init = null; +export default { + async fetch(request, env, ctx) { + const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; + return _b44Run({ env: _b44Env, secrets: ${runtimeSecrets ? "process.env" : "env"}, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p)${telemetry ? `, ${TELEMETRY_STORE_FIELDS}` : ""} }, async () => { +${runtimeSecrets ? ACTIVATION_GATE : ""} if (!_b44Init) _b44Init = import(${JSON.stringify(userImport)}).catch(e => { _b44Init = null; throw e; }); + const _b44Mod = await _b44Init; + // Deno.serve capture wins (legacy contract, zero behavior change); + // a default-exported handler is the new-contract fallback. + const handler = getRegisteredHandler() ?? (typeof _b44Mod?.default === 'function' ? _b44Mod.default : null); + if (!handler) { + return new Response( + "The function must export default a request handler or call Deno.serve()", + { status: 503 }, + ); + } + // Deno's handler signature is (request, info). Cloudflare doesn't expose a + // connection address the same way; pass a best-effort placeholder. Real + // client IP is available via the "cf-connecting-ip" request header. + const info = { + remoteAddr: { transport: "tcp", hostname: "0.0.0.0", port: 0 }, + }; + return ${returnExpr}; + }); + }, +}; +`; +} + +function buildFunctionModuleSource( + functionName: string, + userEntry: string, +): string { + const userImport = "./" + userEntry.replace(/^\.\//, ""); + return `// Auto-generated by the base44 bundler. Do not edit. +import { registerLazy } from "./${SHIM_FILENAME}"; + +registerLazy(${JSON.stringify(functionName)}, () => import(${JSON.stringify(userImport)})); +`; +} + +function buildAppEntrySource( + functionModules: string[], + telemetry: boolean, + runtimeSecrets = false, +): string { + const moduleImports = functionModules + .map((file) => `import "./${file}";`) + .join("\n"); + const handlerExpr = telemetry + ? "_b44AttachTelemetry(await handler(request, info))" + : "await handler(request, info)"; + const returnExpr = runtimeSecrets ? `withoutActivationSignal(${handlerExpr})` : handlerExpr; + return `// Auto-generated by the base44 bundler. Do not edit. + +import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as _b44Run } from ${JSON.stringify(RUNTIME_CONTEXT_SPECIFIER)}; +import { installStaticEgressFetch, resolveHandler } from "./${SHIM_FILENAME}";${activationImport(runtimeSecrets)} +${moduleImports} + +${CONSOLE_PATCH} +// Static egress reads workerEnv from the active request store. Install it +// before telemetry so telemetry remains the outermost fetch wrapper. +installStaticEgressFetch(); +${telemetry ? TELEMETRY_PATCH : ""} + +export default { + async fetch(request, env, ctx) { + const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; + const functionName = request.headers.get("Base44-Function-Name"); + return _b44Run({ env: _b44Env, fn: functionName ?? '', secrets: ${runtimeSecrets ? "process.env" : "env"}, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p)${telemetry ? `, ${TELEMETRY_STORE_FIELDS}` : ""} }, async () => { +${runtimeSecrets ? ACTIVATION_GATE : ""} // Each early return below logs through the patch first: per-function log + // queries on per-app scripts keep only stamped lines, so a bare return + // would leave the failing invocation with no trace in its own logs. + const pendingHandler = functionName ? resolveHandler(functionName) : undefined; + if (pendingHandler === undefined) { + const message = \`No function registered for "\${functionName ?? ""}"\`; + console.error(message); + return new Response(message, { status: 404 }); + } + let handler; + try { + handler = await pendingHandler; + } catch (e) { + console.error(\`Function "\${functionName}" failed to initialize:\`, e); + return new Response( + \`Function "\${functionName}" failed to initialize: \${e instanceof Error ? e.message : String(e)}\`, + { status: 500 }, + ); + } + if (handler === null) { + const message = \`Function "\${functionName}" must export default a request handler or call Deno.serve()\`; + console.error(message); + return new Response(message, { status: 503 }); + } + // Real client IP is in the "cf-connecting-ip" header, not this placeholder. + const info = { + remoteAddr: { transport: "tcp", hostname: "0.0.0.0", port: 0 }, + }; + // Cloudflare reports uncaught exceptions as its own log events, outside + // the console patch and therefore without function attribution. Log the + // crash through the patch (stamped with _b44_function) before + // rethrowing; per-function log queries on per-app scripts keep only + // stamped lines (see log_query.event_matches_function). Known gap: + // exceptions thrown while a response body streams happen after this + // frame returns and cannot be stamped — those crash events are + // dropped from per-function views. + try { + return ${returnExpr}; + } catch (e) { + console.error(e); + throw e; + } + }); + }, +}; +`; +} + +export function assertNoReservedFilenames( + files: Record, +): void { + // Shim/entry/actor are reserved as exact root keys (unchanged — a flag-off + // bundle accepts exactly what prod accepts today). + if ( + SHIM_FILENAME in files || + ENTRY_FILENAME in files || + ACTOR_ENTRY_FILENAME in files + ) { + throw new DenoCompatError( + `Reserved filenames "${SHIM_FILENAME}" / "${ENTRY_FILENAME}" / "${ACTOR_ENTRY_FILENAME}" must not be present in the function files.`, + ); + } + // The activation shim is the ONLY importer allowed to reach the private + // PDS-manifest store (plaintext VPC/DB creds), and that gate keys on the + // ACTIVATION_FILENAME basename. Reserve that basename at ANY depth in EVERY + // mode — not just runtime-secrets. In flag-off / actor bundles no shim is + // injected, but a user file named `__base44_activation.mjs` would still match + // the store gate's allow-list and could forge the manifest to retarget a VPC + // binding; reserving it unconditionally (like shim/entry above) closes that. + for (const filePath of Object.keys(files)) { + if (filePath.slice(filePath.lastIndexOf("/") + 1) === ACTIVATION_FILENAME) { + throw new DenoCompatError( + `Reserved filename "${ACTIVATION_FILENAME}" must not be present in the function files (found "${filePath}").`, + ); + } + } +} + +/** Pass user sources through verbatim, dropping any build-config files: we own + * the `deno.json` (written into the bundle temp dir) and never honor a + * user-supplied one, so it can't change dependency resolution. */ +function userFiles(files: Record): Record { + const out: Record = {}; + for (const [filePath, content] of Object.entries(files)) { + if (isBuildConfig(filePath)) continue; + out[filePath] = content; + } + return out; +} + +function isBuildConfig(filePath: string): boolean { + return ( + filePath === "package.json" || + filePath.endsWith("/package.json") || + filePath === "deno.json" || + filePath.endsWith("/deno.json") + ); +} diff --git a/packages/functions-compiler/test/actor-bundle.e2e.test.ts b/packages/functions-compiler/test/actor-bundle.e2e.test.ts new file mode 100644 index 000000000..3f538a104 --- /dev/null +++ b/packages/functions-compiler/test/actor-bundle.e2e.test.ts @@ -0,0 +1,316 @@ +import { Miniflare } from "miniflare"; +import { describe, expect, it } from "vitest"; + +import { bundle } from "../src/bundler"; +import { STATIC_EGRESS_ARTIFACT_MARKER } from "../src/static-egress"; +import { bundleOrThrow } from "./helpers"; +import { WFP_COMPAT_DATE } from "./workerd"; + +describe("actor bundling via base44:runtime/actors", () => { + it("resolves the Actor base class from the runtime virtual module and bundles the DO wrapper", async () => { + const src = [ + 'import { Actor } from "base44:runtime/actors";', + "export default class GameRoom extends Actor {", + " handleConnect() {}", + " handleMessage() {}", + " handleTick() {}", + " handleClose() {}", + "}", + ].join("\n"); + + // Throws if "base44:runtime/actors" fails to resolve (the whole point of the + // virtual module) or the generated DO wrapper fails to bundle. + const module = await bundleOrThrow(src, "base44/actors/GameRoom/entry.ts"); + + // The generated wrapper exports the DO class and routes to env["GameRoom"]. + expect(module).toContain("GameRoom"); + expect(module).toContain(STATIC_EGRESS_ARTIFACT_MARKER); + expect(module.length).toBeGreaterThan(0); + }); + + it("installs static egress before an Actor captures fetch at module scope", async () => { + const src = [ + 'import { Actor } from "base44:runtime/actors";', + "const capturedFetch = fetch;", + "export default class GameRoom extends Actor {", + ' fetch() { return capturedFetch("https://captured.example.com/check"); }', + " handleConnect() {}", + " handleMessage() {}", + " handleTick() {}", + " handleClose() {}", + "}", + ].join("\n"); + const module = await bundleOrThrow(src, "base44/actors/GameRoom/entry.ts"); + const staticHosts: string[] = []; + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: module }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + durableObjects: { GameRoom: "GameRoom" }, + bindings: { BASE44_STATIC_EGRESS_ENABLED: "1" }, + serviceBindings: { + STATIC_EGRESS: async (request) => { + const hostname = new URL(request.url).hostname; + staticHosts.push(hostname); + return new Response(`static:${hostname}`); + }, + }, + outboundService: async (request) => + new Response(`ordinary:${new URL(request.url).hostname}`), + }); + + try { + const response = await mf.dispatchFetch( + "http://localhost/parties/GameRoom/room-a", + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("static:captured.example.com"); + expect(staticHosts).toEqual(["captured.example.com"]); + } finally { + await mf.dispose(); + } + }); + + it("routes Actor construction-time fetches through static egress", async () => { + const src = [ + 'import { Actor } from "base44:runtime/actors";', + "export default class GameRoom extends Actor {", + ' fieldFetch = fetch("https://field.example.com/check");', + " constructor(ctx, env) {", + " super(ctx, env);", + ' this.constructorFetch = fetch("https://constructor.example.com/check");', + " }", + " async fetch() {", + " const responses = await Promise.all([this.fieldFetch, this.constructorFetch]);", + " return new Response(JSON.stringify(await Promise.all(responses.map((response) => response.text()))));", + " }", + " handleConnect() {}", + " handleMessage() {}", + " handleTick() {}", + " handleClose() {}", + "}", + ].join("\n"); + const module = await bundleOrThrow(src, "base44/actors/GameRoom/entry.ts"); + const staticHosts: string[] = []; + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: module }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + durableObjects: { GameRoom: "GameRoom" }, + bindings: { BASE44_STATIC_EGRESS_ENABLED: "1" }, + serviceBindings: { + STATIC_EGRESS: async (request) => { + const hostname = new URL(request.url).hostname; + staticHosts.push(hostname); + return new Response(`static:${hostname}`); + }, + }, + outboundService: async (request) => + new Response(`ordinary:${new URL(request.url).hostname}`), + }); + + try { + const response = await mf.dispatchFetch( + "http://localhost/parties/GameRoom/room-a", + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual([ + "static:field.example.com", + "static:constructor.example.com", + ]); + expect(staticHosts).toEqual([ + "field.example.com", + "constructor.example.com", + ]); + } finally { + await mf.dispose(); + } + }); + + it("resolves a module-scope private data source from the Actor request environment", async () => { + const src = [ + 'import { Actor } from "base44:runtime/actors";', + 'import { http } from "base44:private-data-sources/http";', + 'const source = http("Internal API");', + "export default class GameRoom extends Actor {", + ' fetch() { return source.binding.fetch("https://internal.example/health"); }', + " handleConnect() {}", + " handleMessage() {}", + " handleTick() {}", + " handleClose() {}", + "}", + ].join("\n"); + const module = await bundleOrThrow(src, "base44/actors/GameRoom/entry.ts"); + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: module }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + durableObjects: { GameRoom: "GameRoom" }, + bindings: { + BASE44_PRIVATE_DATA_SOURCES: JSON.stringify([ + { + name: "Internal API", + type: "http", + bindingName: "DATA_SOURCE_INTERNAL", + baseUrl: "https://internal.example", + }, + ]), + }, + serviceBindings: { + DATA_SOURCE_INTERNAL: async (request) => + new Response(new URL(request.url).toString()), + }, + }); + + try { + const response = await mf.dispatchFetch( + "http://localhost/parties/GameRoom/room-a", + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("https://internal.example/health"); + } finally { + await mf.dispose(); + } + }); + + it("passes managed bindings to the user Actor unfiltered while retaining runtime access", async () => { + const src = [ + 'import { Actor } from "base44:runtime/actors";', + 'import { http } from "base44:private-data-sources/http";', + 'const source = http("Internal API");', + "export default class GameRoom extends Actor {", + " constructor(ctx, env) {", + " super(ctx, env);", + " this.visibleEnv = {", + " staticEgress: env.STATIC_EGRESS !== undefined,", + " enableSecret: env.BASE44_STATIC_EGRESS_ENABLED !== undefined,", + " manifest: env.BASE44_PRIVATE_DATA_SOURCES !== undefined,", + " dataBinding: env.DATA_SOURCE_INTERNAL !== undefined,", + " userValue: env.USER_VALUE,", + " };", + " }", + " async fetch() {", + ' const response = await source.binding.fetch("https://internal.example/health");', + " return Response.json({ visibleEnv: this.visibleEnv, runtimeBody: await response.text() });", + " }", + "}", + ].join("\n"); + const module = await bundleOrThrow(src, "base44/actors/GameRoom/entry.ts"); + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: module }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + durableObjects: { GameRoom: "GameRoom" }, + bindings: { + USER_VALUE: "visible", + BASE44_STATIC_EGRESS_ENABLED: "1", + BASE44_PRIVATE_DATA_SOURCES: JSON.stringify([ + { + name: "Internal API", + type: "http", + bindingName: "DATA_SOURCE_INTERNAL", + baseUrl: "https://internal.example", + }, + ]), + }, + serviceBindings: { + STATIC_EGRESS: async () => new Response("static"), + DATA_SOURCE_INTERNAL: async () => new Response("private"), + }, + }); + + try { + const response = await mf.dispatchFetch( + "http://localhost/parties/GameRoom/room-a", + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + visibleEnv: { + staticEgress: true, + enableSecret: true, + manifest: true, + dataBinding: true, + userValue: "visible", + }, + runtimeBody: "private", + }); + } finally { + await mf.dispose(); + } + }); + + it("preserves legacy static-egress-named secrets without the managed binding", async () => { + const src = [ + 'import { Actor } from "base44:runtime/actors";', + "export default class GameRoom extends Actor {", + " constructor(ctx, env) {", + " super(ctx, env);", + " this.visibleEnv = {", + " staticEgress: env.STATIC_EGRESS,", + " enableSecret: env.BASE44_STATIC_EGRESS_ENABLED,", + " excludedHosts: env.BASE44_STATIC_EGRESS_EXCLUDED_HOSTS,", + " };", + " }", + " fetch() { return Response.json(this.visibleEnv); }", + "}", + ].join("\n"); + const module = await bundleOrThrow(src, "base44/actors/GameRoom/entry.ts"); + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: module }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + durableObjects: { GameRoom: "GameRoom" }, + bindings: { + STATIC_EGRESS: "legacy-network-secret", + BASE44_STATIC_EGRESS_ENABLED: "legacy-enable-secret", + BASE44_STATIC_EGRESS_EXCLUDED_HOSTS: "legacy-hosts-secret", + }, + }); + + try { + const response = await mf.dispatchFetch( + "http://localhost/parties/GameRoom/room-a", + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + staticEgress: "legacy-network-secret", + enableSecret: "legacy-enable-secret", + excludedHosts: "legacy-hosts-secret", + }); + } finally { + await mf.dispose(); + } + }); + + it("a named-only export (no default) fails the compile — the wrapper re-exports default", async () => { + const src = [ + 'import { Actor } from "base44:runtime/actors";', + "export class GameRoom extends Actor { handleConnect() {} }", + ].join("\n"); + await expect( + bundleOrThrow(src, "base44/actors/GameRoom/entry.ts"), + ).rejects.toThrow(/default/i); + }); + + it("rejects runtimeSecrets for an Actor instead of silently ignoring it", async () => { + // An Actor reads secrets from its Durable Object env, and actor connect dials + // the dispatcher without a handshake — the activation wrapper cannot serve it. + // Returning an old-mode bundle here would pair with a backend that already + // omitted the bindings: an actor with no secrets and no signal why. + const src = [ + 'import { Actor } from "base44:runtime/actors";', + "export default class GameRoom extends Actor { handleConnect() {} }", + ].join("\n"); + const res = await bundle({ + entry: "base44/actors/GameRoom/entry.ts", + files: { "base44/actors/GameRoom/entry.ts": src }, + runtimeSecrets: true, + }); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(res.stage).toBe("deno_compat"); + expect(res.errors.map((e) => e.message).join("\n")).toMatch( + /runtimeSecrets is not supported for Actor/, + ); + }); +}); diff --git a/packages/functions-compiler/test/actor-compat.test.ts b/packages/functions-compiler/test/actor-compat.test.ts new file mode 100644 index 000000000..c4a74f308 --- /dev/null +++ b/packages/functions-compiler/test/actor-compat.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; + +import { applyActorCompat } from "../src/actor-compat"; +import { STATIC_EGRESS_ARTIFACT_MARKER } from "../src/static-egress"; +import { SHIM_FILENAME } from "../src/worker-entry"; + +const ACTOR_ENTRY_FILENAME = "__base44_actor_entry.mjs"; +const ACTOR_PRELUDE_FILENAME = "__base44_actor_prelude.mjs"; +const ACTOR_AUTH_FILENAME = "__base44_actor_auth.mjs"; +const STANDARD_ENTRY_FILENAME = "__base44_entry.mjs"; +const ACTOR_HANDLER = + "export default class extends Actor { handleConnect(){} handleMessage(){} handleTick(){} handleClose(){} }"; + +async function makeActorKeypair(): Promise<{ privateKey: CryptoKey; publicKeyB64url: string }> { + const { privateKey, publicKey } = (await crypto.subtle.generateKey( + { name: "Ed25519" }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const raw = await crypto.subtle.exportKey("raw", publicKey); + return { privateKey, publicKeyB64url: Buffer.from(raw).toString("base64url") }; +} + +async function signToken( + claims: Record, + privateKey: CryptoKey, +): Promise { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + const header = encode({ alg: "EdDSA", typ: "JWT" }); + const payload = encode(claims); + const signature = await crypto.subtle.sign( + { name: "Ed25519" }, + privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ); + return `${header}.${payload}.${Buffer.from(signature).toString("base64url")}`; +} + +describe("applyActorCompat", () => { + it("an EMPTY canonical entry is still an actor (never a plain Worker)", () => { + const out = applyActorCompat("base44/actors/Room/entry.ts", { + "base44/actors/Room/entry.ts": "", + }); + expect(out).not.toBeNull(); + expect(out!.handlerName).toBe("Room"); + }); + + it("a canonical actors path IS an actor: wrapper re-exports the default under the FOLDER name", () => { + const entry = "base44/actors/GameRoom/entry.ts"; + const source = `import { Actor } from "base44:runtime/actors";\n${ACTOR_HANDLER}`; + + const result = applyActorCompat(entry, { [entry]: source }); + expect(result).not.toBeNull(); + // The user source stays untouched while the generated wrapper receives the + // same request-scoped runtime as ordinary functions. + expect(result!.files[entry]).toBe(source); + expect(result!.files[SHIM_FILENAME]).toContain( + STATIC_EGRESS_ARTIFACT_MARKER, + ); + // The generated DO wrapper is added under the reserved entry name. + expect(result!.entry).toBe(ACTOR_ENTRY_FILENAME); + const prelude = result!.files[ACTOR_PRELUDE_FILENAME]; + expect(prelude).toContain("installStaticEgressFetch()"); + const wrapper = result!.files[ACTOR_ENTRY_FILENAME]; + expect(wrapper).toContain(`import Base44UserActor from "./${entry}"`); + expect( + wrapper.indexOf(`import "./${ACTOR_PRELUDE_FILENAME}"`), + ).toBeLessThan(wrapper.indexOf(`import Base44UserActor from "./${entry}"`)); + expect(wrapper).toContain("runWithWorkerEnvironment as _b44Run"); + expect(wrapper).not.toContain("base44.workerEnvironment"); + expect(wrapper).toContain("export class GameRoom extends Base44UserActor"); + expect(wrapper).toContain("Reflect.construct(Base44UserActor, [ctx, env], newTarget)"); + expect(wrapper).toContain( + "_b44RunActor(this, null, () => super.onStart(...args))", + ); + expect(wrapper).toContain( + "_b44RunActor(this, null, () => super.onError(...args))", + ); + expect(result!.handlerName).toBe("GameRoom"); + expect(result!.doClassName).toBe("GameRoom"); + + const auth = result!.files[ACTOR_AUTH_FILENAME]; + expect(auth).toContain('header?.alg !== "EdDSA"'); + expect(auth).toContain('claims.aud !== "base44-actor-connect"'); + expect(auth).toContain("claims.actor_script_id !== scriptId"); + expect(auth).toContain("claims.connection_id !== new URL(request.url).searchParams.get(\"_pk\")"); + expect(wrapper.indexOf("_b44VerifyActorConnection")) + .toBeLessThan(wrapper.indexOf("idFromName(room)")); + }); + + it("the user's class name is cosmetic — an anonymous default export names from the folder", () => { + const entry = "base44/actors/Lobby/entry.ts"; + const source = `import { Actor } from "base44:runtime/actors";\nexport default class MyWeirdName extends Actor {}`; + const result = applyActorCompat(entry, { [entry]: source }); + expect(result).not.toBeNull(); + expect(result!.handlerName).toBe("Lobby"); + }); + + it("a non-actors path is never an actor, whatever its source says", () => { + const entry = "base44/functions/api/entry.ts"; + const source = `import { Actor } from "base44:runtime/actors";\n${ACTOR_HANDLER}`; + expect(applyActorCompat(entry, { [entry]: source })).toBeNull(); + }); + + it("a non-canonical entry under actors/ is not detected (helpers are not entries)", () => { + const entry = "base44/actors/GameRoom/lib/entry.ts"; + expect(applyActorCompat(entry, { [entry]: ACTOR_HANDLER })).toBeNull(); + }); + + it("rejects a user file colliding with the generated wrapper filename", () => { + const entry = "base44/actors/GameRoom/entry.ts"; + const files = { + [entry]: ACTOR_HANDLER, + [ACTOR_ENTRY_FILENAME]: "user file", + }; + expect(() => applyActorCompat(entry, files)).toThrow(/reserved filename/i); + }); + it("rejects a trusted standard-entry filename in an actor bundle", () => { + const entry = "base44/actors/GameRoom/entry.ts"; + const files = { + [entry]: ACTOR_HANDLER, + [STANDARD_ENTRY_FILENAME]: + 'export { workerEnvironment } from "base44:internal/runtime-context";', + }; + + expect(() => applyActorCompat(entry, files)).toThrow(/reserved filename/i); + }); + + it("rejects a user file colliding with the generated prelude filename", () => { + const entry = "base44/actors/GameRoom/entry.ts"; + const files = { + [entry]: ACTOR_HANDLER, + [ACTOR_PRELUDE_FILENAME]: "user file", + }; + + expect(() => applyActorCompat(entry, files)).toThrow(/reserved filename/i); + }); + + it("rejects a user file colliding with the generated Actor auth filename", () => { + const entry = "base44/actors/GameRoom/entry.ts"; + const files = { + [entry]: ACTOR_HANDLER, + [ACTOR_AUTH_FILENAME]: "user file", + }; + + expect(() => applyActorCompat(entry, files)).toThrow(/reserved filename/i); + }); + + it("validates every route-bound claim before producing the trusted Actor request", async () => { + const entry = "base44/actors/GameRoom/entry.ts"; + const result = applyActorCompat(entry, { [entry]: ACTOR_HANDLER })!; + const authSource = result.files[ACTOR_AUTH_FILENAME]; + const moduleUrl = `data:text/javascript;base64,${Buffer.from(authSource).toString("base64")}`; + const auth = await import(/* @vite-ignore */ moduleUrl); + const now = Math.floor(Date.now() / 1_000); + const scriptId = "actor-p-0123456789abcdef01234567-aaaaaaaaaaaaaaaaaaaaaaaaaa"; + const { privateKey, publicKeyB64url } = await makeActorKeypair(); + const token = await signToken({ + iss: "base44", + aud: "base44-actor-connect", + purpose: "actor-connect", + v: 1, + iat: now, + nbf: now, + exp: now + 300, + jti: "token-1", + app_id: "0123456789abcdef01234567", + actor_name: "GameRoom", + actor_script_id: scriptId, + room: "room-1", + connection_id: "tab-1", + runtime_mode: "prod", + principal: { type: "anonymous", anonymousId: "browser-1" }, + }, privateKey); + const request = new Request( + `https://worker.example/rooms/room-1?_pk=tab-1&token=${token}`, + ); + const env = { + BASE44_ACTOR_PUBLIC_KEY: publicKeyB64url, + BASE44_ACTOR_SCRIPT_ID: scriptId, + BASE44_APP_ID: "0123456789abcdef01234567", + BASE44_FUNCTIONS_VERSION: "prod", + }; + + const valid = await auth.verifyActorConnection(request, env, "GameRoom", "room-1"); + const wrongRoom = await auth.verifyActorConnection(request, env, "GameRoom", "room-2"); + expect(valid).toMatchObject({ + ok: true, + identity: { type: "anonymous", anonymousId: "browser-1" }, + runtimeMode: "prod", + }); + expect(wrongRoom).toMatchObject({ ok: false, response: { status: 401 } }); + + const trusted = auth.authorizedActorRequest( + request, + valid.identity, + "GameRoom", + "room-1", + valid.runtimeMode, + ); + expect(new URL(trusted.url).pathname).toBe("/parties/GameRoom/room-1"); + expect(new URL(trusted.url).searchParams.has("token")).toBe(false); + expect(trusted.headers.get("X-Base44-Actor-Token")).toBeNull(); + expect(JSON.parse(trusted.headers.get("X-Base44-Actor-Identity")!)).toEqual( + valid.identity, + ); + }); +}); diff --git a/packages/functions-compiler/test/actor-schedule.test.ts b/packages/functions-compiler/test/actor-schedule.test.ts new file mode 100644 index 000000000..7c2bed14c --- /dev/null +++ b/packages/functions-compiler/test/actor-schedule.test.ts @@ -0,0 +1,280 @@ +// Scheduled-wake contract of the Actor shim: schedule/cancelSchedule persist, +// onAlarm delete-then-dispatches due wakes, and the single alarm slot is armed +// to the earliest of (next wake, ticker watchdog). +import { describe, expect, it, vi } from "vitest"; + +vi.mock("partyserver", () => ({ + Server: class { + ctx: unknown; + constructor(ctx: unknown) { + this.ctx = ctx; + } + getConnections() { + return (this as { __conns?: unknown[] }).__conns ?? []; + } + broadcast() {} + async fetch() { return new Response("ok"); } + }, + routePartykitRequest: () => undefined, +})); + +const { Actor } = await import("../src/shim/actor"); + +function fakeStorage() { + const map = new Map(); + const state = { alarm: null as number | null }; + return { + state, + get: async (k: string) => map.get(k), + put: async (k: string, v: unknown) => void map.set(k, v), + delete: async (k: string) => map.delete(k), + deleteAll: async () => void map.clear(), + setAlarm: async (t: number) => void (state.alarm = t), + deleteAlarm: async () => void (state.alarm = null), + }; +} + +class Room extends Actor { + woke: string[] = []; + handleConnect() {} + handleMessage() {} + handleTick() {} + handleClose() {} + override async handleWake(key: string) { + if (key === "boom") throw new Error("boom"); + this.woke.push(key); + } +} + +function makeRoom() { + const storage = fakeStorage(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const room = new Room({ storage } as any, {}) as any; + return { room, storage }; +} + +describe("Actor base44:runtime bridge", () => { + it("installs secrets from the DO env; reserved keys and non-strings hidden", () => { + const storage = fakeStorage(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new Room({ storage } as any, { + MY_KEY: "s3cret", + BASE44_ACTOR_PRIVATE_KEY: "private-key", + BASE44_ACTOR_PUBLIC_KEY: "public-key", + BASE44_ACTOR_SCRIPT_ID: "actor-script", + BASE44_PRIVATE_DATA_SOURCES: "manifest", + SOME_BINDING: { hyperdrive: true }, + }); + const bridge = (globalThis as { Base44?: { secrets: { get(n: unknown): string | undefined } } }).Base44!; + expect(bridge.secrets.get("MY_KEY")).toBe("s3cret"); + expect(bridge.secrets.get("BASE44_ACTOR_PRIVATE_KEY")).toBeUndefined(); + expect(bridge.secrets.get("BASE44_ACTOR_PUBLIC_KEY")).toBeUndefined(); + expect(bridge.secrets.get("BASE44_ACTOR_SCRIPT_ID")).toBeUndefined(); + expect(bridge.secrets.get("BASE44_PRIVATE_DATA_SOURCES")).toBeUndefined(); + expect(bridge.secrets.get("SOME_BINDING")).toBeUndefined(); + // boxed-String coercion must not leak the reserved manifest + expect(bridge.secrets.get(new String("BASE44_PRIVATE_DATA_SOURCES"))).toBeUndefined(); + }); +}); + +describe("Actor verified connection identity", () => { + it("exposes identity for every lifecycle event and restores it from a hibernation attachment", async () => { + const storage = fakeStorage(); + const seen: unknown[] = []; + class IdentityRoom extends Actor { + handleConnect(conn: unknown) { seen.push(conn); } + handleMessage(conn: unknown) { seen.push(conn); } + handleTick() {} + handleClose(conn: unknown) { seen.push(conn); } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const room = new IdentityRoom({ storage } as any, {}) as any; + let attachment: unknown = null; + const deserializeAttachment = vi.fn(() => structuredClone(attachment)); + const ws = { + id: "tab-1", + send: vi.fn(), + close: vi.fn(), + deserializeAttachment, + serializeAttachment: (value: unknown) => { attachment = value; }, + }; + room.__conns = [ws]; + const identity = { type: "authenticated", userId: "user-1" }; + await room.onConnect(ws, { + request: new Request("https://do/parties/IdentityRoom/room-1?_pk=tab-1", { + headers: { "X-Base44-Actor-Identity": JSON.stringify(identity) }, + }), + }); + room.identities.clear(); + deserializeAttachment.mockClear(); + await room.onMessage(ws, JSON.stringify({ type: "event" })); + await room.onMessage(ws, JSON.stringify({ type: "event" })); + const [restoredConnection] = room.getConnections(); + + expect(deserializeAttachment).toHaveBeenCalledOnce(); + await room.onClose(ws); + + expect(seen.map((conn: any) => conn.identity)).toEqual([ + identity, + identity, + identity, + identity, + ]); + expect(Object.isFrozen((seen[0] as any).identity)).toBe(true); + const restoredIdentity = (seen[1] as any).identity; + expect(Object.isFrozen(restoredIdentity)).toBe(true); + expect((seen[2] as any).identity).toBe(restoredIdentity); + expect(restoredConnection.identity).toBe(restoredIdentity); + expect((seen[3] as any).identity).toBe(restoredIdentity); + }); + + it("memoizes a missing identity after hibernation", async () => { + const storage = fakeStorage(); + const seen: unknown[] = []; + class IdentityRoom extends Actor { + handleConnect() {} + handleMessage(conn: unknown) { seen.push(conn); } + handleTick() {} + handleClose() {} + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const room = new IdentityRoom({ storage } as any, {}) as any; + const deserializeAttachment = vi.fn(() => null); + const ws = { + id: "legacy-tab", + send: vi.fn(), + close: vi.fn(), + deserializeAttachment, + }; + room.__conns = [ws]; + + await room.onMessage(ws, JSON.stringify({ type: "first" })); + await room.onMessage(ws, JSON.stringify({ type: "second" })); + const [connection] = room.getConnections(); + + expect(deserializeAttachment).toHaveBeenCalledOnce(); + expect(seen.map((conn: any) => conn.identity)).toEqual([undefined, undefined]); + expect(connection.identity).toBeUndefined(); + }); +}); + +describe("Actor supersede on reconnect (hibernate:false / no attachment API)", () => { + it("supersedes a stale same-id socket without throwing when the attachment API is absent", async () => { + const storage = fakeStorage(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const room = new Room({ storage } as any, {}) as any; + // In-memory (hibernate:false) socket: the attachment API throws. + const stale = { + id: "tab-1", + deserializeAttachment: () => { throw new Error("no attachment API"); }, + serializeAttachment: () => { throw new Error("no attachment API"); }, + close: vi.fn(), + }; + room.__conns = [stale]; + const req = { + url: "https://do/parties/Room/room-1?_pk=tab-1", + headers: { get: (h: string) => (h === "Upgrade" ? "websocket" : null) }, + }; + // Must NOT throw (the attachment write is guarded); the id-rename + close + // fallback still marks the superseded socket for the in-memory manager. + await expect(room.fetch(req)).resolves.toBeDefined(); + expect(stale.close).toHaveBeenCalledWith(4408, "Superseded by reconnect"); + expect(stale.id).toBe("__superseded__:tab-1"); + }); +}); + +describe("Actor scheduled wakes", () => { + it("schedule arms the alarm; onAlarm dispatches due keys once and disarms", async () => { + const { room, storage } = makeRoom(); + const at = Date.now() - 1; // already due + await room.schedule("start", at); + expect(storage.state.alarm).toBe(at); + + await room.onAlarm(); + expect(room.woke).toEqual(["start"]); + expect(await storage.get("__schedules")).toEqual({}); + expect(storage.state.alarm).toBeNull(); // nothing left to wake, no loop + + await room.onAlarm(); // alarm retry must not replay the wake + expect(room.woke).toEqual(["start"]); + }); + + it("a throwing handleWake still consumes its key and fires siblings", async () => { + const { room, storage } = makeRoom(); + await room.schedule("boom", Date.now() - 2); + await room.schedule("ok", Date.now() - 1); + + await room.onAlarm(); + expect(room.woke).toEqual(["ok"]); + expect(await storage.get("__schedules")).toEqual({}); + }); + + it("future wakes stay persisted and keep the alarm armed", async () => { + const { room, storage } = makeRoom(); + const due = Date.now() - 1; + const later = Date.now() + 60_000; + await room.schedule("now", due); + await room.schedule("later", later); + + await room.onAlarm(); + expect(room.woke).toEqual(["now"]); + expect(await storage.get("__schedules")).toEqual({ later }); + expect(storage.state.alarm).toBe(later); + }); + + it("concurrent schedule calls both persist (no read-modify-write loss)", async () => { + const { room, storage } = makeRoom(); + const t1 = Date.now() + 10_000; + const t2 = Date.now() + 20_000; + await Promise.all([room.schedule("a", t1), room.schedule("b", t2)]); + expect(await storage.get("__schedules")).toEqual({ a: t1, b: t2 }); + }); + + it("a stop during handleTick is preserved (deleteAll inside a tick)", async () => { + const { room, storage } = makeRoom(); + room.__conns = [{}]; // occupied room, managed ticker active + room.shouldTick = () => true; + room.handleTick = async () => { + await room.storage.deleteAll(); // stopLoop + wipe, mid-tick + }; + await room.startLoop(50); + const keepGoing = await room.runOneTick(); + expect(keepGoing).toBe(false); + expect(room.ticking).toBe(false); // the tick's write-back must not undo the stop + expect(await storage.get("__loop_ms")).toBeUndefined(); + }); + + it("interleaved cancel + schedule leaves the alarm armed for the survivor", async () => { + const { room, storage } = makeRoom(); + const t1 = Date.now() + 10_000; + const t2 = Date.now() + 20_000; + await room.schedule("a", t1); + // A stale cancel's rearm must not clobber the newer schedule's alarm. + await Promise.all([room.cancelSchedule("a"), room.schedule("b", t2)]); + expect(await storage.get("__schedules")).toEqual({ b: t2 }); + expect(storage.state.alarm).toBe(t2); + }); + + it("cancelSchedule removes the wake and disarms", async () => { + const { room, storage } = makeRoom(); + await room.schedule("gone", Date.now() + 60_000); + await room.cancelSchedule("gone"); + expect(await storage.get("__schedules")).toEqual({}); + expect(storage.state.alarm).toBeNull(); + }); + + it("with the ticker running, the alarm is the earlier of watchdog and wake", async () => { + const { room, storage } = makeRoom(); + await room.startLoop(50); // arms the ~30s watchdog + const watchdog = storage.state.alarm; + expect(watchdog).not.toBeNull(); + + const soon = Date.now() + 1_000; // earlier than the watchdog + await room.schedule("turn-timeout", soon); + expect(storage.state.alarm).toBe(soon); + + await room.cancelSchedule("turn-timeout"); + expect(storage.state.alarm).toBeGreaterThanOrEqual(watchdog!); + await room.stopLoop(); + }); +}); diff --git a/packages/functions-compiler/test/assembly-conflict.test.ts b/packages/functions-compiler/test/assembly-conflict.test.ts new file mode 100644 index 000000000..de1616e48 --- /dev/null +++ b/packages/functions-compiler/test/assembly-conflict.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { importsConflictingPackage } from "../src/bundler"; +import type { BundleErrorItem } from "../src/errors"; + +// The real assembly error from prod app 68da7245efa2ba7a0ede4746: date-fns-tz@2 +// deep-imports date-fns subpaths that date-fns@4's exports map doesn't expose. +const TZ_CONFLICT: BundleErrorItem[] = [ + { + message: + "[ERR_PACKAGE_PATH_NOT_EXPORTED] Package subpath './format/index.js' is not " + + "defined by \"exports\" in '/home/node/.cache/deno/npm/registry.npmjs.org/" + + "date-fns/4.1.0/package.json' imported from 'file:///home/node/.cache/deno/" + + "npm/registry.npmjs.org/date-fns-tz/2.0.1/esm/format/index.js'", + }, +]; + +const entry = (source: string) => ({ + index: 0, + fn: { name: "fn", entry: "main.ts", files: { "main.ts": source } }, +}); + +describe("importsConflictingPackage", () => { + it("blames a function importing the failing major", () => { + expect( + importsConflictingPackage( + entry("import { format } from 'npm:date-fns-tz@^2.0.0';"), + TZ_CONFLICT, + ), + ).toBe(true); + }); + + it("blames an unpinned import of the failing package", () => { + expect( + importsConflictingPackage( + entry("import { format } from 'npm:date-fns-tz';"), + TZ_CONFLICT, + ), + ).toBe(true); + }); + + it("exonerates a function pinned to a different major", () => { + expect( + importsConflictingPackage( + entry("import { toZonedTime } from 'npm:date-fns-tz@3.2.0';"), + TZ_CONFLICT, + ), + ).toBe(false); + }); + + it("does not blame importers of the resolved-to package", () => { + // date-fns@4 is where resolution landed, not the package whose imports + // broke — its importers are fine alone and together. + expect( + importsConflictingPackage( + entry("import { addMinutes } from 'npm:date-fns@4.1.0';"), + TZ_CONFLICT, + ), + ).toBe(false); + }); + + it("handles scoped packages", () => { + const errors: BundleErrorItem[] = [ + { + message: + "[ERR_PACKAGE_PATH_NOT_EXPORTED] Package subpath './x' is not defined " + + "by \"exports\" in '/cache/npm/registry.npmjs.org/left-pad/1.0.0/package.json' " + + "imported from 'file:///cache/npm/registry.npmjs.org/@acme/utils/2.3.0/esm/x.js'", + }, + ]; + expect( + importsConflictingPackage( + entry("import { pad } from 'npm:@acme/utils@^2.0.0';"), + errors, + ), + ).toBe(true); + expect( + importsConflictingPackage( + entry("import { pad } from 'npm:@acme/utils@3.1.0';"), + errors, + ), + ).toBe(false); + }); + + it("blames nothing when errors carry no npm origin", () => { + expect( + importsConflictingPackage( + entry("import { format } from 'npm:date-fns-tz@^2.0.0';"), + [{ message: "something exploded" }], + ), + ).toBe(false); + }); +}); diff --git a/packages/functions-compiler/test/base44-sdk-stub.ts b/packages/functions-compiler/test/base44-sdk-stub.ts new file mode 100644 index 000000000..f4c86d81e --- /dev/null +++ b/packages/functions-compiler/test/base44-sdk-stub.ts @@ -0,0 +1,6 @@ +// Test stub for the `npm:@base44/sdk` external import in src/shim/actor.ts. +// At deploy the Deno resolver bundles the real SDK; under vitest we only need a +// resolvable `createClient` (the actor tests never touch `this.client`). +export function createClient(config: unknown): unknown { + return { config }; +} diff --git a/packages/functions-compiler/test/classify-app-errors.test.ts b/packages/functions-compiler/test/classify-app-errors.test.ts new file mode 100644 index 000000000..0ca20a954 --- /dev/null +++ b/packages/functions-compiler/test/classify-app-errors.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { classifyAppErrors } from "../src/bundler"; + +describe("classifyAppErrors", () => { + it("attributes an fn_/ error to that function and strips the prefix", () => { + const { byIndex, unattributable } = classifyAppErrors([ + { message: "boom", file: "fn_2/main.ts", line: 3 }, + ]); + expect(unattributable).toEqual([]); + expect(byIndex.get(2)).toEqual([ + { message: "boom", file: "main.ts", line: 3 }, + ]); + }); + + it("groups multiple errors for the same function", () => { + const { byIndex } = classifyAppErrors([ + { message: "a", file: "fn_0/x.ts" }, + { message: "b", file: "fn_0/utils/y.ts" }, + ]); + expect(byIndex.get(0)).toEqual([ + { message: "a", file: "x.ts" }, + { message: "b", file: "utils/y.ts" }, + ]); + }); + + it("treats node_modules and location-less errors as unattributable", () => { + // These can't be pinned to one function, so the caller falls back to the + // per-function path rather than dropping the wrong function. + const { byIndex, unattributable } = classifyAppErrors([ + { message: "dep broke", file: "node_modules/foo/index.js" }, + { message: "no location" }, + ]); + expect(byIndex.size).toBe(0); + expect(unattributable).toHaveLength(2); + }); +}); diff --git a/packages/functions-compiler/test/deno-shim-apis.e2e.test.ts b/packages/functions-compiler/test/deno-shim-apis.e2e.test.ts new file mode 100644 index 000000000..74a90f6b3 --- /dev/null +++ b/packages/functions-compiler/test/deno-shim-apis.e2e.test.ts @@ -0,0 +1,137 @@ +// Proves the vendored @deno/shim-deno surface actually runs under workerd +// (nodejs_compat), not just that it bundles. + +import { describe, expect, it } from "vitest"; + +import { bundleOrThrow } from "./helpers"; +import { runInWorkerd } from "./workerd"; + +describe("Deno API surface in workerd (vendored @deno/shim-deno)", () => { + it("Deno.writeTextFile + readTextFile round-trip in the in-memory FS", async () => { + const m = await bundleOrThrow(` + Deno.serve(async () => { + const path = "/tmp/hello.txt"; + await Deno.writeTextFile(path, "hi from deno fs"); + return new Response(await Deno.readTextFile(path)); + }); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("hi from deno fs"); + }); + + it("Deno.writeFile + readFile round-trip bytes", async () => { + const m = await bundleOrThrow(` + Deno.serve(async () => { + const path = "/tmp/bytes.bin"; + const data = new Uint8Array([0, 1, 2, 250, 255]); + await Deno.writeFile(path, data); + const back = await Deno.readFile(path); + const equal = back.length === data.length && back.every((b, i) => b === data[i]); + return new Response(String(equal)); + }); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("true"); + }); + + it("Deno.mkdir (recursive) + readDir lists created entries", async () => { + const m = await bundleOrThrow(` + Deno.serve(async () => { + await Deno.mkdir("/tmp/d/sub", { recursive: true }); + await Deno.writeTextFile("/tmp/d/a.txt", "a"); + await Deno.writeTextFile("/tmp/d/b.txt", "b"); + const entries = []; + for await (const e of Deno.readDir("/tmp/d")) { + entries.push(e.name + ":" + (e.isFile ? "f" : e.isDirectory ? "d" : "?")); + } + entries.sort(); + return Response.json(entries); + }); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual(["a.txt:f", "b.txt:f", "sub:d"]); + }); + + it("Deno.makeTempFile creates writable scratch space", async () => { + const m = await bundleOrThrow(` + Deno.serve(async () => { + const file = await Deno.makeTempFile(); + await Deno.writeTextFile(file, "scratch"); + return Response.json({ + isString: typeof file === "string", + content: await Deno.readTextFile(file), + }); + }); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ isString: true, content: "scratch" }); + }); + + it("Deno.stat reports file info; remove deletes (Deno.errors.NotFound)", async () => { + const m = await bundleOrThrow(` + Deno.serve(async () => { + const path = "/tmp/s.txt"; + await Deno.writeTextFile(path, "12345"); + const info = await Deno.stat(path); + await Deno.remove(path); + let notFound = false; + try { await Deno.stat(path); } + catch (e) { notFound = e instanceof Deno.errors.NotFound; } + // no mtime: CF's node:fs returns the Unix epoch. + return Response.json({ isFile: info.isFile, size: info.size, notFound }); + }); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ isFile: true, size: 5, notFound: true }); + }); + + it("Deno.memoryUsage returns a numeric memory snapshot", async () => { + const m = await bundleOrThrow(` + Deno.serve(() => { + const u = Deno.memoryUsage(); + const ok = ["rss", "heapTotal", "heapUsed", "external"] + .every((k) => typeof u[k] === "number"); + return new Response(String(ok)); + }); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("true"); + }); + + it("Deno.cwd returns a string path", async () => { + const m = await bundleOrThrow( + "Deno.serve(() => new Response(typeof Deno.cwd()));", + ); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("string"); + }); + + // Network-dependent: CF resolves node:dns via DoH (1.1.1.1). + it("Deno.resolveDns resolves NS records via DoH", async () => { + const m = await bundleOrThrow(` + Deno.serve(async () => { + try { + const records = await Deno.resolveDns("cloudflare.com", "NS"); + return Response.json({ ok: Array.isArray(records) && records.length > 0 }); + } catch (e) { + return Response.json({ ok: false, error: e?.message ?? String(e) }); + } + }); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + const body = JSON.parse(text) as { ok: boolean; error?: string }; + // Tolerate an egress failure, but not a broken shim (missing API). + expect(body.error ?? "").not.toMatch( + /is not a function|Cannot read|Dynamic require|No such module/, + ); + expect(body.ok).toBe(true); + }); +}); diff --git a/packages/functions-compiler/test/fetch-guard.test.ts b/packages/functions-compiler/test/fetch-guard.test.ts new file mode 100644 index 000000000..b8da07fa0 --- /dev/null +++ b/packages/functions-compiler/test/fetch-guard.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createGuardedFetch } from "../src/fetch-guard"; + +describe("fetch guard — host allowlist", () => { + it("blocks any host outside the allowlist", async () => { + const original = vi.fn(); + const guarded = createGuardedFetch(original as unknown as typeof fetch); + + for (const url of [ + "https://evil.example.com/payload.ts", + "https://github.com/owner/repo/raw/main/x.ts", + "https://cdn.skypack.dev/lodash", + ]) { + await expect(guarded(url)).rejects.toThrow(/disallowed host/); + } + expect(original).not.toHaveBeenCalled(); + }); + + it("allows the npm/jsr registries and the esm.sh / deno.land CDNs (incl. subdomains)", async () => { + const original = vi.fn(async () => new Response("ok")); + const guarded = createGuardedFetch(original as unknown as typeof fetch); + + const urls = [ + "https://registry.npmjs.org/zod", + "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "https://jsr.io/@std/encoding/meta.json", + "https://npm.jsr.io/@jsr/std__encoding", + "https://esm.sh/is-odd@3.0.1", + "https://cdn.esm.sh/v135/is-number@7.0.0/es2022/is-number.mjs", + "https://deno.land/std@0.224.0/encoding/base64.ts", + ]; + for (const url of urls) await guarded(url); + expect(original).toHaveBeenCalledTimes(urls.length); + }); +}); + +describe("fetch guard — size cap (Content-Length)", () => { + it("returns the original Response object — never rewraps the body", async () => { + // The body stream must reach the loader untouched: rewrapping it + // (TransformStream) corrupts delivery under a live node:http server. + const original = new Response("data"); + const guarded = createGuardedFetch( + (async () => original) as unknown as typeof fetch, + 1024, + ); + const res = await guarded("https://registry.npmjs.org/x"); + expect(res).toBe(original); + expect(await res.text()).toBe("data"); + }); + + it("rejects when Content-Length exceeds the cap (without reading the body)", async () => { + const original = async () => + new Response("x".repeat(50), { headers: { "content-length": "50" } }); + const guarded = createGuardedFetch(original as unknown as typeof fetch, 8); + + await expect(guarded("https://registry.npmjs.org/big.tgz")).rejects.toThrow( + /limit/, + ); + }); + + it("passes a streamed response with no Content-Length through (not capped)", async () => { + // A stream body has no Content-Length, so the cap can't apply — it must + // still pass through untouched rather than be blocked or rewrapped. + const original = async () => + new Response( + new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode("x".repeat(50))); + c.close(); + }, + }), + ); + const guarded = createGuardedFetch(original as unknown as typeof fetch, 8); + + const res = await guarded("https://registry.npmjs.org/streamed"); + expect((await res.text()).length).toBe(50); + }); +}); diff --git a/packages/functions-compiler/test/helpers.ts b/packages/functions-compiler/test/helpers.ts new file mode 100644 index 000000000..167091355 --- /dev/null +++ b/packages/functions-compiler/test/helpers.ts @@ -0,0 +1,40 @@ +/** + * Shared helpers for the compiler specs: compile in-process and assert success. + * + * The HTTP-envelope helpers (postBundle/bundleOk) stay with the bundler service + * in apper — those cover the service's contract, not the engine's. + */ + +import { bundle, bundleApp } from "../src/bundler"; + +/** Bundle in-process (no HTTP server) for specs that run the output in Miniflare. */ +export async function bundleOrThrow( + src: string, + entry = "main.ts", + postResponseTelemetry = false, + runtimeSecrets = false, +): Promise { + const r = await bundle({ + entry, + files: { [entry]: src }, + postResponseTelemetry, + runtimeSecrets, + }); + if (!r.ok) throw new Error(`bundle failed: ${JSON.stringify(r.errors)}`); + return r.module; +} + +/** Bundle a multi-function app in-process; entry defaults to main.ts per function. */ +export async function bundleAppOrThrow( + functions: { name: string; entry?: string; files: Record }[], + postResponseTelemetry = false, + runtimeSecrets = false, +): Promise { + const r = await bundleApp({ + functions: functions.map((f) => ({ ...f, entry: f.entry ?? "main.ts" })), + postResponseTelemetry, + runtimeSecrets, + }); + if (!r.ok) throw new Error(`bundleApp failed: ${JSON.stringify(r.functions)}`); + return r.module; +} diff --git a/packages/functions-compiler/test/ioredis-adapter.test.ts b/packages/functions-compiler/test/ioredis-adapter.test.ts new file mode 100644 index 000000000..88f269c4d --- /dev/null +++ b/packages/functions-compiler/test/ioredis-adapter.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../src/private-data-sources/tcp", () => ({ + isCloudflareTcpSocket: (value: unknown) => value !== null && typeof value === "object", +})); + +import { buildIoredisConnectorFactory } from "../src/private-data-sources/ioredis-adapter"; + +function socket() { + return { + readable: new ReadableStream(), + writable: new WritableStream(), + opened: Promise.resolve(), + closed: new Promise(() => {}), + close: vi.fn(), + }; +} + +describe("ioredis adapter", () => { + it("awaits an authenticated socket before exposing the stream", async () => { + const authenticatedSocket = socket(); + let resolveSocket: (value: ReturnType) => void = () => {}; + const pendingSocket = new Promise>((resolve) => { + resolveSocket = resolve; + }); + const Connector = buildIoredisConnectorFactory(() => pendingSocket); + const connection = new Connector().connect(); + + let settled = false; + void connection.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + resolveSocket(authenticatedSocket); + await expect(connection).resolves.toMatchObject({ writable: true }); + }); +}); diff --git a/packages/functions-compiler/test/package-matrix-data.ts b/packages/functions-compiler/test/package-matrix-data.ts new file mode 100644 index 000000000..42cdf6026 --- /dev/null +++ b/packages/functions-compiler/test/package-matrix-data.ts @@ -0,0 +1,160 @@ +/** + * Package list for the real-world bundle+run matrix (package-matrix.e2e.test.ts). + * + * One isolated diagnostic backend function per npm package — never bundle two + * into one, so each gives a clean per-package signal. Each function imports the + * library, exercises it meaningfully, and returns a consistent JSON envelope: + * { package, version, status: "pass", output } on success + * { package, status: "fail", error } on a caught error (HTTP 500) + * + * `expected` records the CURRENT observed outcome end-to-end (bundle → execute + * in workerd): "pass" = bundles and the handler runs returning status "pass"; + * "fail" = the package does not currently work (bundle error OR runtime throw). + * Failing entries are kept on purpose — the failure is the data point about a + * platform limitation. Do NOT "fix" a function to make it pass; when the + * platform gains the capability, flip `expected` here instead. + */ + +// The redeploy marker mirrors the production builder: whitespace-only changes +// are ignored, so a dated comment is the only reliable redeploy trigger. Inert +// here (just bundled source), kept so these match real functions. +const REDEPLOY = "// redeploy-2026-06-10T01"; + +/** Wrap a library import + exercise in the standard diagnostic-function shape. */ +function fn(pkg: string, version: string, imp: string, body: string): string { + return `${imp} ${REDEPLOY} +Deno.serve(async (req) => { + try { +${body} + return Response.json({ package: ${JSON.stringify(pkg)}, version: ${JSON.stringify(version)}, status: "pass", output }); + } catch (error) { + return Response.json({ package: ${JSON.stringify(pkg)}, status: "fail", error: error.message }, { status: 500 }); + } +});`; +} + +interface PkgCase { + name: string; + pkg: string; + version: string; + expected: "pass" | "fail"; + source: string; +} + +export const PACKAGES: PkgCase[] = [ + { name: "testLodash", pkg: "lodash", version: "4.17.21", expected: "pass", source: fn("lodash", "4.17.21", + "import _ from 'npm:lodash@4.17.21';", + " const output = { sorted: _.sortBy([3,1,2]), chunked: _.chunk([1,2,3,4],2), grouped: _.groupBy([1.1,1.2,2.3], Math.floor), uniq: _.uniq([1,1,2]), merged: _.merge({a:1},{b:2}) };") }, + { name: "testMoment", pkg: "moment", version: "2.30.1", expected: "pass", source: fn("moment", "2.30.1", + "import moment from 'npm:moment@2.30.1';", + " const m = moment('2020-01-01'); const output = { formatted: m.format('YYYY-MM-DD'), fromNow: m.fromNow(), durationMin: moment.duration(2,'hours').asMinutes() };") }, + { name: "testDateFns", pkg: "date-fns", version: "3.6.0", expected: "pass", source: fn("date-fns", "3.6.0", + "import { format, addDays, differenceInDays } from 'npm:date-fns@3.6.0';", + " const d = new Date(2020,0,1); const output = { formatted: format(d,'yyyy-MM-dd'), added: format(addDays(d,5),'yyyy-MM-dd'), diff: differenceInDays(addDays(d,5), d) };") }, + { name: "testUuid", pkg: "uuid", version: "9.0.0", expected: "pass", source: fn("uuid", "9.0.0", + "import { v4, v5 } from 'npm:uuid@9.0.0';", + " const NS='6ba7b810-9dad-11d1-80b4-00c04fd430c8'; const output = { v4: v4(), v5: v5('hello', NS) };") }, + { name: "testCryptoJs", pkg: "crypto-js", version: "4.2.0", expected: "pass", source: fn("crypto-js", "4.2.0", + "import CryptoJS from 'npm:crypto-js@4.2.0';", + " const output = { md5: CryptoJS.MD5('hi').toString(), sha256: CryptoJS.SHA256('hi').toString(), aes: CryptoJS.AES.encrypt('hi','key').toString() };") }, + { name: "testMarked", pkg: "marked", version: "12.0.0", expected: "pass", source: fn("marked", "12.0.0", + "import { marked } from 'npm:marked@12.0.0';", + " const output = { html: marked.parse('# Hi') };") }, + { name: "testSlugify", pkg: "slugify", version: "1.6.6", expected: "pass", source: fn("slugify", "1.6.6", + "import slugify from 'npm:slugify@1.6.6';", + " const output = { slug: slugify('Hello World!', { lower: true }) };") }, + { name: "testValidator", pkg: "validator", version: "13.11.0", expected: "pass", source: fn("validator", "13.11.0", + "import validator from 'npm:validator@13.11.0';", + " const output = { isEmail: validator.isEmail('a@b.com'), isURL: validator.isURL('https://x.com'), isIP: validator.isIP('127.0.0.1') };") }, + { name: "testYaml", pkg: "js-yaml", version: "4.1.0", expected: "pass", source: fn("js-yaml", "4.1.0", + "import yaml from 'npm:js-yaml@4.1.0';", + " const dumped = yaml.dump({ a: 1, b: [2,3] }); const loaded = yaml.load(dumped); const output = { dumped, loaded };") }, + { name: "testNumeral", pkg: "numeral", version: "2.0.6", expected: "pass", source: fn("numeral", "2.0.6", + "import numeral from 'npm:numeral@2.0.6';", + " const output = { currency: numeral(1234.56).format('$0,0.00'), percent: numeral(0.25).format('0%'), bytes: numeral(1024).format('0b') };") }, + { name: "testMimeTypes", pkg: "mime-types", version: "2.1.35", expected: "pass", source: fn("mime-types", "2.1.35", + "import mimeTypes from 'npm:mime-types@2.1.35';", + " const output = { json: mimeTypes.lookup('file.json'), html: mimeTypes.contentType('html') };") }, + { name: "testQs", pkg: "qs", version: "6.12.0", expected: "pass", source: fn("qs", "6.12.0", + "import qs from 'npm:qs@6.12.0';", + " const str = qs.stringify({ a: 1, b: { c: 2 } }); const parsed = qs.parse(str); const output = { str, parsed };") }, + { name: "testJsonwebtoken", pkg: "jsonwebtoken", version: "9.0.2", expected: "pass", source: fn("jsonwebtoken", "9.0.2", + "import jwt from 'npm:jsonwebtoken@9.0.2';", + " const token = jwt.sign({ id: 1 }, 'secret'); const decoded = jwt.verify(token, 'secret'); const output = { token, decoded };") }, + { name: "testCsvStringify", pkg: "csv-stringify", version: "6.4.6", expected: "pass", source: fn("csv-stringify", "6.4.6", + "import { stringify } from 'npm:csv-stringify@6.4.6/sync';", + " const output = { csv: stringify([['a','b'],['1','2']]) };") }, + { name: "testChance", pkg: "chance", version: "1.1.11", expected: "pass", source: fn("chance", "1.1.11", + "import Chance from 'npm:chance@1.1.11';", + " const chance = new Chance(); const output = { name: chance.name(), email: chance.email(), address: chance.address() };") }, + { name: "testDayjs", pkg: "dayjs", version: "1.11.10", expected: "pass", source: fn("dayjs", "1.11.10", + "import dayjs from 'npm:dayjs@1.11.10';", + " const d = dayjs('2020-01-01'); const output = { formatted: d.format('YYYY-MM-DD'), added: d.add(1,'day').format('YYYY-MM-DD'), diff: dayjs('2020-01-10').diff(d,'day') };") }, + { name: "testDiff", pkg: "diff", version: "5.2.0", expected: "pass", source: fn("diff", "5.2.0", + "import { diffWords } from 'npm:diff@5.2.0';", + " const output = { changes: diffWords('hello world','hello there').map(p => ({ value: p.value, added: !!p.added, removed: !!p.removed })) };") }, + // FAILS at runtime — ajv.compile() builds validators with `new Function(...)`; + // workerd forbids runtime code generation ("Code generation from strings + // disallowed"). Bundles fine, throws when the handler runs. + { name: "testAjv", pkg: "ajv", version: "8.12.0", expected: "fail", source: fn("ajv", "8.12.0", + "import Ajv from 'npm:ajv@8.12.0';", + " const ajv = new Ajv(); const validate = ajv.compile({ type: 'object', properties: { x: { type: 'number' } }, required: ['x'] }); const output = { valid: validate({ x: 1 }), invalid: validate({}) };") }, + { name: "testHumanizeDuration", pkg: "humanize-duration", version: "3.31.0", expected: "pass", source: fn("humanize-duration", "3.31.0", + "import humanizeDuration from 'npm:humanize-duration@3.31.0';", + " const output = { human: humanizeDuration(3600000) };") }, + { name: "testPluralize", pkg: "pluralize", version: "8.0.0", expected: "pass", source: fn("pluralize", "8.0.0", + "import pluralize from 'npm:pluralize@8.0.0';", + " const output = { plural: pluralize('apple', 3), singular: pluralize.singular('apples'), isPlural: pluralize.isPlural('apples') };") }, + { name: "testColorConvert", pkg: "color-convert", version: "2.0.1", expected: "pass", source: fn("color-convert", "2.0.1", + "import convert from 'npm:color-convert@2.0.1';", + " const output = { hsl: convert.rgb.hsl(255,0,0), hex: convert.rgb.hex(255,0,0), rgb: convert.hex.rgb('FF0000') };") }, + { name: "testFlatted", pkg: "flatted", version: "3.3.1", expected: "pass", source: fn("flatted", "3.3.1", + "import { stringify, parse } from 'npm:flatted@3.3.1';", + " const obj = {}; obj.self = obj; const str = stringify(obj); const parsed = parse(str); const output = { str, hasSelf: parsed.self === parsed };") }, + { name: "testNanoid", pkg: "nanoid", version: "5.0.4", expected: "pass", source: fn("nanoid", "5.0.4", + "import { nanoid } from 'npm:nanoid@5.0.4';", + " const output = { id: nanoid(), id10: nanoid(10) };") }, + { name: "testZod", pkg: "zod", version: "3.23.8", expected: "pass", source: fn("zod", "3.23.8", + "import { z } from 'npm:zod@3.23.8';", + " const schema = z.object({ name: z.string(), age: z.number() }); const parsed = schema.parse({ name: 'a', age: 1 }); const safe = schema.safeParse({ name: 'a' }); const output = { parsed, safeSuccess: safe.success };") }, + { name: "testAxios", pkg: "axios", version: "1.7.2", expected: "pass", source: fn("axios", "1.7.2", + "import axios from 'npm:axios@1.7.2';", + " const res = await axios.get('https://jsonplaceholder.typicode.com/todos/1'); const output = { id: res.data.id, title: res.data.title };") }, + { name: "testRamda", pkg: "ramda", version: "0.30.1", expected: "pass", source: fn("ramda", "0.30.1", + "import { map, filter, reduce, compose } from 'npm:ramda@0.30.1';", + " const inc = x => x + 1; const isEven = x => x % 2 === 0; const f = compose(filter(isEven), map(inc)); const output = { mapped: map(inc, [1,2,3]), filtered: filter(isEven, [1,2,3,4]), reduced: reduce((a,b)=>a+b, 0, [1,2,3]), composed: f([1,2,3,4]) };") }, + { name: "testFuse", pkg: "fuse.js", version: "7.0.0", expected: "pass", source: fn("fuse.js", "7.0.0", + "import Fuse from 'npm:fuse.js@7.0.0';", + " const fuse = new Fuse(['apple','banana','orange'], {}); const output = { result: fuse.search('aple').map(r => r.item) };") }, + { name: "testJoiValidation", pkg: "joi", version: "17.13.1", expected: "pass", source: fn("joi", "17.13.1", + "import Joi from 'npm:joi@17.13.1';", + " const schema = Joi.object({ name: Joi.string().required(), age: Joi.number() }); const { error, value } = schema.validate({ name: 'a', age: 1 }); const output = { valid: !error, value };") }, + // Now works: the Deno resolver correctly resolves parse5 → entities' ESM + // exports (`htmlDecodeTree`/`EntityDecoder`), which the old homegrown resolver + // could not match. + { name: "testCheerio", pkg: "cheerio", version: "1.0.0", expected: "pass", source: fn("cheerio", "1.0.0", + "import * as cheerio from 'npm:cheerio@1.0.0';", + " const $ = cheerio.load('

Hi

'); const output = { text: $('h1').text() };") }, + { name: "testXlsx", pkg: "xlsx", version: "0.18.5", expected: "pass", source: fn("xlsx", "0.18.5", + "import * as XLSX from 'npm:xlsx@0.18.5';", + " const ws = XLSX.utils.aoa_to_sheet([['a','b'],[1,2]]); const csv = XLSX.utils.sheet_to_csv(ws); const output = { csv };") }, + { name: "testJszip", pkg: "jszip", version: "3.10.1", expected: "pass", source: fn("jszip", "3.10.1", + "import JSZip from 'npm:jszip@3.10.1';", + " const zip = new JSZip(); zip.file('hello.txt', 'world'); const b64 = await zip.generateAsync({ type: 'base64' }); const reloaded = await JSZip.loadAsync(b64, { base64: true }); const text = await reloaded.file('hello.txt').async('string'); const output = { zipped: b64.length > 0, text };") }, + { name: "testCurrencyCodes", pkg: "currency.js", version: "2.0.4", expected: "pass", source: fn("currency.js", "2.0.4", + "import currency from 'npm:currency.js@2.0.4';", + " const output = { sum: currency(1.23).add(4.56).value, product: currency(1.23).multiply(3).value, distributed: currency(10).distribute(3).map(c => c.value) };") }, + { name: "testObjectHash", pkg: "object-hash", version: "3.0.0", expected: "pass", source: fn("object-hash", "3.0.0", + "import objectHash from 'npm:object-hash@3.0.0';", + " const output = { hash: objectHash({ a: 1, b: 2 }), sameHash: objectHash({ b: 2, a: 1 }) };") }, + // Exercises npm package-alias support: @isaacs/cliui's package.json declares + // `"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"` (+ strip-ansi-cjs, string-width-cjs). + { name: "testIsaacsCliui", pkg: "@isaacs/cliui", version: "8.0.2", expected: "pass", source: fn("@isaacs/cliui", "8.0.2", + "import cliui from 'npm:@isaacs/cliui@8.0.2';", + " const ui = cliui({ width: 40 }); ui.div('hello world'); const output = { rendered: ui.toString() };") }, + // Entry in `main`/`module` (no `exports`, no root index.js), root + @jimp/* + // sub-packages alike — resolved via the package.json entry fallback (#17077). + { name: "testJimp", pkg: "jimp", version: "0.16.13", expected: "pass", source: fn("jimp", "0.16.13", + "import Jimp from 'npm:jimp@0.16.13';", + " const img = new Jimp(1, 1, 0xFF0000FF); const base64 = await img.getBase64Async(Jimp.MIME_PNG); const output = { hasData: base64.length > 0 };") }, +]; diff --git a/packages/functions-compiler/test/package-matrix.e2e.test.ts b/packages/functions-compiler/test/package-matrix.e2e.test.ts new file mode 100644 index 000000000..49987972b --- /dev/null +++ b/packages/functions-compiler/test/package-matrix.e2e.test.ts @@ -0,0 +1,75 @@ +/** + * Real-world npm package matrix — bundle AND execute in workerd. + * + * Stress-tests the Deno/Cloudflare bundler with a wide variety of npm packages, + * one isolated backend function per package (see package-matrix-data.ts). Each + * function is bundled via `bundle()` and then run in Miniflare-hosted workerd + * via `runInWorkerd` (production WfP compat config) — so a `pass` means the + * library actually *executed* and the handler returned its success envelope, + * not merely that it bundled. This catches runtime-only failures that a + * bundle-only check misses (e.g. ajv's `new Function` codegen, which workerd + * forbids). Every entry is a real install from registry.npmjs.org, so this + * suite is network-bound and slower than the focused bundle.e2e tests. + * + * Failing packages are kept on purpose — the failure is the data point about a + * current platform limitation. Each `expected: "fail"` row is annotated in + * package-matrix-data.ts; do not "fix" the function to make it pass. When the + * platform gains the capability, flip the expectation there instead. + */ + +import { describe, expect, it } from "vitest"; + +import { bundle } from "../src/bundler"; +import { PACKAGES } from "./package-matrix-data"; +import { runInWorkerd } from "./workerd"; + +type Outcome = + | { kind: "pass"; output: unknown } + | { kind: "bundle-fail"; detail: string } + | { kind: "runtime-fail"; detail: string }; + +/** Bundle one diagnostic function, run it in workerd, and classify the result. + * A clean pass = the worker booted, the handler returned HTTP 200, and the + * function self-reported `status: "pass"`. */ +async function runPackage(source: string): Promise { + const r = await bundle({ entry: "main.ts", files: { "main.ts": source } }); + if (!r.ok) return { kind: "bundle-fail", detail: JSON.stringify(r.errors) }; + + let status: number; + let text: string; + try { + ({ status, text } = await runInWorkerd(r.module)); + } catch (e) { + // The worker threw at instantiation (e.g. a dynamic require at module load). + return { kind: "runtime-fail", detail: `worker-init: ${e instanceof Error ? e.message : String(e)}` }; + } + + let body: { status?: string; output?: unknown; error?: string } | null = null; + try { + body = JSON.parse(text); + } catch { + /* non-JSON response */ + } + if (status === 200 && body?.status === "pass") return { kind: "pass", output: body.output }; + return { kind: "runtime-fail", detail: `status=${status} body=${text.slice(0, 200)}` }; +} + +describe("npm package matrix (bundle + execute in workerd)", () => { + it.each(PACKAGES)( + "$name ($pkg@$version) → expected $expected", + async ({ expected, source }) => { + const result = await runPackage(source); + + if (expected === "pass") { + const detail = result.kind === "pass" ? "" : (result as { detail: string }).detail; + expect(result.kind, `expected a clean workerd run, got ${result.kind}: ${detail}`).toBe("pass"); + // The handler self-reported success and produced some output. + expect((result as { output: unknown }).output).toBeTruthy(); + } else { + // Kept failing on purpose. If it now works, flip `expected` to "pass" + // in package-matrix-data.ts — don't let it silently start passing. + expect(result.kind, "this package now works end-to-end — flip expected to 'pass'").not.toBe("pass"); + } + }, + ); +}); diff --git a/packages/functions-compiler/test/private-data-sources-http-url.test.ts b/packages/functions-compiler/test/private-data-sources-http-url.test.ts new file mode 100644 index 000000000..5309119a2 --- /dev/null +++ b/packages/functions-compiler/test/private-data-sources-http-url.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { resolveHttpPrivateDataSourceUrl } from "../src/private-data-sources/http-url"; +import type { PrivateDataSourceManifestEntry } from "../src/private-data-sources/types"; + +const entry: PrivateDataSourceManifestEntry = { + name: "Wix Trino", + type: "http", + bindingName: "DATA_SOURCE_WIX_TRINO", + bindingKind: "vpc_network", + networkScope: "network", + host: "trino.bi-use1.wixprod.net", + baseUrl: "https://trino.bi-use1.wixprod.net:443", +}; + +describe("resolveHttpPrivateDataSourceUrl", () => { + it("resolves relative paths against the base URL", () => { + expect(resolveHttpPrivateDataSourceUrl(entry, "/v1/statement")).toBe( + "https://trino.bi-use1.wixprod.net/v1/statement", + ); + }); + + it("passes absolute URLs through, including other tunnel hosts (Trino nextUri)", () => { + expect( + resolveHttpPrivateDataSourceUrl(entry, "https://trino-foxtrot.bi-use1.wixprod.net/v1/statement/x"), + ).toBe("https://trino-foxtrot.bi-use1.wixprod.net/v1/statement/x"); + }); + + it("returns Request inputs unchanged", () => { + const req = new Request("https://trino.bi-use1.wixprod.net/v1/info"); + expect(resolveHttpPrivateDataSourceUrl(entry, req)).toBe(req); + }); + + it("coerces URL and other inputs to a string like fetch does", () => { + expect(resolveHttpPrivateDataSourceUrl(entry, new URL("https://trino.bi-use1.wixprod.net/x"))).toBe( + "https://trino.bi-use1.wixprod.net/x", + ); + }); + + it("falls back to a placeholder base when the entry has no base URL", () => { + const noBase: PrivateDataSourceManifestEntry = { name: "x", type: "http" }; + expect(resolveHttpPrivateDataSourceUrl(noBase, "/health")).toBe("http://private-data-source.local/health"); + }); +}); diff --git a/packages/functions-compiler/test/private-data-sources-manifest.test.ts b/packages/functions-compiler/test/private-data-sources-manifest.test.ts new file mode 100644 index 000000000..a4a674a50 --- /dev/null +++ b/packages/functions-compiler/test/private-data-sources-manifest.test.ts @@ -0,0 +1,127 @@ +/** + * Manifest resolution source. Runtime-secrets bundles receive + * BASE44_PRIVATE_DATA_SOURCES through the private, single-instance + * runtime-manifest store the activation shim writes (not a global, not + * process.env — see runtime-manifest-store.ts); old-mode bundles read the + * immutable `cloudflare:workers` binding. The lookup must: + * - prefer the store-delivered manifest, else fall back to the binding; + * - NEVER trust process.env (user code can write it) for the manifest; + * - re-parse when the raw manifest changes (rotation re-delivery); + * - pin a handshake-delivered manifest to the DEPLOYED binding set (a fresh + * manifest must not surface a source whose Hyperdrive/VPC binding this + * frozen script lacks). + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +// manifest.ts reads the Worker env via workerEnvironment() (the request-scoped +// runtime context), so mock that module rather than the raw cloudflare:workers +// binding. currentWorkerRuntimeContext() is truthy — we're "in a request". +const mockCfEnv = vi.hoisted(() => ({} as Record)); +vi.mock("../src/private-data-sources/runtime-environment", () => ({ + workerEnvironment: () => mockCfEnv, + currentWorkerRuntimeContext: () => ({}), +})); + +import { lookupPrivateDataSource } from "../src/private-data-sources/manifest"; +import { + getRuntimeManifest, + setRuntimeManifest, +} from "../src/private-data-sources/runtime-manifest-store"; + +const MANIFEST_ENV = "BASE44_PRIVATE_DATA_SOURCES"; + +const entry = (name: string) => ({ + name, + type: "postgres", + bindingName: `DATA_SOURCE_${name.toUpperCase()}_ABC123`, + bindingKind: "vpc_service", + host: "10.0.0.1", + port: 5432, + database: "db", + username: "u", + password: "hunter2", +}); + +function bindLive(name: string) { + // The live VPC/Hyperdrive handle the script was uploaded with. + mockCfEnv[entry(name).bindingName] = { __handle: name }; +} + +// The activation shim writes the manifest into the private store; manifest.ts +// reads it from the same module. Here we write it directly (unit context). +function deliverViaHandshake(json: string | undefined) { + setRuntimeManifest(json); +} + +afterEach(() => { + delete process.env[MANIFEST_ENV]; + setRuntimeManifest(undefined); + for (const key of Object.keys(mockCfEnv)) delete mockCfEnv[key]; +}); + +describe("private data source manifest resolution", () => { + it("resolves a handshake-delivered manifest from the private store", () => { + bindLive("pg"); + deliverViaHandshake(JSON.stringify([entry("pg")])); + + expect(lookupPrivateDataSource("pg", "postgres").password).toBe("hunter2"); + expect(getRuntimeManifest()).toContain("hunter2"); // store holds it, not a global + }); + + it("falls back to the cloudflare:workers binding in old mode", () => { + bindLive("legacy"); + mockCfEnv[MANIFEST_ENV] = JSON.stringify([entry("legacy")]); + + expect(lookupPrivateDataSource("legacy", "postgres").bindingName).toBe( + "DATA_SOURCE_LEGACY_ABC123", + ); + }); + + it("ignores a manifest forged in process.env (user code cannot spoof it)", () => { + // User code sets process.env[MANIFEST_ENV] to retarget a source; the real + // manifest comes only from the private store / binding. + bindLive("real"); + deliverViaHandshake(JSON.stringify([entry("real")])); + process.env[MANIFEST_ENV] = JSON.stringify([ + { ...entry("real"), host: "attacker.internal" }, + ]); + + expect(lookupPrivateDataSource("real", "postgres").host).toBe("10.0.0.1"); + }); + + it("ignores process.env entirely when no manifest is delivered", () => { + bindLive("evil"); + process.env[MANIFEST_ENV] = JSON.stringify([entry("evil")]); + + expect(() => lookupPrivateDataSource("evil")).toThrowError(/not bound/); + }); + + it("re-reads the manifest when the raw value changes (rotation re-delivery)", () => { + bindLive("first"); + bindLive("second"); + deliverViaHandshake(JSON.stringify([entry("first")])); + expect(lookupPrivateDataSource("first").name).toBe("first"); + + deliverViaHandshake(JSON.stringify([entry("second")])); + expect(lookupPrivateDataSource("second").name).toBe("second"); + expect(() => lookupPrivateDataSource("first")).toThrowError(/not bound/); + }); + + it("drops entries whose binding this script was not deployed with", () => { + // A source added after this script's upload arrives in a fresh manifest + // but its live binding is frozen out — the entry must not resolve. + bindLive("deployed"); + deliverViaHandshake(JSON.stringify([entry("deployed"), entry("added_later")])); + + expect(lookupPrivateDataSource("deployed").name).toBe("deployed"); + expect(() => lookupPrivateDataSource("added_later")).toThrowError(/not bound/); + }); + + it("throws for a source missing from the manifest", () => { + bindLive("pg"); + deliverViaHandshake(JSON.stringify([entry("pg")])); + + expect(() => lookupPrivateDataSource("nope")).toThrowError(/not bound/); + }); +}); diff --git a/packages/functions-compiler/test/private-data-sources-request-scope.test.ts b/packages/functions-compiler/test/private-data-sources-request-scope.test.ts new file mode 100644 index 000000000..9d5160e92 --- /dev/null +++ b/packages/functions-compiler/test/private-data-sources-request-scope.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; + +import { http } from "../src/private-data-sources/http"; +import { postgres } from "../src/private-data-sources/postgres"; +import { sqlserver } from "../src/private-data-sources/sqlserver"; +import { runWithWorkerEnvironment } from "../src/runtime-context"; + +type WorkerEnv = Record; + +function withEnvironment(env: WorkerEnv, callback: () => T): T { + return runWithWorkerEnvironment( + { + env: "preview", + secrets: env, + workerEnv: env, + waitUntil() {}, + }, + callback, + ); +} + +function manifest( + type: string, + bindingName: string, + extra: Record = {}, +): string { + return JSON.stringify([ + { + name: "Primary", + type, + bindingName, + ...extra, + }, + ]); +} + +describe("request-scoped private data source bindings", () => { + it("uses the current HTTP binding and manifest for every fetch", async () => { + const fetchA = vi.fn( + async (request: RequestInfo | URL) => + new Response(`a:${String(request)}`), + ); + const fetchB = vi.fn( + async (request: RequestInfo | URL) => + new Response(`b:${String(request)}`), + ); + const envA = { + BASE44_PRIVATE_DATA_SOURCES: manifest("http", "HTTP_A", { + baseUrl: "https://a.internal", + }), + HTTP_A: { fetch: fetchA }, + }; + const envB = { + BASE44_PRIVATE_DATA_SOURCES: manifest("http", "HTTP_B", { + baseUrl: "https://b.internal", + }), + HTTP_B: { fetch: fetchB }, + }; + const source = http("Primary"); + + await withEnvironment(envA, () => source.fetch("/health")); + await withEnvironment(envB, () => source.fetch("/health")); + + expect(fetchA).toHaveBeenCalledWith( + "https://a.internal/health", + undefined, + ); + expect(fetchB).toHaveBeenCalledWith( + "https://b.internal/health", + undefined, + ); + }); + + it("resolves a deferred HTTP raw binding from the current manifest", () => { + const source = http("Primary"); + const fixedBinding = { fetch: vi.fn() }; + const fixedEnv = { + BASE44_PRIVATE_DATA_SOURCES: manifest("http", "HTTP_FIXED"), + HTTP_FIXED: fixedBinding, + }; + const networkEnv = { + BASE44_PRIVATE_DATA_SOURCES: manifest("http", "HTTP_NETWORK", { + networkScope: "network", + }), + HTTP_NETWORK: { fetch: vi.fn() }, + }; + + expect(withEnvironment(fixedEnv, () => source.binding)).toBe(fixedBinding); + expect(withEnvironment(networkEnv, () => source.binding)).toBeUndefined(); + }); + + it("uses the current TCP binding for every connection", () => { + const connectA = vi.fn(() => "socket-a"); + const connectB = vi.fn(() => "socket-b"); + const envA = { + BASE44_PRIVATE_DATA_SOURCES: manifest("sqlserver", "TCP_A", { + host: "db-a.internal", + port: 1433, + }), + TCP_A: { connect: connectA }, + }; + const envB = { + BASE44_PRIVATE_DATA_SOURCES: manifest("sqlserver", "TCP_B", { + host: "db-b.internal", + port: 1434, + }), + TCP_B: { connect: connectB }, + }; + const source = sqlserver("Primary"); + + expect(withEnvironment(envA, () => source.connect())).toBe("socket-a"); + expect(withEnvironment(envB, () => source.connect())).toBe("socket-b"); + expect(connectA).toHaveBeenCalledWith( + { hostname: "db-a.internal", port: 1433 }, + undefined, + ); + expect(connectB).toHaveBeenCalledWith( + { hostname: "db-b.internal", port: 1434 }, + undefined, + ); + }); + + it("reads Hyperdrive properties from the current request binding", () => { + const envA = { + BASE44_PRIVATE_DATA_SOURCES: manifest("postgres", "PG_A"), + PG_A: { connectionString: "postgres://a" }, + }; + const envB = { + BASE44_PRIVATE_DATA_SOURCES: manifest("postgres", "PG_B"), + PG_B: { connectionString: "postgres://b" }, + }; + const source = postgres("Primary"); + + expect( + withEnvironment(envA, () => source.connectionString), + ).toBe("postgres://a"); + expect( + withEnvironment(envB, () => source.connectionString), + ).toBe("postgres://b"); + }); +}); diff --git a/packages/functions-compiler/test/redis-auth.test.ts b/packages/functions-compiler/test/redis-auth.test.ts new file mode 100644 index 000000000..16891702c --- /dev/null +++ b/packages/functions-compiler/test/redis-auth.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +import { authenticateRedisSocketIfNeeded } from "../src/private-data-sources/redis-auth"; +import type { CloudflareTcpSocket } from "../src/private-data-sources/types"; + +function redisSocket(response: string) { + const writes: string[] = []; + const close = vi.fn(); + const socket: CloudflareTcpSocket = { + readable: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(response)); + controller.close(); + }, + }), + writable: new WritableStream({ + write(chunk) { + writes.push(new TextDecoder().decode(chunk)); + }, + }), + opened: Promise.resolve(), + close, + }; + return { socket, writes, close }; +} + +describe("Redis private data source authentication", () => { + it("authenticates with an ACL username before returning the native socket", async () => { + const { socket, writes } = redisSocket("+OK\r\n"); + + const connected = await authenticateRedisSocketIfNeeded(socket, "app_user", "secret"); + + expect(connected).toBe(socket); + expect(writes).toEqual(["*3\r\n$4\r\nAUTH\r\n$8\r\napp_user\r\n$6\r\nsecret\r\n"]); + }); + + it("authenticates with only a password for requirepass Redis", async () => { + const { socket, writes } = redisSocket("+OK\r\n"); + + await authenticateRedisSocketIfNeeded(socket, undefined, "secret"); + + expect(writes).toEqual(["*2\r\n$4\r\nAUTH\r\n$6\r\nsecret\r\n"]); + }); + + it("surfaces Redis authentication failures and closes the socket", async () => { + const { socket, close } = redisSocket("-WRONGPASS invalid username-password pair\r\n"); + + await expect(authenticateRedisSocketIfNeeded(socket, undefined, "wrong")).rejects.toThrow( + "Redis authentication failed: WRONGPASS invalid username-password pair", + ); + expect(close).toHaveBeenCalledOnce(); + }); + + it("returns unauthenticated native sockets unchanged", () => { + const { socket, writes } = redisSocket("+OK\r\n"); + + expect(authenticateRedisSocketIfNeeded(socket, undefined, undefined)).toBe(socket); + expect(writes).toEqual([]); + }); +}); diff --git a/packages/functions-compiler/test/runtime-secrets.e2e.test.ts b/packages/functions-compiler/test/runtime-secrets.e2e.test.ts new file mode 100644 index 000000000..2d0567b1e --- /dev/null +++ b/packages/functions-compiler/test/runtime-secrets.e2e.test.ts @@ -0,0 +1,600 @@ +/** + * Runtime-secrets activation, end to end in real workerd. + * + * v3 protocol: the SECRETS are baked into the script as an encrypted blob, + * split across `BASE44_SECRETS_BLOB_` bindings; the activation handshake + * delivers only the app DATA KEY, sealed to the isolate's ephemeral public key. + * The "backend" side is simulated with Node WebCrypto using the exact protocol + * from backend/app/cloudflare_functions/activation_handshake.py: + * + * envelope = backendPub(65) || k(1) || k*[nonce(12) || wrap(48)] (constant size) + * wrap = AES-256-GCM(KEK_i, nonce_i, dataKey(32), AAD=app_id) + * KEK_i = ECDH P-256 + HKDF-SHA256(info="base44-runtime-secrets-v1") + * blob = keyId(1) || nonce(12) || AES-256-GCM(dataKey, nonce, deflate(JSON), AAD) + * + * A persistent Miniflare instance stands in for one isolate, so sequential + * requests exercise the cold → activated → warm lifecycle. + */ + +import { deflateSync } from "node:zlib"; + +import { describe, expect, it } from "vitest"; +import { Miniflare } from "miniflare"; + +import { bundleOrThrow as bundled, bundleAppOrThrow as bundledApp } from "./helpers"; +import { WFP_COMPAT_DATE } from "./workerd"; + +const APP_ID = "app-e2e-1"; +const NEEDS_ACTIVATION = "X-Base44-Needs-Activation"; +const RUNTIME_SECRETS = "Base44-Runtime-Secrets"; +// Keep in sync with SECRETS_BLOB_BINDING_PREFIX / BLOB_CHUNK_MAX_CHARS in +// activation_handshake.py and with the shim's BLOB_BINDING_PREFIX. +const BLOB_PREFIX = "BASE44_SECRETS_BLOB"; +const BLOB_CHUNK_MAX_CHARS = 4_800; + +// ── Node-side mirror of the backend ───────────────────────────────────────── + +function b64urlToBytes(value: string): Uint8Array { + const b64 = value.replace(/-/g, "+").replace(/_/g, "/"); + return new Uint8Array(Buffer.from(b64, "base64")); +} + +function bytesToB64url(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64url"); +} + +function newDataKey(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(32)); +} + +/** The at-rest blob: keyId || nonce || AES-GCM(deflated JSON), base64url. */ +async function encryptBlob( + dataKey: Uint8Array, + appId: string, + secrets: Record, + keyId = 1, +): Promise { + const key = await crypto.subtle.importKey("raw", dataKey, "AES-GCM", false, ["encrypt"]); + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: nonce, additionalData: new TextEncoder().encode(appId) }, + key, + new Uint8Array(deflateSync(new TextEncoder().encode(JSON.stringify({ secrets })))), + ); + const out = new Uint8Array(1 + nonce.length + ct.byteLength); + out[0] = keyId; + out.set(nonce, 1); + out.set(new Uint8Array(ct), 1 + nonce.length); + return bytesToB64url(out); +} + +/** Split a blob into the bindings the deploy path would attach. */ +function blobBindings(blob: string): Record { + const out: Record = {}; + for (let i = 0, start = 0; start < Math.max(blob.length, 1); i++, start += BLOB_CHUNK_MAX_CHARS) { + out[`${BLOB_PREFIX}_${i}`] = blob.slice(start, start + BLOB_CHUNK_MAX_CHARS); + } + return out; +} + +/** Seal the data key to every recipient isolate key — the envelope. */ +async function sealDataKey( + workerPubsB64: string | string[], + appId: string, + dataKey: Uint8Array, +): Promise { + const recipients = Array.isArray(workerPubsB64) ? workerPubsB64 : [workerPubsB64]; + const aad = new TextEncoder().encode(appId); + const backendPair = await crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"], + ); + + const blocks: Uint8Array[] = []; + for (const pubB64 of recipients) { + const workerPub = await crypto.subtle.importKey( + "raw", b64urlToBytes(pubB64), { name: "ECDH", namedCurve: "P-256" }, false, [], + ); + const shared = await crypto.subtle.deriveBits( + { name: "ECDH", public: workerPub }, backendPair.privateKey, 256, + ); + const hkdfKey = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveBits"]); + const keyBits = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array(0), + info: new TextEncoder().encode("base44-runtime-secrets-v1"), + }, + hkdfKey, + 256, + ); + const kek = await crypto.subtle.importKey("raw", keyBits, "AES-GCM", false, ["encrypt"]); + const wrapNonce = crypto.getRandomValues(new Uint8Array(12)); + const wrapped = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: wrapNonce, additionalData: aad }, kek, dataKey, + ); + const block = new Uint8Array(12 + wrapped.byteLength); + block.set(wrapNonce, 0); + block.set(new Uint8Array(wrapped), 12); + blocks.push(block); + } + + const backendPubRaw = new Uint8Array( + await crypto.subtle.exportKey("raw", backendPair.publicKey), + ); + const blocksLen = blocks.reduce((n, b) => n + b.length, 0); + const envelope = new Uint8Array(backendPubRaw.length + 1 + blocksLen); + let off = 0; + envelope.set(backendPubRaw, off); off += backendPubRaw.length; + envelope[off] = recipients.length; off += 1; + for (const b of blocks) { envelope.set(b, off); off += b.length; } + return bytesToB64url(envelope); +} + +/** A foreign isolate keypair, for "not sealed to me" cases. */ +async function foreignPubKey(): Promise { + const pair = await crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"], + ); + return bytesToB64url(new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey))); +} + +// ── One-isolate session ────────────────────────────────────────────────────── + +type DispatchInit = { headers?: Record; method?: string; body?: string }; +type Dispatch = (init?: DispatchInit) => Promise; + +/** Boot an isolate carrying `secrets` as a baked blob; hand the test its + * dispatcher and the data key the backend would seal. */ +async function withIsolate( + bundle: string, + run: (dispatch: Dispatch, dataKey: Uint8Array) => Promise, + opts: { + secrets?: Record; + bindings?: Record; + blobAppId?: string; + omitBlob?: boolean; + } = {}, +): Promise { + const dataKey = newDataKey(); + const secrets = opts.secrets ?? {}; + const blob = await encryptBlob(dataKey, opts.blobAppId ?? APP_ID, secrets); + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: bundle }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + bindings: { + BASE44_APP_ID: APP_ID, + ...(opts.omitBlob ? {} : blobBindings(blob)), + ...(opts.bindings ?? {}), + }, + }); + try { + await run( + (init) => mf.dispatchFetch("http://localhost/", init) as unknown as Promise, + dataKey, + ); + } finally { + await mf.dispose(); + } +} + +/** Cold-start: read the signal, seal the key to the isolate, re-send. */ +async function activate( + dispatch: Dispatch, + dataKey: Uint8Array, + headers: Record = {}, + appId: string = APP_ID, +): Promise { + const cold = await dispatch({ headers }); + expect(cold.status).toBe(503); + const pub = cold.headers.get(NEEDS_ACTIVATION); + expect(pub).toBeTruthy(); + return dispatch({ + headers: { ...headers, [RUNTIME_SECRETS]: await sealDataKey(pub!, appId, dataKey) }, + }); +} + +const REPORTER = ` +Deno.serve((req) => Response.json({ + secret: Deno.env.get("MY_SECRET") ?? null, + viaProcess: (globalThis.process?.env?.MY_SECRET) ?? null, + viaBridge: globalThis.Base44?.secrets?.get("MY_SECRET") ?? null, + sawEnvelope: req.headers.get("Base44-Runtime-Secrets"), +})); +`; + +describe("runtime-secrets activation in workerd", () => { + it("cold isolate signals with a pubkey before running any user code", async () => { + const m = await bundled( + 'Deno.serve(() => { throw new Error("user code must not run"); });', + "main.ts", false, true, + ); + await withIsolate(m, async (dispatch) => { + const cold = await dispatch(); + expect(cold.status).toBe(503); + expect(cold.headers.get("Cache-Control")).toBe("no-store"); + expect(await cold.text()).toBe(""); + // 65-byte uncompressed P-256 point. + const pub = cold.headers.get(NEEDS_ACTIVATION); + expect(b64urlToBytes(pub!).length).toBe(65); + expect(b64urlToBytes(pub!)[0]).toBe(0x04); + }); + }); + + it("unwraps the key and opens the baked blob into Deno.env and process.env", async () => { + const m = await bundled(REPORTER, "main.ts", false, true); + await withIsolate(m, async (dispatch, dataKey) => { + const served = await activate(dispatch, dataKey); + expect(served.status).toBe(200); + expect(await served.json()).toEqual({ + secret: "sk-live-123", + viaProcess: "sk-live-123", + viaBridge: "sk-live-123", // Base44.secrets.get reads the installed env + sawEnvelope: null, // the key envelope is stripped before user code + }); + + // Warm request: no handshake, still served from isolate memory. + const warm = await dispatch(); + expect(warm.status).toBe(200); + expect(((await warm.json()) as { secret: string }).secret).toBe("sk-live-123"); + }, { secrets: { MY_SECRET: "sk-live-123" } }); + }); + + it("reassembles a blob split across many bindings", async () => { + // The v3 ceiling mechanism: CF caps one secret_text value at 5 KB, so a + // large blob arrives as BASE44_SECRETS_BLOB_0..n and the shim concatenates + // in index order. A rejoin bug yields truncated ciphertext, and AES-GCM + // fails closed — so this passing proves the reassembly is byte-exact. + // Random bytes: deflate cannot shrink them, so the blob really does exceed + // one binding. + const big = Buffer.from(crypto.getRandomValues(new Uint8Array(9_000))).toString("base64"); + const m = await bundled(REPORTER, "main.ts", false, true); + const dataKey = newDataKey(); + const blob = await encryptBlob(dataKey, APP_ID, { MY_SECRET: big }); + const chunks = blobBindings(blob); + expect(Object.keys(chunks).length).toBeGreaterThan(1); + expect(Object.values(chunks).every((c) => c.length <= BLOB_CHUNK_MAX_CHARS)).toBe(true); + + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: m }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + bindings: { BASE44_APP_ID: APP_ID, ...chunks }, + }); + try { + const dispatch = ((init?: DispatchInit) => + mf.dispatchFetch("http://localhost/", init)) as unknown as Dispatch; + const served = await activate(dispatch, dataKey); + expect(served.status).toBe(200); + expect(((await served.json()) as { secret: string }).secret).toBe(big); + } finally { + await mf.dispose(); + } + }); + + it("keeps the blob opaque to user code — bindings hold ciphertext only", async () => { + // The blob IS a binding, so user code can read it. That is acceptable only + // because it is ciphertext: the plaintext must not appear there, and the + // data key never lands on env. + const snooper = ` +const probes = {}; +for (let i = 0; i < 3; i++) { + const k = "BASE44_SECRETS_BLOB_" + i; + probes[k] = Deno.env.get(k) ?? globalThis.Base44?.secrets?.get(k) ?? null; +} +Deno.serve(() => Response.json({ probes, secret: Deno.env.get("MY_SECRET") ?? null }));`; + const m = await bundled(snooper, "main.ts", false, true); + await withIsolate(m, async (dispatch, dataKey) => { + const served = await activate(dispatch, dataKey); + const body = (await served.json()) as { + probes: Record; + secret: string | null; + }; + // The function resolves its own secret … + expect(body.secret).toBe("sk-live-plaintext"); + // … while whatever it can see of the blob is ciphertext: no plaintext, no + // key names. (Reading the raw binding at all is incidental — the point is + // that doing so yields nothing.) + const seen = JSON.stringify(body.probes); + expect(seen).not.toContain("sk-live-plaintext"); + expect(seen).not.toContain("MY_SECRET"); + }, { secrets: { MY_SECRET: "sk-live-plaintext" } }); + }); + + it("installs from a multi-recipient envelope whichever position its block is in", async () => { + // The backend accumulates isolate keys across re-signals, so a real + // envelope can carry several wrap blocks. This isolate's block is placed + // LAST, after one sealed to a key it does not hold — proving the shim tries + // blocks until its own authenticates. + const m = await bundled(REPORTER, "main.ts", false, true); + await withIsolate(m, async (dispatch, dataKey) => { + const cold = await dispatch(); + expect(cold.status).toBe(503); + const isolatePub = cold.headers.get(NEEDS_ACTIVATION)!; + const envelope = await sealDataKey( + [await foreignPubKey(), isolatePub], APP_ID, dataKey, + ); + const served = await dispatch({ headers: { [RUNTIME_SECRETS]: envelope } }); + expect(served.status).toBe(200); + expect(((await served.json()) as { secret: string }).secret).toBe("multi-1"); + }, { secrets: { MY_SECRET: "multi-1" } }); + }); + + it("re-signals when no wrap block is sealed to this isolate", async () => { + const m = await bundled(REPORTER, "main.ts", false, true); + await withIsolate(m, async (dispatch, dataKey) => { + expect((await dispatch()).status).toBe(503); + const foreign = [await foreignPubKey(), await foreignPubKey()]; + const envelope = await sealDataKey(foreign, APP_ID, dataKey); + const res = await dispatch({ headers: { [RUNTIME_SECRETS]: envelope } }); + expect(res.status).toBe(503); + expect(res.headers.get(NEEDS_ACTIVATION)).toBeTruthy(); + }, { secrets: { MY_SECRET: "x" } }); + }); + + it("re-signals on a key sealed for another app (AAD mismatch)", async () => { + const m = await bundled(REPORTER, "main.ts", false, true); + await withIsolate(m, async (dispatch, dataKey) => { + const cold = await dispatch(); + const pub = cold.headers.get(NEEDS_ACTIVATION)!; + const envelope = await sealDataKey(pub, "other-app", dataKey); + const res = await dispatch({ headers: { [RUNTIME_SECRETS]: envelope } }); + expect(res.status).toBe(503); + expect(res.headers.get(NEEDS_ACTIVATION)).toBeTruthy(); + }, { secrets: { MY_SECRET: "x" } }); + }); + + it("fails closed when the blob was baked for another app", async () => { + // Right key, wrong AAD on the blob: the isolate must not install. + const m = await bundled(REPORTER, "main.ts", false, true); + await withIsolate(m, async (dispatch, dataKey) => { + const res = await activate(dispatch, dataKey); + expect(res.status).toBe(503); + expect(res.headers.get(NEEDS_ACTIVATION)).toBeTruthy(); + }, { secrets: { MY_SECRET: "x" }, blobAppId: "other-app" }); + }); + + it("fails closed when the script carries no blob bindings", async () => { + const m = await bundled(REPORTER, "main.ts", false, true); + await withIsolate(m, async (dispatch, dataKey) => { + const res = await activate(dispatch, dataKey); + expect(res.status).toBe(503); + expect(res.headers.get(NEEDS_ACTIVATION)).toBeTruthy(); + }, { secrets: { MY_SECRET: "x" }, omitBlob: true }); + }); + + it("installs once per isolate: a later key is a no-op, not a refresh", async () => { + // Re-installing would mean decrypting again on globals user code has had a + // chance to patch. Rotation instead rolls the isolate (the generation-nonce + // version bump), so a NEW isolate picks up new values. + const m = await bundled(REPORTER, "main.ts", false, true); + await withIsolate(m, async (dispatch, dataKey) => { + const first = await activate(dispatch, dataKey); + expect(((await first.json()) as { secret: string }).secret).toBe("v1"); + + // A second, valid envelope on the same isolate: served, but ignored. + const cold2 = await dispatch(); + expect(cold2.status).toBe(200); // already installed — no signal + const later = await dispatch({ + headers: { [RUNTIME_SECRETS]: await sealDataKey(await foreignPubKey(), APP_ID, dataKey) }, + }); + expect(later.status).toBe(200); + expect(((await later.json()) as { secret: string }).secret).toBe("v1"); + }, { secrets: { MY_SECRET: "v1" } }); + }); + + it("delivers the PDS manifest to manifest.ts via a private single-instance store (not a global)", async () => { + // Proves the closed channel end to end: the shim writes the manifest into + // the private store and manifest.ts reads it — they MUST dedupe to one + // module instance, or lookup would see an empty manifest and throw "not + // bound". User code reaches PDS only through the public surface. + const fn = ` +import { http } from "base44:private-data-sources/http"; +Deno.serve(() => { + let lookup; + try { http("api"); lookup = "resolved"; } + catch (e) { lookup = String((e && e.message) || e); } + return Response.json({ + lookup, + manifestGlobal: globalThis.__base44RuntimeManifest ?? null, + }); +});`; + const m = await bundled(fn, "main.ts", false, true); + const manifest = JSON.stringify([ + { + name: "api", type: "http", bindingName: "DATA_SOURCE_API", + bindingKind: "vpc_service", host: "api.internal", port: 443, + baseUrl: "https://api.internal:443", + }, + ]); + await withIsolate(m, async (dispatch, dataKey) => { + const served = await activate(dispatch, dataKey); + const body = (await served.json()) as { lookup: string; manifestGlobal: unknown }; + expect(body.lookup).toBe("resolved"); + expect(body.lookup).not.toMatch(/not bound/); // single-instance store worked + expect(body.manifestGlobal).toBeNull(); // never on a user global + }, { + secrets: { BASE44_PRIVATE_DATA_SOURCES: manifest }, + bindings: { DATA_SOURCE_API: { stub: true } }, // present so pinning keeps the entry + }); + }); + + it("a blob with no manifest does not fall back to a stale manifest binding", async () => { + // "Delivered empty" must differ from "not delivered": the shim stores "" + // so manifest.ts treats the handshake as authoritative instead of reading + // the Worker BINDING (unfiltered, unpinned). + const fn = ` +import { http } from "base44:private-data-sources/http"; +Deno.serve(() => { + let lookup; + try { http("api"); lookup = "resolved"; } + catch (e) { lookup = String((e && e.message) || e); } + return Response.json({ lookup }); +});`; + const m = await bundled(fn, "main.ts", false, true); + const staleManifest = JSON.stringify([ + { + name: "api", type: "http", bindingName: "DATA_SOURCE_API", + bindingKind: "vpc_service", host: "api.internal", port: 443, + baseUrl: "https://api.internal:443", + }, + ]); + await withIsolate(m, async (dispatch, dataKey) => { + const served = await activate(dispatch, dataKey); + const body = (await served.json()) as { lookup: string }; + expect(body.lookup).toMatch(/not bound/); + expect(body.lookup).not.toBe("resolved"); + }, { + secrets: { MY_SECRET: "sk-live" }, // no manifest in the blob + bindings: { + BASE44_PRIVATE_DATA_SOURCES: staleManifest, // leftover from binding mode + DATA_SOURCE_API: { stub: true }, + }, + }); + }); + + it("never re-installs, so user-patched globals never see the plaintext", async () => { + // User code patches the primordials the install path uses, then triggers + // another activation. Because install happens once per isolate, before any + // user code, the patched globals observe nothing. + const snooper = ` +const seen = []; +const realParse = JSON.parse; +JSON.parse = (t, ...r) => { seen.push("parse:" + String(t).slice(0, 40)); return realParse(t, ...r); }; +const realDecode = TextDecoder.prototype.decode; +TextDecoder.prototype.decode = function (...a) { + const out = realDecode.apply(this, a); + seen.push("decode:" + String(out).slice(0, 40)); + return out; +}; +Deno.serve(() => Response.json({ secret: Deno.env.get("MY_SECRET") ?? null, seen })); +`; + const m = await bundled(snooper, "main.ts", false, true); + await withIsolate(m, async (dispatch, dataKey) => { + const first = await activate(dispatch, dataKey); + expect(((await first.json()) as { secret: string }).secret).toBe("sk-live"); + + const later = await dispatch({ + headers: { [RUNTIME_SECRETS]: await sealDataKey(await foreignPubKey(), APP_ID, dataKey) }, + }); + const body = (await later.json()) as { secret: string; seen: string[] }; + expect(body.secret).toBe("sk-live"); // still serving its env… + const observed = body.seen.join("\n"); // …and nothing was decrypted again + expect(observed).not.toContain("sk-live"); + expect(observed).not.toContain("parse:"); + }, { secrets: { MY_SECRET: "sk-live" } }); + }); + + it("strips a forged signal even when the handler patches the Headers APIs", async () => { + // The P1: the strip runs AFTER user code, in the same realm. A handler that + // patches Headers.prototype.has/get/delete, Headers.prototype.entries, the + // array iterator, and the Response.prototype.headers accessor must still not + // get a signal past us — otherwise the backend seals the app data key to a + // keypair this handler generated and replays the request to it. + const forger = ` +const realHas = Headers.prototype.has; +const realGet = Headers.prototype.get; +Headers.prototype.has = function (n) { + if (String(n).toLowerCase().startsWith("x-base44") || String(n).toLowerCase().startsWith("base44-runtime")) return false; + return realHas.call(this, n); +}; +Headers.prototype.get = function (n) { + if (String(n).toLowerCase().startsWith("x-base44") || String(n).toLowerCase().startsWith("base44-runtime")) return null; + return realGet.call(this, n); +}; +Headers.prototype.delete = function () {}; +Headers.prototype.entries = function () { return [][Symbol.iterator](); }; +Object.defineProperty(Response.prototype, "headers", { get() { return new Headers(); } }); +Deno.serve(() => new Response("", { + status: 503, + headers: { "X-Base44-Needs-Activation": "BASE64URL_FORGED_PUBKEY" }, +})); +`; + const m = await bundled(forger, "main.ts", false, true); + await withIsolate(m, async (dispatch, dataKey) => { + const served = await activate(dispatch, dataKey); + // The handler's own 503 comes back, but WITHOUT a signal: the backend + // must not read this as a cold isolate asking for the key. + expect(served.status).toBe(503); + expect(served.headers.get(NEEDS_ACTIVATION)).toBeNull(); + }, { secrets: { MY_SECRET: "sk-live" } }); + }); + + it("never hands the envelope to a handler that patched the strip away", async () => { + // Second half of the chain: even if a forged signal somehow got a key + // sealed to the handler, the envelope must not reach it on the replay. + // Same patches, and the handler reports whatever it can still see. + const snooper = ` +const realGet = Headers.prototype.get; +Headers.prototype.has = () => false; +Headers.prototype.get = function (n) { + if (String(n).toLowerCase() === "base44-runtime-secrets") return realGet.call(this, n); + return realGet.call(this, n); +}; +Headers.prototype.delete = function () {}; +Deno.serve((req) => Response.json({ + envelope: realGet.call(req.headers, "Base44-Runtime-Secrets"), + secret: Deno.env.get("MY_SECRET") ?? null, +})); +`; + const m = await bundled(snooper, "main.ts", false, true); + await withIsolate(m, async (dispatch, dataKey) => { + // Activate first so the isolate is warm and the patches are installed … + const first = await activate(dispatch, dataKey); + expect(((await first.json()) as { secret: string }).secret).toBe("sk-live"); + // … then replay WITH an envelope, the way an accumulating loop would. + const replay = await dispatch({ + headers: { [RUNTIME_SECRETS]: await sealDataKey(await foreignPubKey(), APP_ID, dataKey) }, + }); + const body = (await replay.json()) as { envelope: string | null }; + expect(body.envelope).toBeNull(); + }, { secrets: { MY_SECRET: "sk-live" } }); + }); + + it("strips a forged activation signal from user-handler responses", async () => { + const forger = ` +Deno.serve(() => new Response("done", { + headers: { "X-Base44-Needs-Activation": "forged-pubkey" }, +})); +`; + const m = await bundled(forger, "main.ts", false, true); + await withIsolate(m, async (dispatch, dataKey) => { + const res = await activate(dispatch, dataKey); + expect(res.status).toBe(200); + expect(await res.text()).toBe("done"); + expect(res.headers.get(NEEDS_ACTIVATION)).toBeNull(); + }, { secrets: {} }); + }); + + it("per-app bundles gate activation before routing and read blob secrets", async () => { + const m = await bundledApp( + [ + { + name: "whoami", + files: { "main.ts": 'Deno.serve(() => new Response(Deno.env.get("MY_SECRET") ?? "none"));' }, + }, + ], + false, + true, + ); + await withIsolate(m, async (dispatch, dataKey) => { + const routed = { "Base44-Function-Name": "whoami" }; + const served = await activate(dispatch, dataKey, routed); + expect(served.status).toBe(200); + expect(await served.text()).toBe("per-app-secret"); + }, { secrets: { MY_SECRET: "per-app-secret" } }); + }); + + it("binding-mode bundles are untouched: no signal, env from bindings", async () => { + const m = await bundled( + 'Deno.serve(() => new Response(Deno.env.get("BASE44_APP_ID") ?? "none"));', + ); + await withIsolate(m, async (dispatch) => { + const res = await dispatch(); + expect(res.status).toBe(200); + expect(await res.text()).toBe(APP_ID); + expect(res.headers.get(NEEDS_ACTIVATION)).toBeNull(); + }, { omitBlob: true }); + }); +}); diff --git a/packages/functions-compiler/test/shared-dep-conflict.e2e.test.ts b/packages/functions-compiler/test/shared-dep-conflict.e2e.test.ts new file mode 100644 index 000000000..d74ab58f3 --- /dev/null +++ b/packages/functions-compiler/test/shared-dep-conflict.e2e.test.ts @@ -0,0 +1,112 @@ +/** + * E2E for the shared-dep version-conflict class of assembly failure: every + * function compiles alone, but the combined app graph resolves a shared npm + * package onto a version that violates another importer's peer range. + * + * Real-world shape (prod app 68da7245efa2ba7a0ede4746): one function imports + * `npm:date-fns-tz@^2.0.0` (peer: date-fns 2.x, deep-imports + * `date-fns/format/index.js`), another `npm:date-fns-tz@3.2.0` + + * `npm:date-fns@4.1.0`; with both tz majors in the graph, tz@2's deep imports + * can resolve into date-fns@4.1.0 whose exports map doesn't expose them. + * + * Whether the mis-resolution fires depends on the resolver's npm cache state + * (it races concurrent probes), so the hard invariant under test is: bundleApp + * NEVER crashes on it — a crash becomes a 500, which the platform retries and + * reports as "infrastructure error" although the failure is deterministic and + * user-fixable. When it does fire, it must surface as a compile error on the + * function importing the failing major, with the rest still assembled. + */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bundleApp } from "../src/bundler"; + +const fn = (name: string, source: string) => ({ + name, + entry: "main.ts", + files: { "main.ts": source }, +}); + +const TZ_V2_FN = fn( + "getDashboardData", + `import { format } from 'npm:date-fns-tz@^2.0.0'; +Deno.serve(() => new Response(String(format)));`, +); + +const TZ_V3_FN = fn( + "cleanupForgottenDepartures", + `import { toZonedTime } from 'npm:date-fns-tz@3.2.0'; +import { addMinutes } from 'npm:date-fns@4.1.0'; +Deno.serve(() => new Response(String(addMinutes(new Date(), 1))));`, +); + +const PLAIN_FN = fn("health", `Deno.serve(() => new Response("ok"));`); + +// A fresh DENO_DIR maximizes the odds the conflict fires (and matches a fresh +// prod instance); a warm cache can resolve both majors cleanly. +let denoDir: string; +beforeAll(() => { + denoDir = mkdtempSync(path.join(tmpdir(), "bundler-conflict-")); + process.env.DENO_DIR = denoDir; +}); +afterAll(() => { + delete process.env.DENO_DIR; + rmSync(denoDir, { recursive: true, force: true }); +}); + +describe("bundle-app shared-dep version conflict", () => { + it( + "never crashes; when the conflict fires it is attributed per function", + { timeout: 300_000 }, + async () => { + // Never throws — that's the contract this path must keep. + const result = await bundleApp({ + functions: [TZ_V2_FN, TZ_V3_FN, PLAIN_FN], + }); + + const byName = Object.fromEntries(result.functions.map((f) => [f.name, f])); + // These two never participate in the conflict. + expect(byName.cleanupForgottenDepartures.ok).toBe(true); + expect(byName.health.ok).toBe(true); + + const conflicted = byName.getDashboardData; + if (conflicted.ok) { + // Cache state let both majors coexist — nothing to attribute. + expect(result.ok).toBe(true); + return; + } + // Conflict fired: blamed function carries the diagnostics, the rest of + // the app still assembled into a deployable module. + expect(result.ok).toBe(true); + const message = conflicted.errors.map((e) => e.message).join("\n"); + expect(message).toMatch(/version conflict/); + expect(message).toContain("date-fns-tz"); + }, + ); + + it( + "threads runtime-secrets mode through the conflict fallback", + { timeout: 300_000 }, + async () => { + // Regression: the per-function fallback rebuilds survivors via + // assembleWithoutConflicting → compileApp(rest), which must thread + // runtimeSecrets — else the survivors ship with neither the activation + // shim nor bound secrets, so their cold invocations run without env. + // Only asserts when the conflict actually fires (same cache-dependent + // condition as above); the non-fallback path is covered elsewhere. + const result = await bundleApp({ + functions: [TZ_V2_FN, TZ_V3_FN, PLAIN_FN], + runtimeSecrets: true, + }); + const byName = Object.fromEntries(result.functions.map((f) => [f.name, f])); + if (byName.getDashboardData.ok) return; // conflict didn't fire this run + expect(result.ok).toBe(true); + // The rebuilt survivor module still carries the activation wrapper. + expect(result.module).toContain("X-Base44-Needs-Activation"); + }, + ); +}); diff --git a/packages/functions-compiler/test/static-egress.test.ts b/packages/functions-compiler/test/static-egress.test.ts new file mode 100644 index 000000000..8f0e5bb06 --- /dev/null +++ b/packages/functions-compiler/test/static-egress.test.ts @@ -0,0 +1,286 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { describe, expect, it, vi } from "vitest"; + +import { + createRequestScopedStaticEgressFetch, + STATIC_EGRESS_ARTIFACT_MARKER, +} from "../src/static-egress"; + +describe("createRequestScopedStaticEgressFetch routing", () => { + it("routes ordinary fetches through the dedicated network binding", async () => { + const ordinaryFetch = vi.fn(); + const response = new Response("dedicated"); + const bindingFetch = vi.fn().mockResolvedValue(response); + const routedFetch = createRequestScopedStaticEgressFetch( + ordinaryFetch, + () => ({ + STATIC_EGRESS: { fetch: bindingFetch }, + BASE44_STATIC_EGRESS_ENABLED: "1", + }), + ); + + await expect(routedFetch("https://example.com/orders")).resolves.toBe( + response, + ); + expect(bindingFetch).toHaveBeenCalledWith( + "https://example.com/orders", + undefined, + ); + expect(ordinaryFetch).not.toHaveBeenCalled(); + }); + + it.each([ + new URL("https://example.com/from-url"), + new Request("https://example.com/from-request"), + ])( + "routes every standard fetch input form through the binding", + async (input) => { + const ordinaryFetch = vi.fn(); + const response = new Response("dedicated"); + const bindingFetch = vi.fn().mockResolvedValue(response); + const routedFetch = createRequestScopedStaticEgressFetch( + ordinaryFetch, + () => ({ + STATIC_EGRESS: { fetch: bindingFetch }, + BASE44_STATIC_EGRESS_ENABLED: "1", + }), + ); + + await expect(routedFetch(input)).resolves.toBe(response); + expect(bindingFetch).toHaveBeenCalledWith(input, undefined); + expect(ordinaryFetch).not.toHaveBeenCalled(); + }, + ); + + it("bypasses dedicated egress for exact hosts and apps-domain subdomains", async () => { + const response = new Response("ordinary"); + const ordinaryFetch = vi.fn().mockResolvedValue(response); + const dedicatedResponse = new Response("dedicated"); + const bindingFetch = vi.fn().mockResolvedValue(dedicatedResponse); + const routedFetch = createRequestScopedStaticEgressFetch( + ordinaryFetch, + () => ({ + STATIC_EGRESS: { fetch: bindingFetch }, + BASE44_STATIC_EGRESS_ENABLED: "1", + BASE44_STATIC_EGRESS_EXCLUDED_HOSTS: JSON.stringify([ + ".base44.app", + "private.example.com", + ]), + }), + ); + + await expect( + routedFetch("https://BASE44.app./api/apps"), + ).resolves.toBe(response); + await expect( + routedFetch("https://my-app.base44.app/api/apps"), + ).resolves.toBe(response); + await expect( + routedFetch("https://PRIVATE.example.com./health"), + ).resolves.toBe(response); + await expect( + routedFetch("https://customer.example.com/orders"), + ).resolves.toBe(dedicatedResponse); + await expect( + routedFetch("https://notbase44.app/orders"), + ).resolves.toBe(dedicatedResponse); + expect(ordinaryFetch).toHaveBeenCalledTimes(3); + expect(bindingFetch).toHaveBeenCalledTimes(2); + expect(bindingFetch).toHaveBeenCalledWith( + "https://customer.example.com/orders", + undefined, + ); + }); +}); + +describe("createRequestScopedStaticEgressFetch", () => { + it("falls back to native fetch when the current request has no binding", async () => { + const ordinaryResponse = new Response("ordinary"); + const ordinaryFetch = vi + .fn() + .mockResolvedValue(ordinaryResponse); + const routedFetch = createRequestScopedStaticEgressFetch( + ordinaryFetch, + () => ({}), + ); + + await expect( + routedFetch("https://customer.example.com/orders"), + ).resolves.toBe(ordinaryResponse); + expect(ordinaryFetch).toHaveBeenCalledTimes(1); + }); + + it("keeps concurrent requests isolated to their own Worker environments", async () => { + const environments = new AsyncLocalStorage>(); + const ordinaryResponse = new Response("ordinary"); + const ordinaryFetch = vi + .fn() + .mockResolvedValue(ordinaryResponse); + const firstResponse = new Response("first"); + const secondResponse = new Response("second"); + const firstBindingFetch = vi.fn().mockResolvedValue(firstResponse); + const secondBindingFetch = vi.fn().mockResolvedValue(secondResponse); + const routedFetch = createRequestScopedStaticEgressFetch( + ordinaryFetch, + () => environments.getStore() ?? {}, + ); + + let release!: () => void; + const ready = new Promise((resolve) => { + release = resolve; + }); + const request = ( + env: Record, + url: string, + ): Promise => + environments.run(env, async () => { + await ready; + return routedFetch(url); + }); + + const responses = Promise.all([ + request( + { + STATIC_EGRESS: { fetch: firstBindingFetch }, + BASE44_STATIC_EGRESS_ENABLED: "1", + }, + "https://first.example.com", + ), + request( + { + STATIC_EGRESS: { fetch: secondBindingFetch }, + BASE44_STATIC_EGRESS_ENABLED: "1", + }, + "https://second.example.com", + ), + request({}, "https://ordinary.example.com"), + ]); + release(); + + await expect(responses).resolves.toEqual([ + firstResponse, + secondResponse, + ordinaryResponse, + ]); + expect(firstBindingFetch).toHaveBeenCalledWith( + "https://first.example.com", + undefined, + ); + expect(secondBindingFetch).toHaveBeenCalledWith( + "https://second.example.com", + undefined, + ); + expect(ordinaryFetch).toHaveBeenCalledWith( + "https://ordinary.example.com", + undefined, + ); + }); + + it("defaults off when the binding is attached without an enable secret", async () => { + const ordinaryResponse = new Response("ordinary"); + const ordinaryFetch = vi + .fn() + .mockResolvedValue(ordinaryResponse); + const dedicatedResponse = new Response("dedicated"); + const bindingFetch = vi.fn().mockResolvedValue(dedicatedResponse); + const routedFetch = createRequestScopedStaticEgressFetch( + ordinaryFetch, + () => ({ + STATIC_EGRESS: { fetch: bindingFetch }, + }), + ); + + await expect( + routedFetch("https://customer.example.com/orders"), + ).resolves.toBe(ordinaryResponse); + expect(bindingFetch).not.toHaveBeenCalled(); + expect(ordinaryFetch).toHaveBeenCalledTimes(1); + }); + + it.each(["0", "", "true", "garbage"])( + "keeps routing off for an invalid enable secret", + async (enableSecret) => { + const ordinaryResponse = new Response("ordinary"); + const ordinaryFetch = vi + .fn() + .mockResolvedValue(ordinaryResponse); + const bindingFetch = vi.fn().mockResolvedValue(new Response("dedicated")); + const routedFetch = createRequestScopedStaticEgressFetch( + ordinaryFetch, + () => ({ + STATIC_EGRESS: { fetch: bindingFetch }, + BASE44_STATIC_EGRESS_ENABLED: enableSecret, + }), + ); + + await expect(routedFetch("https://customer.example.com")).resolves.toBe( + ordinaryResponse, + ); + expect(bindingFetch).not.toHaveBeenCalled(); + }, + ); + + it("bypasses dedicated egress for bracketed IPv6 private-source URLs", async () => { + const ordinaryResponse = new Response("ordinary"); + const ordinaryFetch = vi + .fn() + .mockResolvedValue(ordinaryResponse); + const bindingFetch = vi + .fn() + .mockResolvedValue(new Response("dedicated")); + const routedFetch = createRequestScopedStaticEgressFetch( + ordinaryFetch, + () => ({ + STATIC_EGRESS: { fetch: bindingFetch }, + BASE44_STATIC_EGRESS_ENABLED: "1", + BASE44_PRIVATE_DATA_SOURCES: + '[{"host":"2001:db8::1"}]', + }), + ); + + await expect( + routedFetch("https://[2001:db8::1]/health"), + ).resolves.toBe(ordinaryResponse); + expect(ordinaryFetch).toHaveBeenCalledTimes(1); + expect(bindingFetch).not.toHaveBeenCalled(); + }); + + it("logs a bounded fallback decision without the request URL or env value", async () => { + vi.resetModules(); + const { createRequestScopedStaticEgressFetch: createFreshRoutedFetch } = + await import("../src/static-egress"); + const ordinaryFetch = vi + .fn() + .mockResolvedValue(new Response("ordinary")); + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + const routedFetch = createFreshRoutedFetch( + ordinaryFetch, + () => ({ + BASE44_STATIC_EGRESS_EXCLUDED_HOSTS: '["secret.internal.example"]', + }), + ); + + try { + await routedFetch("https://customer.example.com/sensitive/path?token=secret"); + + const diagnostic = JSON.parse(String(consoleLog.mock.lastCall?.[0])); + expect(diagnostic).toEqual({ + b44_diagnostic: "static_egress", + diagnostic_version: STATIC_EGRESS_ARTIFACT_MARKER, + event: "fetch_route", + route: "fallback", + input_kind: "string", + binding_present: false, + binding_type: "undefined", + enable_secret_present: false, + enable_secret_enabled: false, + binding_fetch_type: "undefined", + }); + expect(JSON.stringify(diagnostic)).not.toContain("customer.example.com"); + expect(JSON.stringify(diagnostic)).not.toContain("secret.internal.example"); + } finally { + consoleLog.mockRestore(); + } + }); +}); diff --git a/packages/functions-compiler/test/tick-loop.test.ts b/packages/functions-compiler/test/tick-loop.test.ts new file mode 100644 index 000000000..590992c41 --- /dev/null +++ b/packages/functions-compiler/test/tick-loop.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TickLoop } from "../src/shim/tick-loop"; + +describe("TickLoop", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("ticks at the requested cadence without drift", async () => { + let ticks = 0; + const loop = new TickLoop(() => { + ticks++; + return true; + }); + loop.start(100); + await vi.advanceTimersByTimeAsync(1000); + // Drift-corrected schedule: 1000ms / 100ms = exactly 10 ticks, not ~9 + // as a naive re-setTimeout-after-work chain would give. + expect(ticks).toBe(10); + loop.stop(); + }); + + it("caps catch-up after a stall and drops the remaining debt", async () => { + let ticks = 0; + const loop = new TickLoop(async () => { + ticks++; + // The first tick stalls the loop for 1s (event-loop hiccup simulation). + if (ticks === 1) await new Promise((r) => setTimeout(r, 1000)); + return true; + }, 3); + loop.start(100); + // t=100 the first tick starts and finishes at t=1100 — 10 intervals of + // debt. A naive loop would burst through all of them (visible teleport); + // the cap runs 2 more catch-up steps (3 total) and drops the rest. + await vi.advanceTimersByTimeAsync(1100); + expect(ticks).toBe(3); + // The schedule re-anchors to now — the next tick comes one interval later. + await vi.advanceTimersByTimeAsync(100); + expect(ticks).toBe(4); + loop.stop(); + }); + + it("stops when the callback returns false", async () => { + let ticks = 0; + const loop = new TickLoop(() => { + ticks++; + return ticks < 3; + }); + loop.start(50); + await vi.advanceTimersByTimeAsync(1000); + expect(ticks).toBe(3); + expect(loop.isRunning).toBe(false); + }); + + it("start() with a NEW interval while running reschedules to the new cadence", async () => { + let ticks = 0; + const loop = new TickLoop(() => { + ticks++; + return true; + }); + loop.start(100); + await vi.advanceTimersByTimeAsync(200); // 2 ticks at 100ms + expect(ticks).toBe(2); + loop.start(50); // speed up mid-run (legacy startLoop(newMs) pattern) + await vi.advanceTimersByTimeAsync(200); // 4 more ticks at 50ms + expect(ticks).toBe(6); + loop.stop(); + }); + + it("start() with a new interval from INSIDE a tick does not fork a second loop", async () => { + let ticks = 0; + let loop: TickLoop; + loop = new TickLoop(() => { + ticks++; + if (ticks === 2) loop.start(50); // legacy startLoop(newMs) inside handleTick + return true; + }); + loop.start(100); + await vi.advanceTimersByTimeAsync(200); // ticks 1,2 at 100ms; change fires on tick 2 + expect(ticks).toBe(2); + await vi.advanceTimersByTimeAsync(200); // 4 more ticks at 50ms — doubled loops would give ~8 + expect(ticks).toBe(6); + loop.stop(); + }); + + it("stop() cancels the pending timer and start() is idempotent while running", async () => { + let ticks = 0; + const loop = new TickLoop(() => { + ticks++; + return true; + }); + loop.start(100); + loop.start(100); // no double-scheduling + await vi.advanceTimersByTimeAsync(250); + expect(ticks).toBe(2); + loop.stop(); + await vi.advanceTimersByTimeAsync(500); + expect(ticks).toBe(2); + expect(loop.isRunning).toBe(false); + }); + + it("awaits an async callback before scheduling the next tick (no overlap)", async () => { + let inFlight = 0; + let maxInFlight = 0; + let ticks = 0; + const loop = new TickLoop(async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + ticks++; + await new Promise((r) => setTimeout(r, 30)); + inFlight--; + return true; + }); + loop.start(50); + await vi.advanceTimersByTimeAsync(500); + expect(maxInFlight).toBe(1); + expect(ticks).toBeGreaterThanOrEqual(5); + loop.stop(); + }); +}); + +it("keeps scheduling when the tick callback throws", async () => { + vi.useFakeTimers(); + let calls = 0; + const loop = new TickLoop(() => { + calls++; + if (calls === 1) throw new Error("state not ready"); + return true; + }); + loop.start(10); + await vi.advanceTimersByTimeAsync(11); // first tick throws + expect(loop.isRunning).toBe(true); // running survives the throw + await vi.advanceTimersByTimeAsync(10); // and the NEXT tick still fires + expect(calls).toBeGreaterThanOrEqual(2); + loop.stop(); + vi.useRealTimers(); +}); + +it("a stale run resuming after stop+restart does not arm a second timer", async () => { + vi.useFakeTimers(); + let resolveTick: ((v: boolean) => void) | null = null; + let ticks = 0; + const loop = new TickLoop(() => { + ticks++; + return new Promise((res) => { resolveTick = res; }); + }); + loop.start(10); + await vi.advanceTimersByTimeAsync(11); // tick 1 starts, awaits + expect(ticks).toBe(1); + loop.stop(); + loop.start(10); // restart while tick 1 still pending + resolveTick!(true); // stale run resumes — must NOT schedule + await vi.advanceTimersByTimeAsync(1); + const before = ticks; + await vi.advanceTimersByTimeAsync(100); // only the restarted loop's cadence + // one timer → at most ~10 additional tick STARTS in 100ms; a doubled loop + // would produce roughly twice that. Each tick awaits forever until resolved, + // so exactly ONE new tick fires per armed timer chain: assert single-start. + expect(ticks - before).toBe(1); + loop.stop(); + vi.useRealTimers(); +}); diff --git a/packages/functions-compiler/test/worker-entry.test.ts b/packages/functions-compiler/test/worker-entry.test.ts new file mode 100644 index 000000000..18de6617f --- /dev/null +++ b/packages/functions-compiler/test/worker-entry.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vitest"; + +import { prepareApp, prepareFunction } from "../src/worker-entry"; +import { DenoCompatError } from "../src/errors"; +import { STATIC_EGRESS_ARTIFACT_MARKER } from "../src/static-egress"; + +const serve = (body: string) => + `Deno.serve(() => new Response(${JSON.stringify(body)}));`; + +describe("prepareFunction", () => { + it("injects a shim and a wrapper entry that delegates to the Deno.serve handler", async () => { + const result = await prepareFunction("main.ts", { "main.ts": serve("ok") }); + + // The build entry is the injected wrapper, not the user's file. + expect(result.entry).not.toBe("main.ts"); + const workerEntry = result.files[result.entry]; + expect(workerEntry).toContain("getRegisteredHandler"); + expect(workerEntry).toContain('"./main.ts"'); + expect(workerEntry).toContain("installStaticEgressFetch()"); + expect(workerEntry).toContain("runWithWorkerEnvironment as _b44Run"); + expect(workerEntry).toContain("workerEnv: env"); + expect(workerEntry).not.toContain("base44.workerEnvironment"); + expect(workerEntry).not.toContain("Symbol.for"); + expect(workerEntry.indexOf("installStaticEgressFetch()")).toBeLessThan( + workerEntry.indexOf('import("./main.ts")'), + ); + const shim = result.files["__base44_deno_shim.mjs"]; + expect(shim).toContain("globalThis.Deno"); + expect(shim).toContain(STATIC_EGRESS_ARTIFACT_MARKER); + expect(shim).toContain("function serve"); + expect(shim).not.toContain("cloudflareEnv"); + }); + + it("falls back to the module's default export when no Deno.serve handler registered", async () => { + const result = await prepareFunction("main.ts", { "main.ts": serve("ok") }); + const entry = result.files[result.entry]; + expect(entry).toContain("getRegisteredHandler() ?? (typeof _b44Mod?.default === 'function'"); + expect(entry).toContain("export default a request handler or call Deno.serve()"); + }); + + it("exposes secrets and the EdgeRuntime alias through the prelude", async () => { + const result = await prepareFunction("main.ts", { "main.ts": serve("ok") }); + const entry = result.files[result.entry]; + // The Worker env binding rides the request store; the Base44 global (the + // bridge behind base44:runtime) reads it back with a string filter. + expect(entry).toContain("secrets: env,"); + expect(entry).toContain("typeof _v === 'string' ? _v : undefined"); + expect(entry).toContain("globalThis.EdgeRuntime = globalThis.EdgeRuntime ??"); + }); + + it("rejects reserved injected filenames in the input", async () => { + await expect( + prepareFunction("main.ts", { + "main.ts": serve("ok"), + "__base44_entry.mjs": "malicious", + }), + ).rejects.toThrowError(DenoCompatError); + }); + + it("bakes the activation gate before the user import only in runtime-secrets mode", async () => { + const plain = await prepareFunction("main.ts", { "main.ts": serve("ok") }); + expect(plain.files[plain.entry]).not.toContain("ensureActivation"); + expect(plain.files["__base44_activation.mjs"]).toBeUndefined(); + + const gated = await prepareFunction("main.ts", { "main.ts": serve("ok") }, false, true); + const entry = gated.files[gated.entry]; + expect(gated.files["__base44_activation.mjs"]).toContain("X-Base44-Needs-Activation"); + // Gate ordering: activation must resolve before any user module loads, + // the envelope is stripped from the request, and a forged signal is + // stripped from the user handler's response. + expect(entry.indexOf("ensureActivation")).toBeLessThan(entry.indexOf("_b44Init =")); + expect(entry).toContain("withoutRuntimeSecretsHeader(request)"); + expect(entry).toContain("withoutActivationSignal("); + }); + + it("reserves the activation filename in EVERY mode (store gate keys on it)", async () => { + // The manifest-store allow-list keys on this basename, so a user file with + // it could forge the PDS manifest even in flag-off / actor bundles (where no + // shim is injected). Reserve it unconditionally, like shim/entry. + const files = { "main.ts": serve("ok"), "__base44_activation.mjs": "user file" }; + await expect(prepareFunction("main.ts", files)).rejects.toThrowError(DenoCompatError); + await expect(prepareFunction("main.ts", files, false, true)).rejects.toThrowError( + DenoCompatError, + ); + }); + + it("reserves the activation-shim basename at ANY depth, in every mode", async () => { + // A user file NAMED like the shim at a nested path must not slip past the + // reservation and pose as the injected shim to reach the PDS manifest store. + const files = { "main.ts": serve("ok"), "sub/__base44_activation.mjs": "user file" }; + const err = /Reserved filename "__base44_activation\.mjs".*sub\/__base44_activation\.mjs/; + await expect(prepareFunction("main.ts", files)).rejects.toThrowError(err); + await expect(prepareFunction("main.ts", files, false, true)).rejects.toThrowError(err); + }); +}); + +describe("prepareApp", () => { + it("namespaces each function under fn_/, with one shim and a router that imports every wrapper", () => { + const result = prepareApp([ + { + index: 0, + fn: { + name: "alpha", + entry: "main.ts", + files: { "main.ts": serve("a"), "helper.ts": "export const x = 1;" }, + }, + }, + { + index: 1, + fn: { name: "beta", entry: "main.ts", files: { "main.ts": serve("b") } }, + }, + ]); + + expect(result.entry).toBe("__base44_entry.mjs"); + // User files live under their function's namespace (sealed from siblings). + expect(result.files["fn_0/main.ts"]).toContain("a"); + expect(result.files["fn_0/helper.ts"]).toContain("= 1"); + expect(result.files["fn_1/main.ts"]).toContain("b"); + // One register-wrapper per function, importing into that namespace lazily. + expect(result.files["__base44_fn_0.mjs"]).toContain('registerLazy("alpha"'); + expect(result.files["__base44_fn_0.mjs"]).toContain( + 'import("./fn_0/main.ts")', + ); + expect(result.files["__base44_fn_1.mjs"]).toContain('registerLazy("beta"'); + // One shared shim and a router that imports every wrapper and dispatches. + expect(result.files["__base44_deno_shim.mjs"]).toContain("globalThis.Deno"); + expect(result.files["__base44_entry.mjs"]).toContain( + 'import "./__base44_fn_0.mjs"', + ); + expect(result.files["__base44_entry.mjs"]).toContain( + 'import "./__base44_fn_1.mjs"', + ); + expect(result.files["__base44_entry.mjs"]).toContain("resolveHandler"); + expect(result.files["__base44_entry.mjs"]).toContain( + "installStaticEgressFetch()", + ); + expect(result.files["__base44_entry.mjs"]).toContain( + "runWithWorkerEnvironment as _b44Run", + ); + expect(result.files["__base44_entry.mjs"]).toContain("workerEnv: env"); + expect(result.files["__base44_entry.mjs"]).not.toContain( + "base44.workerEnvironment", + ); + expect( + result.files["__base44_entry.mjs"].indexOf( + "installStaticEgressFetch()", + ), + ).toBeLessThan( + result.files["__base44_entry.mjs"].indexOf( + "resolveHandler(functionName)", + ), + ); + // Per-app bundles share one script, so the routed function name rides the + // log context store and the console patch stamps it on every log line. + expect(result.files["__base44_entry.mjs"]).toContain( + "fn: functionName ?? ''", + ); + expect(result.files["__base44_entry.mjs"]).toContain("_b44_function"); + // Background-work hook: request-scoped ctx.waitUntil rides the store. + expect(result.files["__base44_entry.mjs"]).toContain( + "waitUntil: (p) => ctx.waitUntil(p)", + ); + expect(result.files["__base44_entry.mjs"]).toContain("globalThis.Base44"); + // The Worker env binding rides the store so Base44.secrets / base44:runtime + // secrets.get can read it per-request. + expect(result.files["__base44_entry.mjs"]).toContain("secrets: env,"); + // Crashes are logged through the patch (attributed) before rethrowing, + // because CF's own exception event carries no function attribution. + expect(result.files["__base44_entry.mjs"]).toContain("console.error(e);"); + expect(result.files["__base44_entry.mjs"]).toContain("throw e;"); + }); + + it("keys files by the original index so attribution survives excluding a function", () => { + // A survivor rebuild passes the original index, not its new position. + const result = prepareApp([ + { + index: 2, + fn: { name: "gamma", entry: "main.ts", files: { "main.ts": serve("g") } }, + }, + ]); + expect(result.files["fn_2/main.ts"]).toContain("g"); + expect(result.files["__base44_fn_2.mjs"]).toContain( + 'import("./fn_2/main.ts")', + ); + }); + + it("rejects reserved injected filenames in a function's input", () => { + expect(() => + prepareApp([ + { + index: 0, + fn: { + name: "a", + entry: "main.ts", + files: { "main.ts": serve("ok"), "__base44_entry.mjs": "malicious" }, + }, + }, + ]), + ).toThrowError(DenoCompatError); + }); + + it("reserves the activation-shim basename at ANY depth per function, in every mode", () => { + // In the app path each function's files are namespaced under fn_/, so + // a nested `sub/__base44_activation.mjs` would become + // `fn_0/sub/__base44_activation.mjs` — it must be rejected up front (in every + // mode) so it can't pose as the injected shim and reach the PDS manifest store. + const fn = { + index: 0, + fn: { + name: "a", + entry: "main.ts", + files: { "main.ts": serve("ok"), "sub/__base44_activation.mjs": "user file" }, + }, + }; + const err = /Reserved filename "__base44_activation\.mjs"/; + expect(() => prepareApp([fn])).toThrowError(err); + expect(() => prepareApp([fn], false, true)).toThrowError(err); + }); + + it("bakes the activation gate before routing only in runtime-secrets mode", () => { + const fn = { index: 0, fn: { name: "a", entry: "main.ts", files: { "main.ts": serve("a") } } }; + + const plain = prepareApp([fn]); + expect(plain.files["__base44_entry.mjs"]).not.toContain("ensureActivation"); + expect(plain.files["__base44_activation.mjs"]).toBeUndefined(); + + const gated = prepareApp([fn], false, true); + const entry = gated.files["__base44_entry.mjs"]; + expect(gated.files["__base44_activation.mjs"]).toContain("X-Base44-Needs-Activation"); + // Activation gates BEFORE resolveHandler (which imports the user module). + expect(entry.indexOf("ensureActivation")).toBeLessThan(entry.indexOf("resolveHandler(functionName)")); + expect(entry).toContain("withoutRuntimeSecretsHeader(request)"); + expect(entry).toContain("withoutActivationSignal("); + }); +}); diff --git a/packages/functions-compiler/test/workerd-runtime.e2e.test.ts b/packages/functions-compiler/test/workerd-runtime.e2e.test.ts new file mode 100644 index 000000000..f5a24b1f7 --- /dev/null +++ b/packages/functions-compiler/test/workerd-runtime.e2e.test.ts @@ -0,0 +1,1114 @@ +/** + * Runtime behavior in real workerd. + * + * The bundle-output tests prove the bundler made the right resolution + * decisions; these prove the bundled output actually *runs* under workerd with + * `nodejs_compat` — the only thing that catches the runtime-semantics failures + * the `worker-bundler-fixes` were built for (dynamic-require throws at init, + * `(0, X.default) is not a function`, tslib interop, the node vs browser module + * variant, etc.). One representative package per fix, plus Deno.env, the + * post-install builtin-stub case, and @base44/sdk. + * + * Bundling runs in-process via `bundle()`; execution runs in Miniflare-hosted + * workerd via `runInWorkerd` (see test/workerd.ts), pinned to the production WfP + * compat config. Hits registry.npmjs.org for each package — hence the long + * timeouts in vitest.config.ts. + */ + +import { describe, expect, it } from "vitest"; + +import { bundle } from "../src/bundler"; +import { CONSOLE_PATCH } from "../src/worker-entry"; +import { bundleOrThrow as bundled, bundleAppOrThrow as bundledApp } from "./helpers"; +import { runInWorkerd, WFP_COMPAT_DATE } from "./workerd"; +import { Miniflare } from "miniflare"; + +describe("runtime behavior in workerd", () => { + it("invokes the Deno.serve handler", async () => { + const m = await bundled('Deno.serve(() => new Response("hello from workerd"));'); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("hello from workerd"); + }); + + // Build a minimal bundle using the exact same CONSOLE_PATCH as production, + // with a handler that returns _b44Store.getStore() so we can assert the env + // tag without needing to parse workerd log output. + const envTagBundle = ` +import { AsyncLocalStorage } from 'node:async_hooks'; +const _b44Store = new AsyncLocalStorage(); +const _b44Context = () => _b44Store.getStore(); +${CONSOLE_PATCH} +export default { + async fetch(request) { + const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; + const functionName = request.headers.get("Base44-Function-Name"); + return _b44Store.run({ env: _b44Env, fn: functionName ?? '' }, () => { + const store = _b44Store.getStore(); + return Response.json({ env: store.env, fn: store.fn }); + }); + }, +}; +`; + + it("tags env as 'prod' when base44-functions-version: prod header is present", async () => { + const { status, text } = await runInWorkerd(envTagBundle, { + headers: { "base44-functions-version": "prod" }, + }); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ env: "prod", fn: "" }); + }); + + it("tags env as 'preview' when base44-functions-version header is absent", async () => { + const { status, text } = await runInWorkerd(envTagBundle, {}); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ env: "preview", fn: "" }); + }); + + it("completes Base44.waitUntil work after the response", async () => { + // Cloudflare cancels promises left pending after the response unless they + // ride ctx.waitUntil; the prelude's Base44.waitUntil must bridge to it. + const bundle = ` +import { AsyncLocalStorage } from 'node:async_hooks'; +const _b44Store = new AsyncLocalStorage(); +const _b44Context = () => _b44Store.getStore(); +${CONSOLE_PATCH} +let backgroundDone = false; +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + if (url.pathname === "/check") return Response.json({ backgroundDone }); + return _b44Store.run({ env: 'preview', waitUntil: (p) => ctx.waitUntil(p) }, () => { + globalThis.Base44.waitUntil((async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + backgroundDone = true; + })()); + return Response.json({ backgroundDone }); + }); + }, +}; +`; + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: bundle }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + }); + try { + const first = await mf.dispatchFetch("http://localhost/"); + expect(await first.json()).toEqual({ backgroundDone: false }); + await new Promise((resolve) => setTimeout(resolve, 150)); + const second = await mf.dispatchFetch("http://localhost/check"); + expect(await second.json()).toEqual({ backgroundDone: true }); + } finally { + await mf.dispose(); + } + }); + + it("carries the routed function name in the log context store", async () => { + const { status, text } = await runInWorkerd(envTagBundle, { + headers: { "Base44-Function-Name": "inbox-sync" }, + }); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ env: "preview", fn: "inbox-sync" }); + }); + + it("single-function bundle works with prod header (no regression)", async () => { + const m = await bundled('Deno.serve(() => new Response("ok"));'); + const { status } = await runInWorkerd(m, { + headers: { "base44-functions-version": "prod" }, + }); + expect(status).toBe(200); + }); + + it("multi-function bundle routes correctly with prod header (no regression)", async () => { + const m = await bundledApp([ + { name: "greet", files: { "main.ts": 'Deno.serve(() => new Response("hi"));' } }, + ]); + const { status } = await runInWorkerd(m, { + headers: { "base44-functions-version": "prod", "Base44-Function-Name": "greet" }, + }); + expect(status).toBe(200); + }); + + it.each(["single-function", "per-app"] as const)( + "routes the final %s artifact through handler-env static egress without bypassing telemetry", + async (mode) => { + const source = ` + import { http } from "base44:private-data-sources/http"; + + const privateSource = http("Internal API"); + const fetchInstances = new Set([globalThis.fetch]); + + Deno.serve(async (request) => { + fetchInstances.add(globalThis.fetch); + if (new URL(request.url).pathname === "/detached") { + fetch("https://detached.example.com/check").catch(() => {}); + return new Response("detached"); + } + const responses = await Promise.all([ + fetch("https://string.example.com/check"), + fetch(new URL("https://url.example.com/check")), + fetch(new Request("https://request.example.com/check")), + fetch("https://private.example.com/global"), + fetch("https://api.base44.app/platform"), + privateSource.fetch("/direct"), + ]); + const [stringFetch, urlFetch, requestFetch, privateGlobal, platformGlobal, + privateDirect] = await Promise.all( + responses.map((response) => response.text()), + ); + return Response.json({ + fetchInstances: fetchInstances.size, + stringFetch, + urlFetch, + requestFetch, + privateGlobal, + platformGlobal, + privateDirect, + }); + }); + `; + const module = + mode === "single-function" + ? await bundled(source, "main.ts", true) + : await bundledApp( + [{ name: "probe", files: { "main.ts": source } }], + true, + ); + expect(module).not.toContain("cloudflare:workers"); + expect(module).not.toContain("base44.workerEnvironment"); + expect(module).toContain("STATIC_EGRESS"); + expect(module).toContain("post_response_work"); + + const manifest = JSON.stringify([ + { + name: "Internal API", + type: "http", + bindingName: "DATA_SOURCE_INTERNAL", + bindingKind: "vpc_service", + host: "private.example.com", + port: 443, + baseUrl: "https://private.example.com", + }, + ]); + const staticHosts: string[] = []; + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: module }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + bindings: { + BASE44_PRIVATE_DATA_SOURCES: manifest, + BASE44_STATIC_EGRESS_ENABLED: "1", + BASE44_STATIC_EGRESS_EXCLUDED_HOSTS: JSON.stringify([".base44.app"]), + }, + serviceBindings: { + STATIC_EGRESS: async (request) => { + const hostname = new URL(request.url).hostname; + staticHosts.push(hostname); + if (hostname === "detached.example.com") { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return new Response(`static:${hostname}`); + }, + DATA_SOURCE_INTERNAL: async (request) => + new Response(`pds:${new URL(request.url).hostname}`), + }, + outboundService: async (request) => + new Response(`ordinary:${new URL(request.url).hostname}`), + }); + + try { + const headers = + mode === "per-app" ? { "Base44-Function-Name": "probe" } : undefined; + const responses = await Promise.all([ + mf.dispatchFetch("http://localhost/", { headers }), + mf.dispatchFetch("http://localhost/", { headers }), + ]); + for (const response of responses) { + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + fetchInstances: 1, + stringFetch: "static:string.example.com", + urlFetch: "static:url.example.com", + requestFetch: "static:request.example.com", + privateGlobal: "ordinary:private.example.com", + platformGlobal: "ordinary:api.base44.app", + privateDirect: "pds:private.example.com", + }); + } + + const detached = await mf.dispatchFetch("http://localhost/detached", { + headers, + }); + expect(detached.status).toBe(200); + expect(await detached.text()).toBe("detached"); + const telemetry = JSON.parse( + detached.headers.get("X-B44-Post-Response-Telemetry")!, + ); + expect(telemetry.pending.targets).toEqual([ + "https://detached.example.com", + ]); + expect(staticHosts).toContain("detached.example.com"); + } finally { + await mf.dispose(); + } + }, + ); + // ── base44:runtime + export-default handler contract ────────────────────── + + it("uses native fetch for every input form when the final artifact has no binding", async () => { + const module = await bundled( + ` + Deno.serve(async () => { + const responses = await Promise.all([ + fetch("https://string.example.com/check"), + fetch(new URL("https://url.example.com/check")), + fetch(new Request("https://request.example.com/check")), + ]); + return Response.json(await Promise.all( + responses.map((response) => response.text()), + )); + }); + `, + "main.ts", + true, + ); + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: module }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + outboundService: async (request) => + new Response(`ordinary:${new URL(request.url).hostname}`), + }); + + try { + const response = await mf.dispatchFetch("http://localhost/"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual([ + "ordinary:string.example.com", + "ordinary:url.example.com", + "ordinary:request.example.com", + ]); + } finally { + await mf.dispose(); + } + }); + + it("serves a default-exported handler (no Deno.serve)", async () => { + const m = await bundled(` + export default async function (req: Request): Promise { + return new Response("default export served"); + } + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("default export served"); + }); + + it("prefers the Deno.serve capture over a default export", async () => { + const m = await bundled(` + Deno.serve(() => new Response("from serve")); + export default () => new Response("from default"); + `); + const { text } = await runInWorkerd(m); + expect(text).toBe("from serve"); + }); + + it("returns 503 when a function has neither Deno.serve nor a default export", async () => { + const m = await bundled("export const x = 1;"); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(503); + expect(text).toContain("export default a request handler or call Deno.serve()"); + }); + + it("routes a default-exported handler in a per-app bundle", async () => { + const m = await bundledApp([ + { + name: "modern", + files: { "main.ts": "export default () => new Response('modern');" }, + }, + { name: "legacy", files: { "main.ts": 'Deno.serve(() => new Response("legacy"));' } }, + ]); + const modern = await runInWorkerd(m, { + headers: { "Base44-Function-Name": "modern" }, + }); + expect(modern).toEqual({ status: 200, text: "modern" }); + const legacy = await runInWorkerd(m, { + headers: { "Base44-Function-Name": "legacy" }, + }); + expect(legacy).toEqual({ status: 200, text: "legacy" }); + }); + + it("reads secrets via base44:runtime from the Worker env binding", async () => { + const m = await bundled(` + import { secrets } from "base44:runtime"; + export default () => Response.json({ + secret: secrets.get("MY_SECRET"), + missing: secrets.get("NOT_SET") ?? null, + }); + `); + const { status, text } = await runInWorkerd(m, { env: { MY_SECRET: "sekret-123" } }); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ secret: "sekret-123", missing: null }); + }); + + it("does NOT expose the reserved private-data-sources manifest via secrets.get", async () => { + // The manifest carries plaintext VPC DB credentials; user code reaches data + // sources via base44:private-data-sources/* imports, never the raw manifest. + const m = await bundled(` + import { secrets } from "base44:runtime"; + export default () => Response.json({ + manifest: secrets.get("BASE44_PRIVATE_DATA_SOURCES") ?? null, + realSecret: secrets.get("MY_SECRET") ?? null, + }); + `); + const { status, text } = await runInWorkerd(m, { + env: { + BASE44_PRIVATE_DATA_SOURCES: JSON.stringify([{ name: "db", password: "leak-me" }]), + MY_SECRET: "ok", + }, + }); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ manifest: null, realSecret: "ok" }); + }); + + it("does not leak the manifest via a boxed-String key (coercion bypass)", async () => { + // `new String("BASE44_...")` fails Set.has (object identity) but coerces to + // the reserved key on the env lookup — String(n) must close that path. + const m = await bundled(` + import { secrets } from "base44:runtime"; + export default () => Response.json({ + boxed: (secrets.get as any)(new String("BASE44_PRIVATE_DATA_SOURCES")) ?? null, + }); + `); + const { status, text } = await runInWorkerd(m, { + env: { BASE44_PRIVATE_DATA_SOURCES: JSON.stringify([{ password: "leak-me" }]) }, + }); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ boxed: null }); + }); + + it("completes base44:runtime waitUntil work after the response and returns the promise", async () => { + const m = await bundled(` + import { waitUntil } from "base44:runtime"; + let backgroundDone = false; + export default async (req: Request) => { + const url = new URL(req.url); + if (url.pathname === "/check") return Response.json({ backgroundDone }); + const p = waitUntil((async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + backgroundDone = true; + return "done"; + })()); + return Response.json({ returnsPromise: typeof p?.then === "function", backgroundDone }); + }; + `); + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: m }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + }); + try { + const first = await mf.dispatchFetch("http://localhost/"); + expect(await first.json()).toEqual({ returnsPromise: true, backgroundDone: false }); + await new Promise((resolve) => setTimeout(resolve, 150)); + const second = await mf.dispatchFetch("http://localhost/check"); + expect(await second.json()).toEqual({ backgroundDone: true }); + } finally { + await mf.dispose(); + } + }); + + it("supports the EdgeRuntime.waitUntil compat alias", async () => { + const m = await bundled(` + export default () => { + const er = (globalThis as any).EdgeRuntime; + er.waitUntil(Promise.resolve()); + return Response.json({ hasAlias: typeof er?.waitUntil === "function" }); + }; + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ hasAlias: true }); + }); + + it("reads Deno.env at top level and inside the handler", async () => { + const m = await bundled(` + const TOP = Deno.env.get("MY_SECRET"); + Deno.serve(() => Response.json({ + top: TOP, + inHandler: Deno.env.get("MY_SECRET"), + has: Deno.env.has("MY_SECRET"), + missing: Deno.env.get("NOT_SET") ?? null, + })); + `); + const { status, text } = await runInWorkerd(m, { env: { MY_SECRET: "sekret-123" } }); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ + top: "sekret-123", + inHandler: "sekret-123", + has: true, + missing: null, + }); + }); + + it("rejects ambiguous private data source name lookups", async () => { + const m = await bundled(` + import { http } from "base44:private-data-sources/http"; + + Deno.serve(() => { + try { + http("Sales API"); + return new Response("unexpected", { status: 200 }); + } catch (error) { + return new Response(error instanceof Error ? error.message : String(error), { status: 409 }); + } + }); + `); + const manifest = JSON.stringify([ + { + name: "Sales API", + type: "http", + bindingName: "DATA_SOURCE_SALES_A", + bindingKind: "vpc_service", + host: "sales-a.internal", + port: 80, + baseUrl: "http://sales-a.internal:80" + }, + { + name: "Sales API", + type: "http", + bindingName: "DATA_SOURCE_SALES_B", + bindingKind: "vpc_service", + host: "sales-b.internal", + port: 80, + baseUrl: "http://sales-b.internal:80" + } + ]); + + const { status, text } = await runInWorkerd(m, { + env: { BASE44_PRIVATE_DATA_SOURCES: manifest }, + }); + + expect(status).toBe(409); + expect(text).toContain('Private data source "Sales API" is ambiguous'); + }); + + it("passes Redis manifest credentials through ioredis options", async () => { + const m = await bundled(` + import { redis } from "base44:private-data-sources/redis"; + + Deno.serve(() => { + const options = redis("Cache").ioredisOptions(); + return Response.json({ + username: options.username, + password: options.password, + connectorType: typeof options.Connector, + }); + }); + `); + const manifest = JSON.stringify([{ + name: "Cache", + type: "redis", + bindingName: "DATA_SOURCE_REDIS", + bindingKind: "vpc_service", + host: "redis.internal", + port: 6379, + username: "acl_user", + password: "redis-secret", + }]); + + const { status, text } = await runInWorkerd(m, { + env: { + BASE44_PRIVATE_DATA_SOURCES: manifest, + DATA_SOURCE_REDIS: "bound", + }, + }); + + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ + username: "acl_user", + password: "redis-secret", + connectorType: "function", + }); + }); + + it("leaves credentials out of ioredis options for unauthenticated Redis", async () => { + const m = await bundled(` + import { redis } from "base44:private-data-sources/redis"; + + Deno.serve(() => { + const options = redis("Cache").ioredisOptions(); + return Response.json({ + hasUsername: "username" in options, + hasPassword: "password" in options, + connectorType: typeof options.Connector, + }); + }); + `); + const manifest = JSON.stringify([{ + name: "Cache", + type: "redis", + bindingName: "DATA_SOURCE_REDIS", + bindingKind: "vpc_service", + host: "redis.internal", + port: 6379, + }]); + + const { status, text } = await runInWorkerd(m, { + env: { + BASE44_PRIVATE_DATA_SOURCES: manifest, + DATA_SOURCE_REDIS: "bound", + }, + }); + + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ + hasUsername: false, + hasPassword: false, + connectorType: "function", + }); + }); + + it("does not emit a Redis username without a password", async () => { + const m = await bundled(` + import { redis } from "base44:private-data-sources/redis"; + + Deno.serve(() => { + const options = redis("Cache").ioredisOptions(); + return Response.json({ + hasUsername: "username" in options, + hasPassword: "password" in options, + }); + }); + `); + const manifest = JSON.stringify([{ + name: "Cache", + type: "redis", + bindingName: "DATA_SOURCE_REDIS", + bindingKind: "vpc_service", + host: "redis.internal", + port: 6379, + username: "acl_user", + }]); + + const { status, text } = await runInWorkerd(m, { + env: { + BASE44_PRIVATE_DATA_SOURCES: manifest, + DATA_SOURCE_REDIS: "bound", + }, + }); + + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ + hasUsername: false, + hasPassword: false, + }); + }); + + // ── Post-response detached-work telemetry ───────────────────────────────── + // Runs a real bundle in workerd and captures runtime stdio: telemetry lines + // go through the console patch, so they land in workerd's stdout. + + async function runCapturingLogs( + bundle: string, + headers: Record = {}, + ): Promise<{ logs: string; telemetryHeader: string | null }> { + const chunks: string[] = []; + let telemetryHeader: string | null = null; + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: bundle }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + handleRuntimeStdio(stdout, stderr) { + stdout.on("data", (d) => chunks.push(String(d))); + stderr.on("data", (d) => chunks.push(String(d))); + }, + }); + try { + const res = await mf.dispatchFetch("http://localhost/", { headers }); + expect(res.status).toBe(200); + telemetryHeader = res.headers.get("X-B44-Post-Response-Telemetry"); + // Let waitUntil-sanctioned background work (if any) run before teardown. + await new Promise((resolve) => setTimeout(resolve, 200)); + } finally { + await mf.dispose(); + } + return { logs: chunks.join(""), telemetryHeader }; + } + + it("logs inflight_at_response for detached fetches workerd will cancel", async () => { + const m = await bundled(` + Deno.serve(() => { + // The lunair shape: fire-and-forget fetch, response returned first. + fetch("http://127.0.0.1:1/unreachable").catch(() => {}); + return new Response("ok"); + }); + `, "main.ts", true); + const { logs, telemetryHeader } = await runCapturingLogs(m); + expect(logs).toContain("inflight_at_response"); + // The only trace of unsanctioned work is this line — it must name what + // workerd is about to cancel. + expect(logs).toContain('"targets":["http://127.0.0.1:1"]'); + // The same signal rides the response header for the Datadog bridge. + expect(telemetryHeader).not.toBeNull(); + const relayed = JSON.parse(telemetryHeader!); + expect(relayed.pending.targets).toEqual(["http://127.0.0.1:1"]); + // Deliberate fire-and-forget has no pre-response rejection and no + // pre-response non-ok resolution — the discriminators that keep the + // target population flagged. + expect(relayed.pending.rejected_pre_response).toBe(false); + expect(relayed.pending.non_ok_pre_response).toBe(false); + }); + + it("flags Promise.all fail-fast fallout via rejected_pre_response", async () => { + // The accidental-orphan shape: parallel fetches, one rejects before the + // handler responds, siblings still in flight at response. The pre-response + // rejection separates this from deliberate fire-and-forget (above), even + // when the handler swallows the error and still returns a 2xx. + const m = await bundled(` + Deno.serve(async () => { + fetch("http://upstream/sibling").catch(() => {}); + const ac = new AbortController(); + const doomed = fetch("http://upstream/doomed", { signal: ac.signal }); + ac.abort(); + try { await doomed; } catch (e) {} + return new Response("degraded"); + }); + `, "main.ts", true); + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: m }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + // The sibling never settles — pending at response by construction. + outboundService: () => new Promise(() => {}), + }); + try { + const res = await mf.dispatchFetch("http://localhost/"); + expect(res.status).toBe(200); + const relayed = JSON.parse(res.headers.get("X-B44-Post-Response-Telemetry")!); + expect(relayed.pending.rejected_pre_response).toBe(true); + expect(relayed.pending.targets).toEqual(["http://upstream"]); + } finally { + await mf.dispose(); + } + }); + + it("flags app-level fail-fast via non_ok_pre_response", async () => { + // The SDK/helper shape: the fetch RESOLVES with a non-ok status, a wrapper + // throws on !res.ok, Promise.all fail-fasts, the handler catches and still + // returns 2xx with the sibling orphaned. The fetch promise never rejected, + // so rejected_pre_response can't see it — non_ok_pre_response does. + const m = await bundled(` + Deno.serve(async () => { + const read = async (path) => { + const res = await fetch("http://upstream" + path); + if (!res.ok) throw new Error("upstream " + res.status); + return res; + }; + try { + await Promise.all([read("/hang"), read("/rate-limited")]); + return new Response("unexpected-ok"); + } catch (e) { + return new Response("degraded"); + } + }); + `, "main.ts", true); + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: m }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + outboundService: (req) => + new URL(req.url).pathname === "/rate-limited" + ? new Response("slow down", { status: 429 }) + : new Promise(() => {}), // sibling never settles + }); + try { + const res = await mf.dispatchFetch("http://localhost/"); + expect(res.status).toBe(200); + const relayed = JSON.parse(res.headers.get("X-B44-Post-Response-Telemetry")!); + expect(relayed.pending.non_ok_pre_response).toBe(true); + expect(relayed.pending.rejected_pre_response).toBe(false); + expect(relayed.pending.targets).toEqual(["http://upstream"]); + } finally { + await mf.dispose(); + } + }); + + it("keeps a detached fetch chain alive WITHOUT user-code waitUntil", async () => { + // The overload rides ctx.waitUntil on every observed fetch, so a plain + // fire-and-forget chain survives the response: fetch A (pending at + // response) completes instead of being cancelled, its continuation runs, + // and fetch B is both issued upstream and logged as post-response. + const m = await bundled(` + Deno.serve(() => { + (async () => { + await fetch("http://upstream/a").catch(() => {}); + await fetch("http://upstream/b").catch(() => {}); + })(); + return new Response("ok"); + }); + `, "main.ts", true); + const seen: string[] = []; + const chunks: string[] = []; + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: m }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + handleRuntimeStdio(stdout, stderr) { + stdout.on("data", (d) => chunks.push(String(d))); + stderr.on("data", (d) => chunks.push(String(d))); + }, + outboundService: async (req) => { + seen.push(new URL(req.url).pathname); + await new Promise((resolve) => setTimeout(resolve, 50)); + return new Response("ok"); + }, + }); + try { + const res = await mf.dispatchFetch("http://localhost/"); + expect(res.status).toBe(200); + // fetch A was pending at response — reported. + const relayed = JSON.parse(res.headers.get("X-B44-Post-Response-Telemetry")!); + expect(relayed.pending.targets).toEqual(["http://upstream"]); + // Give the kept-alive chain time to run its continuation. + await new Promise((resolve) => setTimeout(resolve, 400)); + } finally { + await mf.dispose(); + } + // Without the platform-side waitUntil ride, workerd cancels the chain + // after A and /b is never requested. + expect(seen).toEqual(["/a", "/b"]); + expect(chunks.join("")).toContain("fetch_started_post_response"); + }); + + it("emits no telemetry for a clean request", async () => { + const m = await bundled(` + Deno.serve(async () => { + try { await fetch("http://127.0.0.1:1/x"); } catch (e) {} + return new Response("ok"); + }); + `, "main.ts", true); + const { logs, telemetryHeader } = await runCapturingLogs(m); + expect(logs).not.toContain("b44_telemetry"); + expect(telemetryHeader).toBeNull(); + }); + + it("per-app topology: signals carry function attribution, clean sibling silent", async () => { + // Production CFW runs the per-app (multi-function) entry — a separate + // template from the single-function path. + const m = await bundledApp([ + { name: "detached-fn", files: { "main.ts": ` + Deno.serve(() => { + fetch("http://127.0.0.1:1/unreachable").catch(() => {}); + return new Response("ok"); + }); + ` } }, + { name: "clean-fn", files: { "main.ts": 'Deno.serve(() => new Response("ok"));' } }, + ], true); + const chunks: string[] = []; + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: m }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + handleRuntimeStdio(stdout, stderr) { + stdout.on("data", (d) => chunks.push(String(d))); + stderr.on("data", (d) => chunks.push(String(d))); + }, + }); + try { + const detached = await mf.dispatchFetch("http://localhost/", { + headers: { "Base44-Function-Name": "detached-fn" }, + }); + expect(detached.status).toBe(200); + const relayed = JSON.parse(detached.headers.get("X-B44-Post-Response-Telemetry")!); + expect(relayed.pending.targets).toEqual(["http://127.0.0.1:1"]); + + const clean = await mf.dispatchFetch("http://localhost/", { + headers: { "Base44-Function-Name": "clean-fn" }, + }); + expect(clean.status).toBe(200); + expect(clean.headers.get("X-B44-Post-Response-Telemetry")).toBeNull(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } finally { + await mf.dispose(); + } + const logs = chunks.join(""); + expect(logs).toContain("inflight_at_response"); + expect(logs).toContain("_b44_function: 'detached-fn'"); + expect(logs).not.toContain("_b44_function: 'clean-fn'"); + }); + + it("flag off (default): detached fetches emit no telemetry — prod baseline", async () => { + const m = await bundled(` + Deno.serve(() => { + fetch("http://127.0.0.1:1/unreachable").catch(() => {}); + return new Response("ok"); + }); + `); + const { logs, telemetryHeader } = await runCapturingLogs(m); + expect(logs).not.toContain("b44_telemetry"); + expect(telemetryHeader).toBeNull(); + }); + + it("flag-off module carries no telemetry artifacts (prod-baseline bytes)", async () => { + const src = 'Deno.serve(() => new Response("ok"));'; + const off = await bundled(src); + for (const marker of ["post_response_work", "_b44OnResponded", "_b44AttachTelemetry", "randomUUID", "inflightTargets"]) { + expect(off, `flag-off module must not contain ${marker}`).not.toContain(marker); + } + const on = await bundled(src, "main.ts", true); + expect(on).toContain("post_response_work"); + }); + + it("keeps duplicate in-flight targets until the last sibling settles", async () => { + // Two overlapping fetches to the same origin+path; the FIRST settles + // before the response while the second stays pending. Refcounting must + // keep the target in the report — a plain Set deletes it on the first + // settle and reports inflight=1 with empty targets. Settlement order is + // driven deterministically from the test via outboundService. + const m = await bundled(` + Deno.serve(async () => { + const first = fetch("http://upstream/dup").catch(() => {}); + fetch("http://upstream/dup").catch(() => {}); + await first; // settles when the test releases it; sibling never does + return new Response("ok"); + }); + `, "main.ts", true); + let release!: () => void; + const released = new Promise((resolve) => (release = resolve)); + let calls = 0; + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: m }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + outboundService: async () => { + calls += 1; + if (calls === 1) { + await released; + return new Response("first"); + } + return new Promise(() => {}); // sibling: never settles + }, + }); + try { + const dispatched = mf.dispatchFetch("http://localhost/"); + setTimeout(release, 100); + const res = await dispatched; + expect(res.status).toBe(200); + const relayed = JSON.parse(res.headers.get("X-B44-Post-Response-Telemetry")!); + expect(relayed.pending.inflight).toBe(1); + expect(relayed.pending.targets).toEqual(["http://upstream"]); + } finally { + await mf.dispose(); + } + }); + + // Issue 1 — extensionless entry (entry-extensions). form-data's entry + // (`browser`/`main`) has no extension; without the fix it's "No such module + // form-data" at import. In a browser-targeted bundle the `browser` field + // resolves to form-data's browser entry (native web FormData in workerd), so + // we assert the universal `.append`, not the Node-only `.getBoundary`. + it("form-data: extensionless entry resolves and constructs (#1)", async () => { + const m = await bundled(` + import FormData from "npm:form-data@4.0.5"; + Deno.serve(() => new Response(String( + typeof FormData === "function" && typeof new FormData().append === "function" + ))); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("true"); + }); + + // Issue 2 — browser field (axios's node http adapter must be excluded, else + // it pulls node http and crashes at module init). + it("axios: browser build loads without the node adapter (#2)", async () => { + const m = await bundled(` + import axios from "npm:axios@1.7.7"; + Deno.serve(() => new Response(String( + typeof axios.get === "function" && typeof axios.create === "function" + ))); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("true"); + }); + + it("mysql2: an undeclared optional require takes its runtime fallback", async () => { + const m = await bundled(` + import mysql from "npm:mysql2@3.13.0"; + Deno.serve(() => new Response(mysql.escape("O'Reilly"))); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("'O\\'Reilly'"); + }); + + // Issue 3 — node condition wins (engine.io-client's node build does + // require("fs") via ws; the browser build uses native WebSocket/fetch). + it("engine.io-client: browser build loads (no require('fs') at init) (#3)", async () => { + const m = await bundled(` + import { Socket } from "npm:engine.io-client@^6.6.0"; + Deno.serve(() => new Response(String(typeof Socket === "function"))); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("true"); + }); + + // Issue 4 — subpath imports of a no-`exports` package (synthesize-exports). + // Wrong resolution binds to validator/index.js → not a function. + it("validator: deep subpath import resolves and runs (#4)", async () => { + const m = await bundled(` + import isEmail from "npm:validator@13.12.0/lib/isEmail.js"; + Deno.serve(() => new Response(String(isEmail("a@b.com") === true && isEmail("nope") === false))); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("true"); + }); + + // Issue 5 — CJS require() of node builtins (node-builtin plugin). safe-buffer + // does require("buffer") at init; without the stub it throws at load. + it("jsonwebtoken: CJS require() of node builtins works at runtime (#5)", async () => { + const m = await bundled(` + import jwt from "npm:jsonwebtoken@9.0.2"; + Deno.serve(() => { + const token = jwt.sign({ a: 1 }, "k"); + return new Response(String(token.split(".").length === 3)); + }); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("true"); + }); + + // require() of a builtin resolves to a static import instead of crashing + // with "Dynamic require of X is not supported" — bare and `node:`-prefixed, + // including a subpath (fs/promises) that must survive to the re-export. + it("CJS require() of any node builtin resolves (fs, fs/promises, os) (#5)", async () => { + const m = await bundled(` + const os = require("os"); + const fs = require("fs"); + const fsp = require("fs/promises"); + const nfs = require("node:fs"); + const nfsp = require("node:fs/promises"); + Deno.serve(() => Response.json({ + fs: typeof fs.readFileSync, + fsp: typeof fsp.readFile, + os: typeof os.platform, + nfs: typeof nfs.readFileSync, + nfsp: typeof nfsp.readFile, + })); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ + fs: "function", + fsp: "function", + os: "function", + nfs: "function", + nfsp: "function", + }); + }); + + // Issue 6 — node-ESM wrapper interop (prefer-module-condition). pdf-lib → tslib; + // wrong interop is "Cannot destructure property '__extends'". + it("pdf-lib: tslib interop works (creates + saves a PDF) (#6)", async () => { + const m = await bundled(` + import { PDFDocument } from "npm:pdf-lib@1.17.1"; + Deno.serve(async () => { + const doc = await PDFDocument.create(); + doc.addPage(); + const bytes = await doc.save(); + return new Response(String(bytes.length > 0)); + }); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(text).toBe("true"); + }); + + // A real npm package named like a builtin (process) must win over the + // builtin, subpath included. Import path — esbuild resolves it directly; the + // plugin's guard is require()-only, so this pins plain resolution. + it("process/browser: real npm package wins over the builtin (import)", async () => { + const m = await bundled(` + import proc from "npm:process@0.11.10/browser"; + Deno.serve(() => Response.json({ + title: proc.title, + hasNextTick: typeof proc.nextTick === "function", + })); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ title: "browser", hasNextTick: true }); + }); + + // With Deno's resolver a *bare* require() of a name that is also a node + // builtin resolves to the builtin: a side-effect `import "npm:process"` does + // not remap the bare specifier (unlike the old flat-node_modules model). So + // workerd's process is returned (no `browser` field). To use a builtin-named + // npm package, import it explicitly via npm: — see the import-side test above, + // which passes. This documents the intended behavior change. + it("process: bare require() of a builtin name resolves to the builtin", async () => { + const m = await bundled(` + import "npm:process@0.11.10"; + Deno.serve(() => { + const proc = require("process"); + return Response.json({ + browser: proc.browser ?? null, + hasNextTick: typeof proc.nextTick === "function", + }); + }); + `); + const { status, text } = await runInWorkerd(m); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ browser: null, hasNextTick: true }); + }); + + // #17077 — jimp@0.16.13 has entry in `main`/`module` and no root index.js; + // the package.json entry fallback resolves the whole graph (end-to-end + // execution is covered by the package matrix). + it("jimp: package with main/module but no root index.js bundles (#7)", async () => { + const r = await bundle({ + entry: "main.ts", + files: { + "main.ts": ` + import Jimp from "npm:jimp@0.16.13"; + Deno.serve(() => new Response(String(typeof Jimp))); + `, + }, + postResponseTelemetry: false, + }); + expect(r.ok, r.ok ? "" : r.errors.map((e) => e.message).join("\n")).toBe( + true, + ); + }); + + // Exercises the fixes together (SDK pulls axios + socket.io-client + uuid): + // the bundle must evaluate and instantiate the client. + it("@base44/sdk: createClientFromRequest evaluates and instantiates", async () => { + const m = await bundled(` + import { createClientFromRequest } from "npm:@base44/sdk@^0.8.30"; + Deno.serve((req) => { + const c = createClientFromRequest(req); + return Response.json({ + hasAuthMe: typeof c?.auth?.me === "function", + hasEntities: c?.entities !== undefined && typeof c.entities === "object", + hasFunctionsInvoke: typeof c?.functions?.invoke === "function", + }); + }); + `); + const { status, text } = await runInWorkerd(m, { + headers: { "Base44-App-Id": "test-app-123", Authorization: "Bearer fake-user-token" }, + }); + expect(status).toBe(200); + expect(JSON.parse(text)).toMatchObject({ + hasAuthMe: true, + hasEntities: true, + hasFunctionsInvoke: true, + }); + }); +}); diff --git a/packages/functions-compiler/test/workerd.ts b/packages/functions-compiler/test/workerd.ts new file mode 100644 index 000000000..bdc327c2b --- /dev/null +++ b/packages/functions-compiler/test/workerd.ts @@ -0,0 +1,46 @@ +/** + * Execute a Node-produced bundle inside real workerd via Miniflare. + * + * Replaces the old wrangler `unstable_dev` + Worker-Loader runner fixture: now + * the bundler is a Node service, so we just hand its output string to Miniflare, + * which hosts it as a worker directly. Pinned to the SAME config WfP uploads + * with in production (backend/app/cloudflare_functions): main module + * "_bundled.mjs", compat date 2026-05-18, flags ["nodejs_compat"] — so a green + * test reflects the real runtime, not an approximation. + */ + +import { Miniflare } from "miniflare"; + +/** Mirror of `cloudflare_wfp_runtime.py` CloudflareWfpRuntime.compatibility_date. */ +export const WFP_COMPAT_DATE = "2026-05-18"; + +interface WorkerdRequest { + url?: string; + method?: string; + headers?: Record; + body?: string; + /** Becomes the worker's bindings → `cloudflare:workers` env → `Deno.env`. */ + env?: Record; +} + +export async function runInWorkerd( + bundled: string, + req: WorkerdRequest = {}, +): Promise<{ status: number; text: string }> { + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: bundled }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + bindings: req.env ?? {}, + }); + try { + const res = await mf.dispatchFetch(req.url ?? "http://localhost/", { + method: req.method, + headers: req.headers, + body: req.body, + }); + return { status: res.status, text: await res.text() }; + } finally { + await mf.dispose(); + } +} diff --git a/packages/functions-compiler/tsconfig.build.json b/packages/functions-compiler/tsconfig.build.json new file mode 100644 index 000000000..7f2d323b7 --- /dev/null +++ b/packages/functions-compiler/tsconfig.build.json @@ -0,0 +1,25 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "sourceMap": true, + "rootDir": ".", + "outDir": "lib" + }, + "include": [ + "src/index.ts", + "src/bundler.ts", + "src/contracts.ts", + "src/errors.ts", + "src/deno-bundle.ts", + "src/actor-compat.ts", + "src/worker-entry.ts", + "src/telemetry.ts", + "src/tracing.ts", + "src/log.ts", + "src/fetch-guard.ts", + "src/static-egress-marker.ts", + "src/esbuild/*.ts" + ] +} diff --git a/packages/functions-compiler/tsconfig.json b/packages/functions-compiler/tsconfig.json new file mode 100644 index 000000000..8397bf30a --- /dev/null +++ b/packages/functions-compiler/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable", "ESNext.Disposable"], + "types": ["node"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "baseUrl": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"] +} diff --git a/packages/functions-compiler/vitest.config.ts b/packages/functions-compiler/vitest.config.ts new file mode 100644 index 000000000..951f6d164 --- /dev/null +++ b/packages/functions-compiler/vitest.config.ts @@ -0,0 +1,23 @@ +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "base44:internal/runtime-context": fileURLToPath( + new URL("./src/runtime-context.ts", import.meta.url), + ), + // Resolved by the Deno bundler at deploy; stub it for vitest. + "npm:@base44/sdk@0.8.41": fileURLToPath( + new URL("./test/base44-sdk-stub.ts", import.meta.url), + ), + }, + }, + test: { + include: ["test/**/*.test.ts", "test/**/*.spec.ts"], + // Dependency fetch (real registry) + esbuild compile are slow on cold runs. + hookTimeout: 120_000, + testTimeout: 120_000, + }, +}); From a11079082e16c832ad932bb75b5d67b11a38d25a Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Thu, 10 Sep 2026 17:07:54 +0300 Subject: [PATCH 2/8] style(functions-compiler): apply the repo's Biome rules to the library modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- biome.json | 37 +++++++- packages/functions-compiler/README.md | 8 ++ .../functions-compiler/src/actor-compat.ts | 2 +- packages/functions-compiler/src/bundler.ts | 26 ++++-- .../functions-compiler/src/deno-bundle.ts | 11 +-- .../src/esbuild/deno-resolver.ts | 22 +++-- .../src/esbuild/node-builtin-require.ts | 2 - .../esbuild/private-data-sources-virtual.ts | 88 ++++++++++++------- .../src/esbuild/runtime-context-virtual.ts | 2 - .../src/esbuild/runtime-virtual.ts | 6 +- .../src/esbuild/user-files.ts | 1 - packages/functions-compiler/src/index.ts | 29 +++--- packages/functions-compiler/src/tracing.ts | 4 +- .../functions-compiler/src/worker-entry.ts | 55 ++++++++---- 14 files changed, 187 insertions(+), 106 deletions(-) diff --git a/biome.json b/biome.json index b2d7a9728..dacbb85d0 100644 --- a/biome.json +++ b/biome.json @@ -6,14 +6,31 @@ "organizeImports": { "level": "on", "options": { - "groups": [":NODE:", ":PACKAGE:", ":ALIAS:", ":PATH:"] + "groups": [ + ":NODE:", + ":PACKAGE:", + ":ALIAS:", + ":PATH:" + ] } } } } }, "files": { - "includes": ["**", "!**/dist", "!**/node_modules", "!**/tests/fixtures", "!**/*.d.ts"] + "includes": [ + "**", + "!**/dist", + "!**/node_modules", + "!**/tests/fixtures", + "!**/*.d.ts", + "!packages/functions-compiler/src/shim/**", + "!packages/functions-compiler/src/private-data-sources/**", + "!packages/functions-compiler/src/runtime/**", + "!packages/functions-compiler/src/runtime-context.ts", + "!packages/functions-compiler/src/static-egress.ts", + "!packages/functions-compiler/src/static-egress-marker.ts" + ] }, "formatter": { "enabled": true, @@ -46,12 +63,24 @@ "useNodejsImportProtocol": "error", "noNonNullAssertion": "off", "useArrayLiterals": "error", - "useConsistentArrayType": { "level": "error", "options": { "syntax": "shorthand" } } + "useConsistentArrayType": { + "level": "error", + "options": { + "syntax": "shorthand" + } + } }, "suspicious": { "noExplicitAny": "warn", "noVar": "error", - "noConsole": { "level": "off", "options": { "allow": ["log"] } }, + "noConsole": { + "level": "off", + "options": { + "allow": [ + "log" + ] + } + }, "noIrregularWhitespace": "error" }, "nursery": {} diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 69d26b41c..75fe9b1f8 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -62,6 +62,14 @@ The compile-time assets must stay TypeScript: the virtual plugins load them with esbuild's `ts` loader. `scripts/copy-assets.ts` copies them into `lib/` next to the compiled JS so the published package resolves them the same way. +**Do not reformat an asset.** Their text goes into the user's bundle, so +whitespace is part of the emitted worker bytes — running Biome over +`src/shim/`, `src/private-data-sources/`, `src/runtime/`, `runtime-context.ts`, +`static-egress.ts` or `static-egress-marker.ts` changes what every compiled +function hashes to. `biome.json` excludes those paths for that reason. The +`test/` directory is likewise held byte-identical to apper's copy until that +copy is deleted; it sits outside the repo's `packages/*/src` lint glob. + ## Commands ```bash diff --git a/packages/functions-compiler/src/actor-compat.ts b/packages/functions-compiler/src/actor-compat.ts index 01444d731..a22b1da91 100644 --- a/packages/functions-compiler/src/actor-compat.ts +++ b/packages/functions-compiler/src/actor-compat.ts @@ -213,7 +213,7 @@ installStaticEgressFetch(); } function buildEntryWrapper(doClassName: string, userEntry: string): string { - const userImport = "./" + userEntry.replace(/^\.\//, ""); + const userImport = `./${userEntry.replace(/^\.\//, "")}`; // ponytail: skip routePartykitRequest (uses Object.entries(env) which doesn't enumerate // DO namespace bindings in WfP dispatch context). Access env["ChatRoom"] directly instead. return `// Auto-generated by the base44 bundler. Do not edit. diff --git a/packages/functions-compiler/src/bundler.ts b/packages/functions-compiler/src/bundler.ts index a40ed1528..5e0d7dd36 100644 --- a/packages/functions-compiler/src/bundler.ts +++ b/packages/functions-compiler/src/bundler.ts @@ -1,7 +1,7 @@ +import { type ActorCompat, applyActorCompat } from "./actor-compat.js"; import type { BundleAppRequest, BundleRequest } from "./contracts.js"; import { bundleToModule, type NodeModulesMode } from "./deno-bundle.js"; -import { DenoCompatError, type BundleErrorItem } from "./errors.js"; -import { applyActorCompat, type ActorCompat } from "./actor-compat.js"; +import { type BundleErrorItem, DenoCompatError } from "./errors.js"; import { setSpanTags, withSpan } from "./tracing.js"; import { type AppFunctionEntry, @@ -142,7 +142,8 @@ export async function bundleApp( try { combined = await compileApp(entries, telemetry, runtimeSecrets); } catch (e) { - if (e instanceof DenoCompatError) return bundleAppPerFunction(entries, telemetry, runtimeSecrets); + if (e instanceof DenoCompatError) + return bundleAppPerFunction(entries, telemetry, runtimeSecrets); throw e; } if (combined.ok) { @@ -170,7 +171,8 @@ export async function bundleApp( try { rebuilt = await compileApp(survivors, telemetry, runtimeSecrets); } catch (e) { - if (e instanceof DenoCompatError) return bundleAppPerFunction(entries, telemetry, runtimeSecrets); + if (e instanceof DenoCompatError) + return bundleAppPerFunction(entries, telemetry, runtimeSecrets); throw e; } if (rebuilt.ok) return appResponse(rebuilt.module, functions); @@ -187,7 +189,9 @@ async function compileApp( telemetry = false, runtimeSecrets = false, ): Promise { - const outcome = await installAndCompile(prepareApp(entries, telemetry, runtimeSecrets)); + const outcome = await installAndCompile( + prepareApp(entries, telemetry, runtimeSecrets), + ); return outcome.ok ? { ok: true, module: outcome.module } : { ok: false, errors: outcome.errors }; @@ -263,7 +267,13 @@ async function bundleAppPerFunction( // shared npm package onto a version some importer can't use. Deterministic // user-dep breakage — a 500 here gets retried by the platform and surfaced // as an infrastructure error, hiding the diagnostics the agent needs. - return assembleWithoutConflicting(survivors, functions, combined.errors, telemetry, runtimeSecrets); + return assembleWithoutConflicting( + survivors, + functions, + combined.errors, + telemetry, + runtimeSecrets, + ); } /** Assembly failed on errors originating inside shared npm deps. Blame the @@ -327,7 +337,9 @@ export function importsConflictingPackage( const culprits = new Map>(); for (const err of errors) { for (const m of err.message.matchAll(IMPORTED_FROM_PACKAGE)) { - (culprits.get(m[1]) ?? culprits.set(m[1], new Set()).get(m[1])!).add(m[2]); + (culprits.get(m[1]) ?? culprits.set(m[1], new Set()).get(m[1])!).add( + m[2], + ); } } const sources = Object.values(entry.fn.files); diff --git a/packages/functions-compiler/src/deno-bundle.ts b/packages/functions-compiler/src/deno-bundle.ts index c8cb399ab..9d1b9f53b 100644 --- a/packages/functions-compiler/src/deno-bundle.ts +++ b/packages/functions-compiler/src/deno-bundle.ts @@ -14,9 +14,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; - -import { build, type BuildFailure } from "esbuild"; - +import { type BuildFailure, build } from "esbuild"; import type { BundleErrorItem } from "./errors.js"; import { denoResolverPlugin } from "./esbuild/deno-resolver.js"; import { nodeBuiltinRequirePlugin } from "./esbuild/node-builtin-require.js"; @@ -53,7 +51,7 @@ export async function bundleToModule( JSON.stringify({ nodeModulesDir }), ); - let result; + let result: Awaited>; try { result = await build({ entryPoints: [prepared.entry], @@ -118,10 +116,7 @@ export async function bundleToModule( /** Flatten an esbuild `BuildFailure` into compile diagnostics, rewriting the * temp-dir-absolute file paths back to the paths the user typed. Returns null * if `e` is not an esbuild failure (so the caller rethrows it). */ -function buildFailureErrors( - e: unknown, - dir: string, -): BundleErrorItem[] | null { +function buildFailureErrors(e: unknown, dir: string): BundleErrorItem[] | null { if (typeof e !== "object" || e === null || !("errors" in e)) return null; const raw = (e as BuildFailure).errors; if (!Array.isArray(raw)) return null; diff --git a/packages/functions-compiler/src/esbuild/deno-resolver.ts b/packages/functions-compiler/src/esbuild/deno-resolver.ts index 626387cf8..df2732802 100644 --- a/packages/functions-compiler/src/esbuild/deno-resolver.ts +++ b/packages/functions-compiler/src/esbuild/deno-resolver.ts @@ -22,13 +22,12 @@ import { isBuiltin } from "node:module"; import { homedir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; - import { MediaType, RequestedModuleType, ResolutionMode, - Workspace, ResolveError, + Workspace, } from "@deno/loader"; import type { Loader, @@ -38,7 +37,6 @@ import type { OnResolveResult, Plugin, } from "esbuild"; - import { logEvent } from "../log.js"; import { USER_NAMESPACE } from "./user-files.js"; @@ -105,10 +103,14 @@ export function denoResolverPlugin(options: DenoResolverOptions = {}): Plugin { loader = await workspace.createLoader(); } catch (err) { dispose(); - logEvent("error", "base44.bundler.workspace_disposed_on_setup_failure", { - phase: "create_loader", - wasm_trap: errMessage(err) === "unreachable", - }); + logEvent( + "error", + "base44.bundler.workspace_disposed_on_setup_failure", + { + phase: "create_loader", + wasm_trap: errMessage(err) === "unreachable", + }, + ); throw err; } build.onDispose(() => dispose(loader)); @@ -386,7 +388,9 @@ function errMessage(err: unknown): string { // Same wording as the user-files plugin so the message is consistent. function fileOutsideCacheError(spec: string): { text: string } { - return { text: `Cannot import "${spec}": filesystem imports are not allowed` }; + return { + text: `Cannot import "${spec}": filesystem imports are not allowed`, + }; } // Where the loader caches npm; prod sets DENO_DIR, else Deno's per-OS default. @@ -498,7 +502,7 @@ function resolveEntryFromPackageJson( // `pkg/index.js` (or `./index.js`) import that resolves to this same path was // a deliberate request for that file — redirecting it to main/module would // silently load a different entry, so let the missing-file error stand. - if (specifier.endsWith("/" + base)) return null; + if (specifier.endsWith(`/${base}`)) return null; const pkgDir = path.dirname(attempted); // Fail closed: never touch a package.json (or its entries) outside the cache. diff --git a/packages/functions-compiler/src/esbuild/node-builtin-require.ts b/packages/functions-compiler/src/esbuild/node-builtin-require.ts index c303f332e..d9c3dd8c5 100644 --- a/packages/functions-compiler/src/esbuild/node-builtin-require.ts +++ b/packages/functions-compiler/src/esbuild/node-builtin-require.ts @@ -1,7 +1,5 @@ import { isBuiltin } from "node:module"; - import type { OnResolveArgs, Plugin } from "esbuild"; - import { USER_NAMESPACE } from "./user-files.js"; const REEXPORT_NAMESPACE = "node-builtin-reexport"; diff --git a/packages/functions-compiler/src/esbuild/private-data-sources-virtual.ts b/packages/functions-compiler/src/esbuild/private-data-sources-virtual.ts index 73597905a..652e38cfb 100644 --- a/packages/functions-compiler/src/esbuild/private-data-sources-virtual.ts +++ b/packages/functions-compiler/src/esbuild/private-data-sources-virtual.ts @@ -1,14 +1,11 @@ import { readFileSync } from "node:fs"; import path from "node:path"; - import type { Plugin } from "esbuild"; - import { ACTIVATION_FILENAME } from "../worker-entry.js"; import { USER_NAMESPACE } from "./user-files.js"; const PREFIX = "base44:private-data-sources"; -export const PRIVATE_DATA_SOURCES_NAMESPACE = - "base44-private-data-sources"; +export const PRIVATE_DATA_SOURCES_NAMESPACE = "base44-private-data-sources"; const MODULE_DIR = new URL("../private-data-sources/", import.meta.url); const PUBLIC_MODULES = new Set([ "elasticsearch", @@ -47,15 +44,30 @@ function publicModulePath(specifier: string): string | null { // instance in the final bundle. const INTERNAL_STORE_SPECIFIER = `${PREFIX}/runtime-manifest-store`; -function isActivationShimImporter(namespace: string, importer: string): boolean { +function isActivationShimImporter( + namespace: string, + importer: string, +): boolean { return namespace === USER_NAMESPACE && importer === ACTIVATION_FILENAME; } -function relativeModulePath(importer: string, specifier: string): string | null { +function relativeModulePath( + importer: string, + specifier: string, +): string | null { if (!specifier.startsWith("./") && !specifier.startsWith("../")) return null; - const importerDir = importer.includes("/") ? importer.slice(0, importer.lastIndexOf("/")) : ""; - const resolved = path.posix.normalize(path.posix.join(importerDir, specifier)); - if (resolved.startsWith("../") || resolved === ".." || path.posix.isAbsolute(resolved)) return null; + const importerDir = importer.includes("/") + ? importer.slice(0, importer.lastIndexOf("/")) + : ""; + const resolved = path.posix.normalize( + path.posix.join(importerDir, specifier), + ); + if ( + resolved.startsWith("../") || + resolved === ".." || + path.posix.isAbsolute(resolved) + ) + return null; return resolved.endsWith(".ts") ? resolved : `${resolved}.ts`; } @@ -67,36 +79,44 @@ export function privateDataSourcesVirtualPlugin(): Plugin { return { name: "base44-private-data-sources-virtual", setup(build) { - build.onResolve({ filter: /^base44:private-data-sources(?:\/.*)?$/ }, (args) => { - if (args.path === INTERNAL_STORE_SPECIFIER) { - if (!isActivationShimImporter(args.namespace, args.importer)) { + build.onResolve( + { filter: /^base44:private-data-sources(?:\/.*)?$/ }, + (args) => { + if (args.path === INTERNAL_STORE_SPECIFIER) { + if (!isActivationShimImporter(args.namespace, args.importer)) { + return { + errors: [ + { + text: + `"${args.path}" is internal to the Base44 runtime and cannot be imported ` + + "by backend function code.", + }, + ], + }; + } return { - errors: [{ - text: - `"${args.path}" is internal to the Base44 runtime and cannot be imported ` + - "by backend function code.", - }], + path: "runtime-manifest-store.ts", + namespace: PRIVATE_DATA_SOURCES_NAMESPACE, + }; + } + const modulePath = publicModulePath(args.path); + if (!modulePath) { + return { + errors: [ + { + text: + `Unsupported import "${args.path}". Use a type-specific private data source import, ` + + 'for example "base44:private-data-sources/postgres".', + }, + ], }; } - return { path: "runtime-manifest-store.ts", namespace: PRIVATE_DATA_SOURCES_NAMESPACE }; - } - const modulePath = publicModulePath(args.path); - if (!modulePath) { return { - errors: [ - { - text: - `Unsupported import "${args.path}". Use a type-specific private data source import, ` + - 'for example "base44:private-data-sources/postgres".', - }, - ], + path: modulePath, + namespace: PRIVATE_DATA_SOURCES_NAMESPACE, }; - } - return { - path: modulePath, - namespace: PRIVATE_DATA_SOURCES_NAMESPACE, - }; - }); + }, + ); build.onResolve( { diff --git a/packages/functions-compiler/src/esbuild/runtime-context-virtual.ts b/packages/functions-compiler/src/esbuild/runtime-context-virtual.ts index 7c876534b..80afc29ad 100644 --- a/packages/functions-compiler/src/esbuild/runtime-context-virtual.ts +++ b/packages/functions-compiler/src/esbuild/runtime-context-virtual.ts @@ -1,7 +1,5 @@ import { readFileSync } from "node:fs"; - import type { Plugin } from "esbuild"; - import { PRIVATE_DATA_SOURCES_NAMESPACE } from "./private-data-sources-virtual.js"; import { USER_NAMESPACE } from "./user-files.js"; diff --git a/packages/functions-compiler/src/esbuild/runtime-virtual.ts b/packages/functions-compiler/src/esbuild/runtime-virtual.ts index ff3e36d0a..49c7ed2d9 100644 --- a/packages/functions-compiler/src/esbuild/runtime-virtual.ts +++ b/packages/functions-compiler/src/esbuild/runtime-virtual.ts @@ -1,5 +1,4 @@ import { existsSync, readFileSync } from "node:fs"; - import type { Plugin } from "esbuild"; // Keep this allowlist in sync with @@ -45,7 +44,10 @@ export function runtimeVirtualPlugin(): Plugin { ], }; } - return { contents: readFileSync(ACTOR_SHIM_URL, "utf8"), loader: "js" }; + return { + contents: readFileSync(ACTOR_SHIM_URL, "utf8"), + loader: "js", + }; } return { contents: readFileSync(MODULE_URL, "utf8"), loader: "ts" }; }); diff --git a/packages/functions-compiler/src/esbuild/user-files.ts b/packages/functions-compiler/src/esbuild/user-files.ts index 28842fbf7..b71ed2c9d 100644 --- a/packages/functions-compiler/src/esbuild/user-files.ts +++ b/packages/functions-compiler/src/esbuild/user-files.ts @@ -12,7 +12,6 @@ */ import path from "node:path"; - import type { Loader, Plugin } from "esbuild"; export const USER_NAMESPACE = "user"; diff --git a/packages/functions-compiler/src/index.ts b/packages/functions-compiler/src/index.ts index 54a04aae0..62a51536f 100644 --- a/packages/functions-compiler/src/index.ts +++ b/packages/functions-compiler/src/index.ts @@ -2,7 +2,6 @@ // src/ is either an internal engine module or a compile-time asset read as text // by the esbuild plugins. -export { bundle, bundleApp, classifyAppErrors, importsConflictingPackage } from "./bundler.js"; export type { AppErrorClassification, AppFunctionStatus, @@ -10,27 +9,27 @@ export type { BundleErrorStage, BundleResponse, } from "./bundler.js"; - export { - appFunctionSchema, - bundleAppRequestSchema, - bundleRequestSchema, -} from "./contracts.js"; + bundle, + bundleApp, + classifyAppErrors, + importsConflictingPackage, +} from "./bundler.js"; export type { AppFunctionInput, BundleAppRequest, BundleRequest, } from "./contracts.js"; - -export { DenoCompatError } from "./errors.js"; +export { + appFunctionSchema, + bundleAppRequestSchema, + bundleRequestSchema, +} from "./contracts.js"; export type { BundleErrorItem } from "./errors.js"; - +export { DenoCompatError } from "./errors.js"; export { createGuardedFetch, installFetchGuard } from "./fetch-guard.js"; - +export type { Field, Level, LogSink } from "./log.js"; +export { setLogSink } from "./log.js"; export { STATIC_EGRESS_ARTIFACT_MARKER } from "./static-egress-marker.js"; - -export { setCompilerTracer } from "./tracing.js"; export type { CompilerTracer } from "./tracing.js"; - -export { setLogSink } from "./log.js"; -export type { Field, Level, LogSink } from "./log.js"; +export { setCompilerTracer } from "./tracing.js"; diff --git a/packages/functions-compiler/src/tracing.ts b/packages/functions-compiler/src/tracing.ts index 7ec060957..7d8c37f66 100644 --- a/packages/functions-compiler/src/tracing.ts +++ b/packages/functions-compiler/src/tracing.ts @@ -5,9 +5,7 @@ export interface CompilerTracer { withSpan(name: string, fn: () => Promise): Promise; - setSpanTags( - tags: Record, - ): Promise; + setSpanTags(tags: Record): Promise; } const noopTracer: CompilerTracer = { diff --git a/packages/functions-compiler/src/worker-entry.ts b/packages/functions-compiler/src/worker-entry.ts index 4288254a3..857a44425 100644 --- a/packages/functions-compiler/src/worker-entry.ts +++ b/packages/functions-compiler/src/worker-entry.ts @@ -6,10 +6,9 @@ */ import { readFileSync } from "node:fs"; - import type { AppFunctionInput } from "./contracts.js"; -import { RUNTIME_CONTEXT_SPECIFIER } from "./esbuild/runtime-context-virtual.js"; import { DenoCompatError } from "./errors.js"; +import { RUNTIME_CONTEXT_SPECIFIER } from "./esbuild/runtime-context-virtual.js"; import { TELEMETRY_PATCH, TELEMETRY_STORE_FIELDS } from "./telemetry.js"; // Pre-built by scripts/build-shim.ts; regenerate it after changing the shim. @@ -19,17 +18,19 @@ import { TELEMETRY_PATCH, TELEMETRY_STORE_FIELDS } from "./telemetry.js"; // files (and only pass locally where dist/ already exists). let _denoShimSource: string | undefined; function denoShimSource(): string { - return (_denoShimSource ??= readFileSync( + _denoShimSource ??= readFileSync( new URL("../dist/deno-shim.mjs", import.meta.url), "utf8", - )); + ); + return _denoShimSource; } let _activationShimSource: string | undefined; function activationShimSource(): string { - return (_activationShimSource ??= readFileSync( + _activationShimSource ??= readFileSync( new URL("../dist/activation-shim.mjs", import.meta.url), "utf8", - )); + ); + return _activationShimSource; } // Synthetic files injected into the bundle; asserted absent from user input. @@ -117,8 +118,14 @@ export async function prepareFunction( files: { ...userFiles(files), ...workerRuntimeFiles(), - ...(runtimeSecrets ? { [ACTIVATION_FILENAME]: activationShimSource() } : {}), - [ENTRY_FILENAME]: buildEntrySource(entry, postResponseTelemetry, runtimeSecrets), + ...(runtimeSecrets + ? { [ACTIVATION_FILENAME]: activationShimSource() } + : {}), + [ENTRY_FILENAME]: buildEntrySource( + entry, + postResponseTelemetry, + runtimeSecrets, + ), }, }; } @@ -146,7 +153,9 @@ export function prepareApp( ): PreparedWorker { const files: Record = { ...workerRuntimeFiles(), - ...(runtimeSecrets ? { [ACTIVATION_FILENAME]: activationShimSource() } : {}), + ...(runtimeSecrets + ? { [ACTIVATION_FILENAME]: activationShimSource() } + : {}), }; const moduleFiles = entries.map(({ index, fn }) => { assertNoReservedFilenames(fn.files); @@ -158,7 +167,11 @@ export function prepareApp( files[wrapper] = buildFunctionModuleSource(fn.name, `${dir}/${fn.entry}`); return wrapper; }); - files[ENTRY_FILENAME] = buildAppEntrySource(moduleFiles, postResponseTelemetry, runtimeSecrets); + files[ENTRY_FILENAME] = buildAppEntrySource( + moduleFiles, + postResponseTelemetry, + runtimeSecrets, + ); return { entry: ENTRY_FILENAME, files }; } @@ -179,8 +192,12 @@ function activationImport(runtimeSecrets: boolean): string { /** Build the wrapper entry that loads the shim, lazily imports the user module * on the first request (so init logs are tagged with the real request env), * then exports a standard Worker fetch that delegates to that handler. */ -function buildEntrySource(userEntry: string, telemetry: boolean, runtimeSecrets = false): string { - const userImport = "./" + userEntry.replace(/^\.\//, ""); +function buildEntrySource( + userEntry: string, + telemetry: boolean, + runtimeSecrets = false, +): string { + const userImport = `./${userEntry.replace(/^\.\//, "")}`; // Handler responses get the activation-signal header stripped (user code must // not be able to forge a signal after side effects and cause a re-execution). const handlerExpr = telemetry @@ -188,7 +205,9 @@ function buildEntrySource(userEntry: string, telemetry: boolean, runtimeSecrets : runtimeSecrets ? "await handler(request, info)" : "handler(request, info)"; - const returnExpr = runtimeSecrets ? `withoutActivationSignal(${handlerExpr})` : handlerExpr; + const returnExpr = runtimeSecrets + ? `withoutActivationSignal(${handlerExpr})` + : handlerExpr; return `// Auto-generated by the base44 bundler. Do not edit. import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as _b44Run } from ${JSON.stringify(RUNTIME_CONTEXT_SPECIFIER)}; @@ -233,7 +252,7 @@ function buildFunctionModuleSource( functionName: string, userEntry: string, ): string { - const userImport = "./" + userEntry.replace(/^\.\//, ""); + const userImport = `./${userEntry.replace(/^\.\//, "")}`; return `// Auto-generated by the base44 bundler. Do not edit. import { registerLazy } from "./${SHIM_FILENAME}"; @@ -252,7 +271,9 @@ function buildAppEntrySource( const handlerExpr = telemetry ? "_b44AttachTelemetry(await handler(request, info))" : "await handler(request, info)"; - const returnExpr = runtimeSecrets ? `withoutActivationSignal(${handlerExpr})` : handlerExpr; + const returnExpr = runtimeSecrets + ? `withoutActivationSignal(${handlerExpr})` + : handlerExpr; return `// Auto-generated by the base44 bundler. Do not edit. import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as _b44Run } from ${JSON.stringify(RUNTIME_CONTEXT_SPECIFIER)}; @@ -318,9 +339,7 @@ ${runtimeSecrets ? ACTIVATION_GATE : ""} // Each early return below logs th `; } -export function assertNoReservedFilenames( - files: Record, -): void { +export function assertNoReservedFilenames(files: Record): void { // Shim/entry/actor are reserved as exact root keys (unchanged — a flag-off // bundle accepts exactly what prod accepts today). if ( From 5e16c1c0cb20f1c19582272c362145eea50a03da Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Thu, 10 Sep 2026 17:09:04 +0300 Subject: [PATCH 3/8] fix(functions-compiler): resolve the package only through lib/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/functions-compiler/README.md | 5 +++++ packages/functions-compiler/package.json | 1 - .../functions-compiler/scripts/verify-package.ts | 14 +++++++------- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 75fe9b1f8..08193817e 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -78,3 +78,8 @@ bun run test # vitest (builds the shims first) bun run typecheck # tsc --noEmit over src/, test/, scripts/ bun run build # shims + tsc -> lib/ + assets; what gets published ``` + +`exports` points only at `lib/`, so anything consuming this package — including +a sibling workspace — needs `bun run build` here first. There is deliberately +no source-resolving export condition: the tarball ships `lib/` alone, and a +second resolution path would mean two answers to "which code ran". diff --git a/packages/functions-compiler/package.json b/packages/functions-compiler/package.json index d7605d170..9d3f267c2 100644 --- a/packages/functions-compiler/package.json +++ b/packages/functions-compiler/package.json @@ -6,7 +6,6 @@ "type": "module", "exports": { ".": { - "bun": "./src/index.ts", "types": "./lib/src/index.d.ts", "default": "./lib/src/index.js" } diff --git a/packages/functions-compiler/scripts/verify-package.ts b/packages/functions-compiler/scripts/verify-package.ts index f7367967f..b4b8d2d2b 100644 --- a/packages/functions-compiler/scripts/verify-package.ts +++ b/packages/functions-compiler/scripts/verify-package.ts @@ -9,7 +9,7 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -60,16 +60,16 @@ try { const tarball = path.join(work, packed.split("\n").at(-1)!); const consumer = path.join(work, "consumer"); - await writeFile( - path.join(work, ".npmrc"), - "registry=https://registry.npmjs.org/\n@jsr:registry=https://npm.jsr.io\n", - ); - run("mkdir", ["-p", consumer], work); + await mkdir(consumer, { recursive: true }); await writeFile( path.join(consumer, "package.json"), JSON.stringify({ name: "compiler-package-probe", private: true, type: "module" }), ); - await writeFile(path.join(consumer, ".npmrc"), "registry=https://registry.npmjs.org/\n@jsr:registry=https://npm.jsr.io\n"); + // @deno/loader is published to JSR, mirrored under the @jsr scope. + await writeFile( + path.join(consumer, ".npmrc"), + "registry=https://registry.npmjs.org/\n@jsr:registry=https://npm.jsr.io\n", + ); run("npm", ["install", "--silent", "--no-audit", "--no-fund", tarball], consumer); await writeFile(path.join(consumer, "probe.mjs"), PROBE); From e148471ccf91409399bc9447e8ad658b4cc6f347 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Thu, 10 Sep 2026 17:10:39 +0300 Subject: [PATCH 4/8] fix(functions-compiler): keep src/index.ts as a knip entry 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) --- knip.json | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.json b/knip.json index 63caa03ac..7e0b54385 100644 --- a/knip.json +++ b/knip.json @@ -23,6 +23,7 @@ }, "packages/functions-compiler": { "entry": [ + "src/index.ts", "scripts/*.ts", "test/**/*.test.ts" ], From e56c7a448d8fc6088eed0b90d0f7f13971f7a98f Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Thu, 10 Sep 2026 17:34:12 +0300 Subject: [PATCH 5/8] test(functions-compiler): cover the seams the move left untested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps, none of them covered by the suite that came across with the engine: - **Real repository paths.** Production sends `base44/functions//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_/` 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) --- .../test/app-partial-results.test.ts | 129 ++++++++++++++ .../test/diagnostics-hooks.test.ts | 139 +++++++++++++++ .../test/real-paths.e2e.test.ts | 162 ++++++++++++++++++ 3 files changed, 430 insertions(+) create mode 100644 packages/functions-compiler/test/app-partial-results.test.ts create mode 100644 packages/functions-compiler/test/diagnostics-hooks.test.ts create mode 100644 packages/functions-compiler/test/real-paths.e2e.test.ts diff --git a/packages/functions-compiler/test/app-partial-results.test.ts b/packages/functions-compiler/test/app-partial-results.test.ts new file mode 100644 index 000000000..3d82c6cca --- /dev/null +++ b/packages/functions-compiler/test/app-partial-results.test.ts @@ -0,0 +1,129 @@ +/** + * `bundleApp` can succeed and still report a failed function. The HTTP service + * depends on that (it deploys the good ones and attributes the failures), and a + * whole-app CLI build must refuse it. Both readings need the behaviour pinned + * here rather than only in apper's endpoint specs. + * + * Every fixture fails for a resolution reason the compiler decides on its own, + * so these run without touching a registry. + */ + +import { describe, expect, it } from "vitest"; + +import { bundleApp } from "../src/bundler"; + +const ok = (name: string) => ({ + name, + entry: "main.ts", + files: { "main.ts": `Deno.serve(() => new Response("${name}"));` }, +}); + +const broken = (name: string) => ({ + name, + entry: "main.ts", + files: { + "main.ts": `import { x } from "./not-submitted.ts";\nDeno.serve(() => new Response(x));`, + }, +}); + +describe("bundleApp partial results", () => { + it("returns a module for the survivors and marks the broken function failed", async () => { + const result = await bundleApp({ + functions: [ok("alpha"), broken("beta"), ok("gamma")], + }); + + expect(result.ok).toBe(true); + const byName = Object.fromEntries(result.functions.map((f) => [f.name, f])); + expect(byName.alpha.ok).toBe(true); + expect(byName.gamma.ok).toBe(true); + expect(byName.beta.ok).toBe(false); + + // The survivors are routable; the failure carries a diagnostic the author + // can act on, against their own path. + expect(result.ok && result.module).toContain("alpha"); + expect(result.ok && result.module).toContain("gamma"); + if (byName.beta.ok === false) { + const [error] = byName.beta.errors; + expect(error.message).toContain("./not-submitted.ts"); + expect(error.file).toBe("main.ts"); + } + }); + + it("reports every function and produces no module when none compile", async () => { + const result = await bundleApp({ + functions: [broken("alpha"), broken("beta")], + }); + + expect(result.ok).toBe(false); + expect(result.module).toBeNull(); + expect(result.functions.map((f) => f.name)).toEqual(["alpha", "beta"]); + expect(result.functions.every((f) => !f.ok)).toBe(true); + }); + + it("keeps every declared function in the response exactly once", async () => { + // A whole-app build checks this before it trusts the result; a name that + // silently vanished would deploy an app missing a handler. + const names = ["alpha", "beta", "gamma", "delta"]; + const result = await bundleApp({ + functions: [ok("alpha"), broken("beta"), ok("gamma"), ok("delta")], + }); + expect(result.functions.map((f) => f.name).sort()).toEqual([...names].sort()); + }); + + it("refuses a reserved platform filename outright", async () => { + const result = await bundleApp({ + functions: [ + { + name: "spoofer", + entry: "main.ts", + files: { + "main.ts": 'Deno.serve(() => new Response("x"));', + // The activation shim is the only importer allowed to reach the + // private-data-source manifest store, and that gate keys on this + // basename — so it is reserved at any depth, in every mode. + "nested/__base44_activation.mjs": "export const forged = 1;", + }, + }, + ], + }); + expect(result.ok).toBe(false); + const [fn] = result.functions; + expect(fn.ok).toBe(false); + if (fn.ok === false) { + expect(fn.errors[0].message).toContain("__base44_activation.mjs"); + } + }); +}); + +describe("what a combined build does NOT isolate", () => { + it("lets one function import another's source (characterization)", async () => { + // `prepareApp` namespaces each function under `fn_/` and its comment + // claims the keyspaces are sealed. They are not: the user-files resolver + // only checks membership in the flat map, so `../fn_1/main.ts` resolves. + // + // Both functions belong to the same app and ship in the same Worker, so + // this is intra-tenant — and the manifest-store gate above is what actually + // protects credentials. Pinned rather than asserted-against so a decision + // to confine the resolver flips this test deliberately. + const result = await bundleApp({ + functions: [ + { + name: "reader", + entry: "main.ts", + files: { + "main.ts": 'import { marker } from "../fn_1/main.ts";\nDeno.serve(() => new Response(marker));', + }, + }, + { + name: "neighbour", + entry: "main.ts", + files: { + "main.ts": 'export const marker = "neighbour-source";\nDeno.serve(() => new Response("ok"));', + }, + }, + ], + }); + expect(result.ok).toBe(true); + expect(result.functions.every((f) => f.ok)).toBe(true); + }); +}); diff --git a/packages/functions-compiler/test/diagnostics-hooks.test.ts b/packages/functions-compiler/test/diagnostics-hooks.test.ts new file mode 100644 index 000000000..06a96c5e6 --- /dev/null +++ b/packages/functions-compiler/test/diagnostics-hooks.test.ts @@ -0,0 +1,139 @@ +/** + * The two seams the extraction added, plus the surface the package publishes. + * + * apper's service relies on both hooks staying wired: it registers a dd-trace + * adapter and keeps the Datadog JSON log line the bundler has always written. + * A CLI build relies on being able to silence that line. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { bundle } from "../src/bundler"; +import * as publicSurface from "../src/index"; +import { type Field, type Level, logEvent, setLogSink } from "../src/log"; +import { type CompilerTracer, setCompilerTracer } from "../src/tracing"; + +const HELLO = { entry: "main.ts", files: { "main.ts": 'Deno.serve(() => new Response("ok"));' } }; + +afterEach(() => { + setLogSink(null); + setCompilerTracer(null); + vi.restoreAllMocks(); +}); + +describe("log sink", () => { + it("writes the Datadog JSON line by default", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + logEvent("info", "base44.bundler.test", { specifier: "x" }); + expect(log).toHaveBeenCalledOnce(); + expect(JSON.parse(log.mock.calls[0][0] as string)).toMatchObject({ + status: "info", + event: "base44.bundler.test", + specifier: "x", + }); + }); + + it("routes warn and error to their own console channels", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + logEvent("warn", "base44.bundler.warn"); + logEvent("error", "base44.bundler.error"); + expect(warn).toHaveBeenCalledOnce(); + expect(error).toHaveBeenCalledOnce(); + }); + + it("drops undefined fields so absent dimensions make no facet", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + logEvent("info", "base44.bundler.test", { present: 1, absent: undefined }); + const line = JSON.parse(log.mock.calls[0][0] as string); + expect(line).toHaveProperty("present", 1); + expect(line).not.toHaveProperty("absent"); + }); + + it("hands events to a registered sink instead, and restores on null", () => { + const seen: [Level, string, Record][] = []; + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + setLogSink((level, event, fields) => seen.push([level, event, fields])); + + logEvent("info", "base44.bundler.routed", { n: 1 }); + expect(seen).toEqual([["info", "base44.bundler.routed", { n: 1 }]]); + expect(log).not.toHaveBeenCalled(); + + setLogSink(null); + logEvent("info", "base44.bundler.default"); + expect(log).toHaveBeenCalledOnce(); + }); +}); + +describe("compiler tracer", () => { + it("compiles with no tracer registered", async () => { + const result = await bundle(HELLO); + expect(result.ok).toBe(true); + }); + + it("wraps the compile in a span and tags its outcome", async () => { + const spans: string[] = []; + const tags: Record = {}; + const tracer: CompilerTracer = { + withSpan: (name, fn) => { + spans.push(name); + return fn(); + }, + setSpanTags: async (t) => void Object.assign(tags, t), + }; + setCompilerTracer(tracer); + + const result = await bundle(HELLO); + + expect(result.ok).toBe(true); + expect(spans).toContain("base44.bundler.compile"); + // apper's dashboards read both: `node_modules_mode` shows the resolver + // fallback firing, `outcome` whether it rescued the build. + expect(tags).toMatchObject({ node_modules_mode: "none", outcome: "ok" }); + }); + + it("stops calling a tracer once it is cleared", async () => { + const withSpan = vi.fn((_name: string, fn: () => Promise) => fn()); + setCompilerTracer({ + withSpan: withSpan as CompilerTracer["withSpan"], + setSpanTags: async () => {}, + }); + await bundle(HELLO); + expect(withSpan).toHaveBeenCalled(); + + withSpan.mockClear(); + setCompilerTracer(null); + await bundle(HELLO); + expect(withSpan).not.toHaveBeenCalled(); + }); +}); + +describe("published surface", () => { + it("exports what the two consumers import", () => { + // A rename here silently breaks apper's service at its next upgrade, and + // nothing else in the suite imports through the package entry point. + expect(Object.keys(publicSurface).sort()).toEqual([ + "DenoCompatError", + "STATIC_EGRESS_ARTIFACT_MARKER", + "appFunctionSchema", + "bundle", + "bundleAppRequestSchema", + "bundleApp", + "bundleRequestSchema", + "classifyAppErrors", + "createGuardedFetch", + "importsConflictingPackage", + "installFetchGuard", + "setCompilerTracer", + "setLogSink", + ].sort()); + }); + + it("keeps the static-egress marker in step with the Python constant", () => { + // backend/app/static_egress/config.py holds the same literal; they are one + // capability marker read on both sides of the HTTP boundary. + expect(publicSurface.STATIC_EGRESS_ARTIFACT_MARKER).toBe( + "base44.static-egress.request-env.v2", + ); + }); +}); diff --git a/packages/functions-compiler/test/real-paths.e2e.test.ts b/packages/functions-compiler/test/real-paths.e2e.test.ts new file mode 100644 index 000000000..0d404acfb --- /dev/null +++ b/packages/functions-compiler/test/real-paths.e2e.test.ts @@ -0,0 +1,162 @@ +/** + * The input shape production actually sends. apper's adapter (`cfw_bundle_input` + * in backend/app/cloudflare_functions/function_bundle.py) submits the function's + * REAL repository path as the entry plus every reachable backend file, so + * relative imports cross directories — nothing like the flat `main.ts` the rest + * of these specs use. + * + * Translated from apper's test_function_bundle.py: those tests prove which files + * the adapter collects and then assert, in comments, what the compiler does with + * them. These are the same fixtures, run through the compiler. + */ + +import { describe, expect, it } from "vitest"; + +import { bundle, bundleApp } from "../src/bundler"; +import { runInWorkerd } from "./workerd"; + +const ENTRY = "base44/functions/crossshared/entry.ts"; + +const serve = (body: string) => + `Deno.serve(() => new Response(${body}));`; + +async function compile(files: Record, entry = ENTRY) { + return bundle({ entry, files }); +} + +async function compileOk(files: Record, entry = ENTRY) { + const result = await compile(files, entry); + if (!result.ok) { + throw new Error(`bundle failed: ${JSON.stringify(result.errors)}`); + } + return result.module; +} + +function firstError(result: Awaited>): string { + if (result.ok) throw new Error("expected the bundle to be refused"); + return result.errors.map((e) => e.message).join("\n"); +} + +describe("real repository paths", () => { + it("resolves a shared module in a sibling directory and runs it", async () => { + const module = await compileOk({ + [ENTRY]: `import { greet } from "../../shared/greeting.ts";\n${serve('greet("x")')}`, + "base44/shared/greeting.ts": "export const greet = (n: string) => `hi ${n}`;", + }); + const res = await runInWorkerd(module); + expect(res.status).toBe(200); + expect(res.text).toBe("hi x"); + }); + + it("follows a transitive shared import and leaves unreached files out", async () => { + const module = await compileOk({ + [ENTRY]: `import { a } from "../../shared/a.ts";\n${serve("a")}`, + "base44/shared/a.ts": 'import { b } from "./b.ts";\nexport const a = b;', + "base44/shared/b.ts": 'export const b = "transitive";', + "base44/shared/unused.ts": "export const u = 2;", + }); + expect(await runInWorkerd(module)).toMatchObject({ text: "transitive" }); + expect(module).not.toContain("export const u = 2"); + }); + + it("resolves a relative import inside the function's own directory", async () => { + const entry = "base44/functions/withinshared/entry.ts"; + const module = await compileOk( + { + [entry]: `import { greet } from "./greeting.ts";\n${serve("greet()")}`, + "base44/functions/withinshared/greeting.ts": 'export const greet = () => "local";', + }, + entry, + ); + expect(await runInWorkerd(module)).toMatchObject({ text: "local" }); + }); +}); + +describe("relative imports cannot leave the submission", () => { + it("refuses a path that escapes into the frontend tree", async () => { + // `../../../src/lib/x.ts` resolves to `src/lib/x.ts`, which the adapter + // deliberately excludes from the backend file set. + const message = firstError( + await compile({ + [ENTRY]: `import { x } from "../../../src/lib/x.ts";\n${serve("x")}`, + }), + ); + expect(message).toContain("../../../src/lib/x.ts"); + expect(message).toContain("bundled with this function"); + }); + + it("refuses a relative import with no target", async () => { + const message = firstError( + await compile({ + [ENTRY]: `import { greet } from "../../shared/nope.ts";\n${serve('greet("x")')}`, + "base44/shared/greeting.ts": "export const greet = (n: string) => n;", + }), + ); + expect(message).toContain("../../shared/nope.ts"); + expect(message).toContain("bundled with this function"); + }); + + it("refuses absolute paths and file: URLs", async () => { + for (const spec of ["/etc/passwd", "file:///etc/passwd"]) { + const message = firstError( + await compile({ [ENTRY]: `import x from "${spec}";\n${serve("x")}` }), + ); + expect(message).toContain("absolute paths and file: URLs"); + } + }); + + it("leaves npm: and bare specifiers to the Deno resolver", async () => { + // Not an escape: these are not graph edges into the submission at all. + const module = await compileOk({ + [ENTRY]: `import slugify from "npm:slugify@1.6.6";\nimport { greet } from "../../shared/greeting.ts";\n${serve('greet(slugify("Hello There"))')}`, + "base44/shared/greeting.ts": "export const greet = (n: string) => n;", + }); + expect(await runInWorkerd(module)).toMatchObject({ text: "Hello-There" }); + }); +}); + +describe("real paths in a combined app build", () => { + it("compiles two functions that each reach their own shared module", async () => { + const result = await bundleApp({ + functions: [ + { + name: "orders", + entry: "base44/functions/orders/entry.ts", + files: { + "base44/functions/orders/entry.ts": `import { tag } from "../../shared/tag.ts";\n${serve('tag("orders")')}`, + "base44/shared/tag.ts": "export const tag = (n: string) => `[${n}]`;", + }, + }, + { + name: "health", + entry: "base44/functions/health/entry.ts", + files: { + "base44/functions/health/entry.ts": serve('"ok"'), + }, + }, + ], + }); + expect(result.ok).toBe(true); + expect(result.functions.every((f) => f.ok)).toBe(true); + }); + + it("reports an escape against the function's own path, without the fn_ prefix", async () => { + const result = await bundleApp({ + functions: [ + { + name: "broken", + entry: "base44/functions/broken/entry.ts", + files: { + "base44/functions/broken/entry.ts": `import { x } from "../../../src/lib/x.ts";\n${serve("x")}`, + }, + }, + { name: "health", entry: "main.ts", files: { "main.ts": serve('"ok"') } }, + ], + }); + const broken = result.functions.find((f) => f.name === "broken"); + expect(broken?.ok).toBe(false); + const files = broken?.ok === false ? broken.errors.map((e) => e.file) : []; + // Attribution strips `fn_/`, so the author sees their own path. + expect(files.some((f) => f?.startsWith("fn_"))).toBe(false); + }); +}); From 5e0f4324d43414ca7a0512a8ac4e928e98c4ad43 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 14 Sep 2026 09:20:49 +0300 Subject: [PATCH 6/8] chore(functions-compiler): pin publish access and guard what the tarball ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/functions-compiler/README.md | 12 ++++++++++++ packages/functions-compiler/package.json | 3 +++ .../scripts/verify-package.ts | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 08193817e..82d4ea13c 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -12,6 +12,18 @@ Two consumers share this one engine: - apper's **`base44-userapp-bundler`** HTTP service, which keeps its own endpoints, auth, worker pool and telemetry around it. +## Status and scope + +Internal to Base44 — published **restricted**, and the CLI bundles it at build +time so end users never install it. It compiles functions and nothing else: +shard planning, size splitting, artifact writing, version creation and deploy +all live above it. + +The package carries no credentials and reads no configuration of its own. It +names the environment variables and headers the generated worker will use at +runtime (`BASE44_*`, `X-Base44-*`) but holds none of their values, and the code +it ships is the same code already compiled into every deployed user worker. + ## Using it ```ts diff --git a/packages/functions-compiler/package.json b/packages/functions-compiler/package.json index 9d3f267c2..270a31b59 100644 --- a/packages/functions-compiler/package.json +++ b/packages/functions-compiler/package.json @@ -3,6 +3,9 @@ "version": "0.1.0", "description": "Production compiler for Base44 backend functions — turns function sources into a single Cloudflare Workers module.", "license": "MIT", + "publishConfig": { + "access": "restricted" + }, "type": "module", "exports": { ".": { diff --git a/packages/functions-compiler/scripts/verify-package.ts b/packages/functions-compiler/scripts/verify-package.ts index b4b8d2d2b..4af30bbae 100644 --- a/packages/functions-compiler/scripts/verify-package.ts +++ b/packages/functions-compiler/scripts/verify-package.ts @@ -58,6 +58,7 @@ const work = await mkdtemp(path.join(tmpdir(), "b44-compiler-pack-")); try { const packed = run("npm", ["pack", "--silent", "--pack-destination", work], packageRoot).trim(); const tarball = path.join(work, packed.split("\n").at(-1)!); + assertShipsOnlyBuildOutput(tarball); const consumer = path.join(work, "consumer"); await mkdir(consumer, { recursive: true }); @@ -78,3 +79,21 @@ try { } finally { await rm(work, { recursive: true, force: true }); } + +/** The tarball must carry the build output and nothing else. Widening `files`, + * or adding a path that sweeps the working tree in, is how a test fixture, a + * local .npmrc or a scratch file reaches the registry. */ +function assertShipsOnlyBuildOutput(tarball: string): void { + const entries = run("tar", ["tzf", tarball], path.dirname(tarball)) + .split("\n") + .map((line) => line.trim().replace(/^package\//, "")) + .filter((line) => line.length > 0 && !line.endsWith("/")); + + const unexpected = entries.filter( + (entry) => entry !== "package.json" && entry !== "README.md" && !entry.startsWith("lib/"), + ); + if (unexpected.length > 0) { + throw new Error(`tarball ships unexpected files: ${unexpected.join(", ")}`); + } + console.log(`tarball carries ${entries.length} files, all under lib/`); +} From b36d3d64653d0f9f6671bac088981075dcb47022 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 14 Sep 2026 09:26:20 +0300 Subject: [PATCH 7/8] docs(functions-compiler): say what the package cannot do yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/functions-compiler/README.md | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 82d4ea13c..6789d4913 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -24,6 +24,45 @@ names the environment variables and headers the generated worker will use at runtime (`BASE44_*`, `X-Base44-*`) but holds none of their values, and the code it ships is the same code already compiled into every deployed user worker. +## What is missing: shards, and whole-app CFW bundles + +Today 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. That work is still Python in apper's +`backend/app/cloudflare_functions/` and moves here next — it is what turns +"compile this set of sources" into "produce the deployable Cloudflare Workers +bundles for this app". + +| Missing piece | Where it lives today | What it has to do | +|---|---|---| +| Source assembly | `function_bundle.py` — `cfw_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.py` — `full_repartition`, `target_shard_count` | Group an app's functions into shards deterministically, and refuse a set that exceeds the supplied product capacity | +| Size measurement | `cloudflare_wfp_runtime.py` — `measure_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 PR #23460 | Reject a partial result: every declared function in exactly one successful shard, or no build at all | + +Only the **fresh-build** slice comes across. 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 verified 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 deliberately 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. + ## Using it ```ts From 3afb66bf0ea71812a2c3c2162b2807b791e39b9b Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 14 Sep 2026 09:46:50 +0300 Subject: [PATCH 8/8] test(functions-compiler): cover the engine assertions stranded in apper's HTTP specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../test/engine-contracts.e2e.test.ts | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 packages/functions-compiler/test/engine-contracts.e2e.test.ts diff --git a/packages/functions-compiler/test/engine-contracts.e2e.test.ts b/packages/functions-compiler/test/engine-contracts.e2e.test.ts new file mode 100644 index 000000000..4c88db945 --- /dev/null +++ b/packages/functions-compiler/test/engine-contracts.e2e.test.ts @@ -0,0 +1,135 @@ +/** + * Engine guarantees that only apper's HTTP specs asserted. Those specs boot the + * Hono server and stayed with the service, so the behaviour below — which is the + * compiler's, not the endpoint's — had no coverage on this side of the move. + */ + +import { describe, expect, it } from "vitest"; + +import { bundle, bundleApp } from "../src/bundler"; +import { runInWorkerd } from "./workerd"; + +const serve = (body: string) => `Deno.serve(() => new Response(${body}));`; + +describe("function names", () => { + it("accepts and routes a nested name containing a slash", async () => { + // CLI function names can nest. The name is the routing key, so a compiler + // that quietly rewrote it would break invocation rather than fail loudly. + const name = "functions/v1/users"; + const result = await bundleApp({ + functions: [ + { name, entry: "main.ts", files: { "main.ts": serve('"nested"') } }, + { name: "health", entry: "main.ts", files: { "main.ts": serve('"ok"') } }, + ], + }); + expect(result.ok).toBe(true); + expect(result.functions.map((f) => f.name)).toContain(name); + + const routed = await runInWorkerd(result.ok ? result.module : "", { + headers: { "Base44-Function-Name": name }, + }); + expect(routed).toMatchObject({ status: 200, text: "nested" }); + }); +}); + +describe("combined builds dedupe shared dependencies", () => { + it("inlines a package imported by two functions only once", async () => { + // This is the whole reason a shard is one compile rather than N: a second + // copy of every shared dependency would push each shard toward the size + // ceiling. `__lodash_hash_undefined__` is lodash's own sentinel and appears + // once per inlined copy — the same marker apper's endpoint spec counts. + const source = `import { chunk } from "npm:lodash@4.17.21";\n${serve('String(chunk([1, 2, 3], 2).length)')}`; + const result = await bundleApp({ + functions: [ + { name: "a", entry: "main.ts", files: { "main.ts": source } }, + { name: "b", entry: "main.ts", files: { "main.ts": source } }, + ], + }); + expect(result.ok).toBe(true); + + const copies = (result.ok ? result.module : "").split("__lodash_hash_undefined__").length - 1; + expect(copies).toBeGreaterThan(0); + + const single = await bundleApp({ + functions: [{ name: "a", entry: "main.ts", files: { "main.ts": source } }], + }); + const singleCopies = (single.ok ? single.module : "").split("__lodash_hash_undefined__").length - 1; + expect(copies).toBe(singleCopies); + }); +}); + +describe("compile diagnostics", () => { + it("locates a syntax error by file, line, column and source text", async () => { + // The builder agent reads these fields to point the author at the line; + // an error with only a message sends it guessing. + const result = await bundle({ + entry: "main.ts", + files: { "main.ts": 'const x = ;\nDeno.serve(() => new Response("x"));' }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + const [error] = result.errors; + expect(error.file).toBe("main.ts"); + expect(error.line).toBe(1); + expect(typeof error.column).toBe("number"); + expect(error.lineText).toContain("const x ="); + }); + + it("attributes an error in a shared file to that file, not the entry", async () => { + const result = await bundle({ + entry: "base44/functions/broken/entry.ts", + files: { + "base44/functions/broken/entry.ts": 'import { v } from "../../shared/bad.ts";\n' + serve("v"), + "base44/shared/bad.ts": "export const v = ;", + }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.some((e) => e.file === "base44/shared/bad.ts")).toBe(true); + }); +}); + +describe("the import sandbox holds through indirection", () => { + it("refuses a file: URL reached through a data: module", async () => { + // A data: module is resolved by the Deno resolver, not the user-files + // plugin, so this is the path where a filesystem read could slip past the + // check on the user's own imports. + const inner = 'export { readFileSync } from "file:///etc/passwd";'; + const dataUrl = `data:text/javascript;base64,${Buffer.from(inner).toString("base64")}`; + const result = await bundle({ + entry: "main.ts", + files: { "main.ts": `import * as m from "${dataUrl}";\n${serve("String(m)")}` }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + const message = result.errors.map((e) => e.message).join("\n"); + expect(message).toContain("filesystem imports are not allowed"); + }); +}); + +describe("Deno specifier support", () => { + it("resolves a jsr: import", async () => { + const result = await bundle({ + entry: "main.ts", + files: { + "main.ts": `import { encodeHex } from "jsr:@std/encoding@1.0.5/hex";\n${serve('encodeHex(new Uint8Array([255]))')}`, + }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(await runInWorkerd(result.module)).toMatchObject({ text: "ff" }); + }); + + it("keeps a declared optional dependency external instead of failing the build", async () => { + // axios declares follow-redirects, which is not installed under the Workers + // target. The author's own guard handles the miss at runtime; refusing the + // bundle instead would break a package that works in production. + const result = await bundle({ + entry: "main.ts", + files: { + "main.ts": `import axios from "npm:axios@1.7.2";\n${serve("typeof axios")}`, + }, + }); + expect(result.ok).toBe(true); + }); +});