diff --git a/.github/workflows/functions-compiler.yml b/.github/workflows/functions-compiler.yml new file mode 100644 index 00000000..480430d0 --- /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 214c29d1..fce1be97 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/biome.json b/biome.json index b2d7a972..dacbb85d 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/bun.lock b/bun.lock index d7e535e7..a5378fbb 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 9257f724..45504ec8 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 a80f9265..7e0b5438 100644 --- a/knip.json +++ b/knip.json @@ -1,15 +1,52 @@ { "$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": [ + "src/index.ts", + "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 00000000..e97a7fb7 --- /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 00000000..6789d491 --- /dev/null +++ b/packages/functions-compiler/README.md @@ -0,0 +1,136 @@ +# @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. + +## 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. + +## 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 +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. + +**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 +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 +``` + +`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 new file mode 100644 index 00000000..270a31b5 --- /dev/null +++ b/packages/functions-compiler/package.json @@ -0,0 +1,43 @@ +{ + "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", + "publishConfig": { + "access": "restricted" + }, + "type": "module", + "exports": { + ".": { + "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 00000000..8fe09880 --- /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 00000000..d8f91e7e --- /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 00000000..4af30bba --- /dev/null +++ b/packages/functions-compiler/scripts/verify-package.ts @@ -0,0 +1,99 @@ +// 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 { mkdir, 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)!); + assertShipsOnlyBuildOutput(tarball); + + const consumer = path.join(work, "consumer"); + await mkdir(consumer, { recursive: true }); + await writeFile( + path.join(consumer, "package.json"), + JSON.stringify({ name: "compiler-package-probe", private: true, type: "module" }), + ); + // @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); + process.stdout.write(run(nodeBin, ["probe.mjs"], consumer)); + console.log(`packaged compiler works from ${tarball}`); +} 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/`); +} diff --git a/packages/functions-compiler/src/actor-compat.ts b/packages/functions-compiler/src/actor-compat.ts new file mode 100644 index 00000000..a22b1da9 --- /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 00000000..5e0d7dd3 --- /dev/null +++ b/packages/functions-compiler/src/bundler.ts @@ -0,0 +1,470 @@ +import { type ActorCompat, applyActorCompat } from "./actor-compat.js"; +import type { BundleAppRequest, BundleRequest } from "./contracts.js"; +import { bundleToModule, type NodeModulesMode } from "./deno-bundle.js"; +import { type BundleErrorItem, DenoCompatError } from "./errors.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 00000000..93e769da --- /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 00000000..18778fb9 --- /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 00000000..9d1b9f53 --- /dev/null +++ b/packages/functions-compiler/src/deno-bundle.ts @@ -0,0 +1,175 @@ +/** + * 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 { 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"; +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: Awaited>; + 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 00000000..9f6ea90d --- /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 00000000..df273280 --- /dev/null +++ b/packages/functions-compiler/src/esbuild/deno-resolver.ts @@ -0,0 +1,538 @@ +/** + * 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, + ResolveError, + Workspace, +} 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 00000000..d9c3dd8c --- /dev/null +++ b/packages/functions-compiler/src/esbuild/node-builtin-require.ts @@ -0,0 +1,37 @@ +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 00000000..652e38cf --- /dev/null +++ b/packages/functions-compiler/src/esbuild/private-data-sources-virtual.ts @@ -0,0 +1,153 @@ +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 00000000..80afc29a --- /dev/null +++ b/packages/functions-compiler/src/esbuild/runtime-context-virtual.ts @@ -0,0 +1,50 @@ +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 00000000..49c7ed2d --- /dev/null +++ b/packages/functions-compiler/src/esbuild/runtime-virtual.ts @@ -0,0 +1,56 @@ +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 00000000..b71ed2c9 --- /dev/null +++ b/packages/functions-compiler/src/esbuild/user-files.ts @@ -0,0 +1,92 @@ +/** + * 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 00000000..b0ed002d --- /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 00000000..62a51536 --- /dev/null +++ b/packages/functions-compiler/src/index.ts @@ -0,0 +1,35 @@ +// 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 type { + AppErrorClassification, + AppFunctionStatus, + BundleAppResponse, + BundleErrorStage, + BundleResponse, +} from "./bundler.js"; +export { + bundle, + bundleApp, + classifyAppErrors, + importsConflictingPackage, +} from "./bundler.js"; +export type { + AppFunctionInput, + BundleAppRequest, + BundleRequest, +} from "./contracts.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 type { CompilerTracer } from "./tracing.js"; +export { setCompilerTracer } from "./tracing.js"; diff --git a/packages/functions-compiler/src/log.ts b/packages/functions-compiler/src/log.ts new file mode 100644 index 00000000..b6e9c019 --- /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 00000000..0118e80f --- /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 00000000..024caed4 --- /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 00000000..dddb2dd4 --- /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 00000000..42b942d2 --- /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 00000000..f0d4612f --- /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 00000000..fc94599a --- /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 00000000..fa0fd42a --- /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 00000000..0995bd98 --- /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 00000000..1f897f8d --- /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 00000000..10fc6f25 --- /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 00000000..69847b41 --- /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 00000000..1e0e76d7 --- /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 00000000..030f1dcf --- /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 00000000..85cc027f --- /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 00000000..cd4c7f27 --- /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 00000000..854a4620 --- /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 00000000..674b9ca9 --- /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 00000000..e461d996 --- /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 00000000..ff630e1f --- /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 00000000..2277b142 --- /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 00000000..3626e3c4 --- /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 00000000..9800a162 --- /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 00000000..53afd956 --- /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 00000000..98683f63 --- /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 00000000..bd77e796 --- /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 00000000..378d81ae --- /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 00000000..d1548e74 --- /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 00000000..8de4b6a2 --- /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 00000000..c1348b49 --- /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 00000000..7d8c37f6 --- /dev/null +++ b/packages/functions-compiler/src/tracing.ts @@ -0,0 +1,32 @@ +// 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 00000000..857a4442 --- /dev/null +++ b/packages/functions-compiler/src/worker-entry.ts @@ -0,0 +1,389 @@ +/** + * 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 { 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. +// 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 { + _denoShimSource ??= readFileSync( + new URL("../dist/deno-shim.mjs", import.meta.url), + "utf8", + ); + return _denoShimSource; +} +let _activationShimSource: string | undefined; +function activationShimSource(): string { + _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. +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 00000000..3f538a10 --- /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 00000000..c4a74f30 --- /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 00000000..7c2bed14 --- /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/app-partial-results.test.ts b/packages/functions-compiler/test/app-partial-results.test.ts new file mode 100644 index 00000000..3d82c6cc --- /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/assembly-conflict.test.ts b/packages/functions-compiler/test/assembly-conflict.test.ts new file mode 100644 index 00000000..de1616e4 --- /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 00000000..f4c86d81 --- /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 00000000..0ca20a95 --- /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 00000000..74a90f6b --- /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/diagnostics-hooks.test.ts b/packages/functions-compiler/test/diagnostics-hooks.test.ts new file mode 100644 index 00000000..06a96c5e --- /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/engine-contracts.e2e.test.ts b/packages/functions-compiler/test/engine-contracts.e2e.test.ts new file mode 100644 index 00000000..4c88db94 --- /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); + }); +}); 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 00000000..b8da07fa --- /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 00000000..16709135 --- /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 00000000..88f269c4 --- /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 00000000..42cdf602 --- /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 00000000..49987972 --- /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 00000000..5309119a --- /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 00000000..a4a674a5 --- /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 00000000..9d5160e9 --- /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/real-paths.e2e.test.ts b/packages/functions-compiler/test/real-paths.e2e.test.ts new file mode 100644 index 00000000..0d404acf --- /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); + }); +}); 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 00000000..16891702 --- /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 00000000..2d0567b1 --- /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 00000000..d74ab58f --- /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 00000000..8f0e5bb0 --- /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 00000000..590992c4 --- /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 00000000..18de6617 --- /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 00000000..f5a24b1f --- /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 00000000..bdc327c2 --- /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 00000000..7f2d323b --- /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 00000000..8397bf30 --- /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 00000000..951f6d16 --- /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, + }, +});