From 9240d69699f4d9f6c50b29b904ecd8409c7272c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Egidijus=20Jucevi=C4=8Dius?= Date: Mon, 12 Oct 2026 22:44:46 +0300 Subject: [PATCH 1/3] feat(sdk): send the app's build commit as X-Base44-Build from the browser The Production Agent groups runtime errors from published apps into issues, and needs to know which frontend build produced each browser-side error so a tab still running an old bundle can't reopen a fixed issue. The publish pipeline sets VITE_BASE44_BUILD_COMMIT for every build. The SDK now reads it itself via `import.meta.env.VITE_BASE44_BUILD_COMMIT`, which Vite statically replaces inside node_modules in both `vite build` and the dev server's prebundled deps, so no app code changes. Every API call from a browser client (entities, functions.invoke, functions.fetch, and the rest of the axios path) carries the header when the value is exactly 40 lowercase hex chars; otherwise it is omitted. `import.meta` lives only in build-commit-source.browser.js, reached through the package's `browser` field. The default file is a stub, so Node, Deno and SSR never see it, and the `react-native` field maps Metro back to the stub, since Hermes (Expo 54) rejects `import.meta` outright. Never sent from createClientFromRequest, Node/Deno, or React Native. scripts/verify-build-commit.sh (npm run test:build-commit) proves it against the packed tarball: Vite 6 and 7 build + dev, and Expo 54 with --expo. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/package.json | 7 + packages/sdk/scripts/verify-build-commit.sh | 90 +++++++ packages/sdk/src/client.ts | 21 +- packages/sdk/src/import-meta-env.d.ts | 7 + .../src/utils/build-commit-source.browser.ts | 25 ++ packages/sdk/src/utils/build-commit-source.ts | 14 + packages/sdk/src/utils/build-commit.ts | 26 ++ .../tests/unit/build-commit-header.test.ts | 243 ++++++++++++++++++ 8 files changed, 430 insertions(+), 3 deletions(-) create mode 100755 packages/sdk/scripts/verify-build-commit.sh create mode 100644 packages/sdk/src/import-meta-env.d.ts create mode 100644 packages/sdk/src/utils/build-commit-source.browser.ts create mode 100644 packages/sdk/src/utils/build-commit-source.ts create mode 100644 packages/sdk/src/utils/build-commit.ts create mode 100644 packages/sdk/tests/unit/build-commit-header.test.ts diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a1b7db6f..61d705e8 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -4,6 +4,12 @@ "description": "JavaScript SDK for Base44 API", "main": "dist/index.js", "types": "dist/index.d.ts", + "browser": { + "./dist/utils/build-commit-source.js": "./dist/utils/build-commit-source.browser.js" + }, + "react-native": { + "./dist/utils/build-commit-source.js": "./dist/utils/build-commit-source.js" + }, "type": "module", "files": [ "dist" @@ -16,6 +22,7 @@ "test:e2e": "vitest run tests/e2e", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "test:build-commit": "scripts/verify-build-commit.sh", "docs": "typedoc", "prepublishOnly": "npm run build", "create-docs": "npm run create-docs:generate && npm run create-docs:process", diff --git a/packages/sdk/scripts/verify-build-commit.sh b/packages/sdk/scripts/verify-build-commit.sh new file mode 100755 index 00000000..d539bb92 --- /dev/null +++ b/packages/sdk/scripts/verify-build-commit.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Proves the X-Base44-Build value really reaches a bundled app, using the SDK as +# it ships: packed and installed into node_modules, not linked. +# +# scripts/verify-build-commit.sh # Vite 6 and 7: build + dev server +# scripts/verify-build-commit.sh --expo # also Expo 54 (Metro/Hermes) +# +# Checks that `vite build` bakes VITE_BASE44_BUILD_COMMIT into the bundle and +# leaves no `import.meta` behind, that the dev server's prebundled SDK carries +# it, and that a Metro/Hermes build still bundles the SDK without the reader. +set -euo pipefail + +SDK_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$(mktemp -d)" +COMMIT=0123456789abcdef0123456789abcdef01234567 +PORT=5391 +trap 'kill $(jobs -p) 2>/dev/null || true; rm -rf "$WORK"' EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } + +(cd "$SDK_DIR" && npm run build >/dev/null && npm pack --pack-destination "$WORK" >/dev/null 2>&1) +TARBALL="$(ls "$WORK"/base44-sdk-*.tgz)" + +make_vite_app() { # dir vite-version plugin-react-version + mkdir -p "$1/src" + cat >"$1/package.json" <"$1/vite.config.js" <<'EOF' +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +export default defineConfig({ plugins: [react()] }); +EOF + echo '
' >"$1/index.html" + cat >"$1/src/main.jsx" <<'EOF' +import { createClient } from "@base44/sdk"; +const base44 = createClient({ appId: import.meta.env.VITE_BASE44_APP_ID }); +base44.entities.Todo.list().catch(() => {}); +EOF + (cd "$1" && npm install --silent >/dev/null 2>&1) +} + +check_vite() { # dir label + local app="$1" label="$2" + cd "$app" + # Output is captured before grepping: under pipefail, `grep -q` exiting + # early would fail the pipeline with SIGPIPE. + local out + VITE_BASE44_BUILD_COMMIT=$COMMIT npx vite build --logLevel silent >/dev/null + out=$(cat dist/assets/*.js) + grep -q "$COMMIT" <<<"$out" || fail "$label build: commit not in bundle" + ! grep -q 'import\.meta' <<<"$out" || fail "$label build: import.meta left in bundle" + echo "ok $label build bakes in the commit" + + npx vite build --logLevel silent >/dev/null + out=$(cat dist/assets/*.js) + ! grep -q "$COMMIT" <<<"$out" || fail "$label build without the var still has a commit" + echo "ok $label build without the var has none" + + rm -rf node_modules/.vite + VITE_BASE44_BUILD_COMMIT=$COMMIT npx vite --port $PORT --strictPort >/dev/null 2>&1 & + local server=$! + for _ in $(seq 1 60); do curl -sf "localhost:$PORT" >/dev/null && break; sleep 0.5; done + local dep + dep=$(curl -s "localhost:$PORT/src/main.jsx" | grep -o '/node_modules/.vite/deps/@base44_sdk.js[^"]*') + out=$(curl -s "localhost:$PORT$dep") + grep -q "$COMMIT" <<<"$out" || fail "$label dev: prebundled SDK lacks the commit" + kill $server; wait $server 2>/dev/null || true + echo "ok $label dev server's prebundled SDK carries the commit" +} + +make_vite_app "$WORK/vite6" "^6.1.0" "^4.3.4" && check_vite "$WORK/vite6" "vite 6" +make_vite_app "$WORK/vite7" "^7.0.0" "^5.0.0" && check_vite "$WORK/vite7" "vite 7" + +if [ "${1:-}" = "--expo" ]; then + cd "$WORK" + npx --yes create-expo-app@latest expo-app --template blank@sdk-54 --no-install >/dev/null 2>&1 + cd expo-app && npm install --silent >/dev/null 2>&1 && npm install --silent "$TARBALL" >/dev/null 2>&1 + printf '%s\n' "import { createClient } from '@base44/sdk';" \ + "export const base44 = createClient({ appId: 'x' });" | cat - App.js >App.tmp && mv App.tmp App.js + npx expo export --platform android --output-dir out-hbc >/dev/null 2>&1 \ + || fail "expo 54: Hermes build failed" + npx expo export --platform android --no-bytecode --output-dir out-js >/dev/null 2>&1 + bundle=$(cat out-js/_expo/static/js/android/*.js) + grep -q "X-Base44-Build" <<<"$bundle" || fail "expo 54: SDK not in bundle" + ! grep -q "VITE_BASE44_BUILD_COMMIT" <<<"$bundle" || fail "expo 54: picked the browser reader" + echo "ok expo 54 Hermes build bundles the SDK without the import.meta reader" +fi diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 9e357dfa..bb4855a3 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -9,6 +9,7 @@ import { } from "./modules/connectors.js"; import { getAccessToken } from "./utils/auth-utils.js"; import { createFetchWithAuth } from "./utils/fetch-with-auth.js"; +import { getBuildCommitHeaders } from "./utils/build-commit.js"; import { createFunctionsModule } from "./modules/functions.js"; import { createAgentsModule } from "./modules/agents.js"; import { createAiGatewayModule } from "./modules/ai-gateway.js"; @@ -75,6 +76,13 @@ export type { * ``` */ export function createClient(config: CreateClientConfig): Base44Client { + return buildClient(config, { sendBuildCommit: true }); +} + +function buildClient( + config: CreateClientConfig, + { sendBuildCommit }: { sendBuildCommit: boolean } +): Base44Client { const { serverUrl = "https://base44.app", appId, @@ -110,7 +118,12 @@ export function createClient(config: CreateClientConfig): Base44Client { return socket; }; + // The build commit goes on every API call, so the backend can tell which + // frontend bundle a request came from. Platform headers win over it. + const buildCommitHeaders = sendBuildCommit ? getBuildCommitHeaders() : {}; + const headers = { + ...buildCommitHeaders, ...optionalHeaders, "X-App-Id": String(appId), }; @@ -237,7 +250,7 @@ export function createClient(config: CreateClientConfig): Base44Client { auth: userAuthModule, functions: createFunctionsModule(functionsAxiosClient, appId, { getAuthHeaders: () => { - const headers: Record = {}; + const headers: Record = { ...buildCommitHeaders }; // Get current token from storage or initial config const currentToken = token || getAccessToken(); if (currentToken) { @@ -538,12 +551,14 @@ export function createClientFromRequest(request: Request): Base44Client { additionalHeaders["X-Data-Env"] = dataEnvHeader; } - return createClient({ + // A backend function isn't the browser bundle, so it never reports a build + // commit, whatever its runtime looks like. + return buildClient({ serverUrl: serverUrlHeader || "https://base44.app", appId, token: userToken, serviceToken: serviceRoleToken, functionsVersion: functionsVersion ?? undefined, headers: additionalHeaders, - }); + }, { sendBuildCommit: false }); } diff --git a/packages/sdk/src/import-meta-env.d.ts b/packages/sdk/src/import-meta-env.d.ts new file mode 100644 index 00000000..234cd2ab --- /dev/null +++ b/packages/sdk/src/import-meta-env.d.ts @@ -0,0 +1,7 @@ +// Vite statically replaces `import.meta.env.*` when it bundles the SDK; see +// utils/build-commit-source.browser.ts. Elsewhere `import.meta.env` is undefined. +interface ImportMeta { + readonly env: { + readonly VITE_BASE44_BUILD_COMMIT?: string; + }; +} diff --git a/packages/sdk/src/utils/build-commit-source.browser.ts b/packages/sdk/src/utils/build-commit-source.browser.ts new file mode 100644 index 00000000..6521774c --- /dev/null +++ b/packages/sdk/src/utils/build-commit-source.browser.ts @@ -0,0 +1,25 @@ +/** + * Reads the commit the app's frontend bundle was built from. + * + * Base44's publish pipeline sets `VITE_BASE44_BUILD_COMMIT` for the build, and + * Vite replaces the expression below with that literal string when it bundles + * this file out of `node_modules`, in `vite build` and in the dev server's + * prebundled deps alike. The expression must stay spelled out exactly like + * this: Vite matches it statically, and any indirection leaves a runtime lookup + * that is always undefined. + * + * A browser bundler that isn't Vite leaves `import.meta.env` undefined, so the + * lookup throws, and that means "no build commit". + * + * Only reached through the package's `browser` field; see + * `build-commit-source.ts`. + * + * @internal + */ +export function readBuildCommitSource(): unknown { + try { + return import.meta.env.VITE_BASE44_BUILD_COMMIT; + } catch { + return undefined; + } +} diff --git a/packages/sdk/src/utils/build-commit-source.ts b/packages/sdk/src/utils/build-commit-source.ts new file mode 100644 index 00000000..14ec7383 --- /dev/null +++ b/packages/sdk/src/utils/build-commit-source.ts @@ -0,0 +1,14 @@ +/** + * Where the SDK gets the app's build commit outside a browser bundle: nowhere. + * + * The package's `browser` field swaps this file for `build-commit-source.browser.js`, + * which reads the value Vite bakes in. Everything else — Node, Deno, SSR builds, + * and React Native, whose `react-native` field maps this file back to itself — + * gets this one, so `import.meta` never reaches a runtime or bundler that can't + * parse it (Hermes rejects it outright). + * + * @internal + */ +export function readBuildCommitSource(): unknown { + return undefined; +} diff --git a/packages/sdk/src/utils/build-commit.ts b/packages/sdk/src/utils/build-commit.ts new file mode 100644 index 00000000..354d34a9 --- /dev/null +++ b/packages/sdk/src/utils/build-commit.ts @@ -0,0 +1,26 @@ +import { isNode, isReactNative } from "./common.js"; +import { readBuildCommitSource } from "./build-commit-source.js"; + +/** Header the backend reads to tell which frontend build sent a request. */ +export const BUILD_COMMIT_HEADER = "X-Base44-Build"; + +// The backend accepts exactly this shape and ignores anything else, so a value +// that doesn't match is never sent. +const BUILD_COMMIT_PATTERN = /^[a-f0-9]{40}$/; + +/** + * The build-commit header to send from this client, if any. + * + * Only a browser sends it: the value names the browser bundle, so a server + * (Node, Deno, a backend function) or React Native would report a commit that + * isn't the one running. + * + * @internal + */ +export function getBuildCommitHeaders(): Record { + if (isNode || isReactNative) return {}; + const commit = readBuildCommitSource(); + return typeof commit === "string" && BUILD_COMMIT_PATTERN.test(commit) + ? { [BUILD_COMMIT_HEADER]: commit } + : {}; +} diff --git a/packages/sdk/tests/unit/build-commit-header.test.ts b/packages/sdk/tests/unit/build-commit-header.test.ts new file mode 100644 index 00000000..3dcd747c --- /dev/null +++ b/packages/sdk/tests/unit/build-commit-header.test.ts @@ -0,0 +1,243 @@ +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import nock from "nock"; + +// What the bundler would have baked in for VITE_BASE44_BUILD_COMMIT. The real +// source is swapped in by the package's `browser` field, which these tests +// can't exercise, so it is replaced here and tested on its own below. +const source = vi.hoisted(() => ({ value: undefined as unknown })); +vi.mock("../../src/utils/build-commit-source.ts", () => ({ + readBuildCommitSource: () => source.value, +})); + +const COMMIT = "0123456789abcdef0123456789abcdef01234567"; +const serverUrl = "https://api.base44.com"; +const appId = "test-app-id"; + +function makeLocalStorage() { + const store = new Map(); + return { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + }; +} + +function stubBrowserGlobals() { + const localStorage = makeLocalStorage(); + vi.stubGlobal("window", { + location: { href: "https://my-app.base44.app/", origin: "https://my-app.base44.app" }, + localStorage, + addEventListener: () => {}, + removeEventListener: () => {}, + }); + vi.stubGlobal("document", {}); + vi.stubGlobal("localStorage", localStorage); +} + +function stubReactNativeGlobals() { + vi.stubGlobal("window", {}); + vi.stubGlobal("document", undefined); + vi.stubGlobal("localStorage", undefined); +} + +// Environment flags are read once at module load, so each test re-imports the +// SDK after stubbing the globals it runs under. +async function loadSdk() { + vi.resetModules(); + return await import("../../src/index.ts"); +} + +// The X-Base44-Build value the next entity call sends, or undefined. +async function entityCallBuildHeader(client: { + entities: Record; +}): Promise { + let sent: string | undefined; + nock(serverUrl) + .get(`/api/apps/${appId}/entities/Todo`) + .query(true) + .reply(function () { + const value = this.req.headers["x-base44-build"]; + sent = Array.isArray(value) ? value[0] : value; + return [200, []]; + }); + await client.entities.Todo.list(); + return sent; +} + +beforeEach(() => { + nock.disableNetConnect(); + source.value = undefined; +}); + +afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +describe("X-Base44-Build header", () => { + test("a browser client sends the build commit on entity calls", async () => { + stubBrowserGlobals(); + source.value = COMMIT; + const { createClient } = await loadSdk(); + const client = createClient({ serverUrl, appId, analytics: { enabled: false } }); + + expect(await entityCallBuildHeader(client)).toBe(COMMIT); + }); + + test("a browser client sends it on function invocations and functions.fetch", async () => { + stubBrowserGlobals(); + source.value = COMMIT; + const { createClient } = await loadSdk(); + const client = createClient({ serverUrl, appId, analytics: { enabled: false } }); + + let invoked: string | undefined; + nock(serverUrl) + .post(`/api/apps/${appId}/functions/hello`) + .reply(function () { + invoked = this.req.headers["x-base44-build"] as string; + return [200, {}]; + }); + await client.functions.invoke("hello", {}); + expect(invoked).toBe(COMMIT); + + const fetchMock = vi.fn(async () => new Response("ok")); + vi.stubGlobal("fetch", fetchMock); + await client.functions.fetch("/hello"); + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(new Headers(init.headers).get("X-Base44-Build")).toBe(COMMIT); + }); + + test("omits the header when no build commit was baked in", async () => { + stubBrowserGlobals(); + const { createClient } = await loadSdk(); + const client = createClient({ serverUrl, appId, analytics: { enabled: false } }); + + expect(await entityCallBuildHeader(client)).toBeUndefined(); + }); + + test.each([ + ["empty", ""], + ["uppercase", COMMIT.toUpperCase()], + ["short", COMMIT.slice(0, 39)], + ["long", COMMIT + "0"], + ["padded", ` ${COMMIT}`], + ["trailing newline", `${COMMIT}\n`], + ["not hex", "g".repeat(40)], + ["not a string", 123], + ])("omits the header for a malformed value (%s)", async (_label, value) => { + stubBrowserGlobals(); + source.value = value; + const { createClient } = await loadSdk(); + const client = createClient({ serverUrl, appId, analytics: { enabled: false } }); + + expect(await entityCallBuildHeader(client)).toBeUndefined(); + }); + + test("createClientFromRequest never sends it, even where a browser bundle would", async () => { + stubBrowserGlobals(); + source.value = COMMIT; + const { createClientFromRequest } = await loadSdk(); + const client = createClientFromRequest( + new Request("https://fn.example/", { + headers: { "Base44-App-Id": appId, "Base44-Api-Url": serverUrl }, + }) + ); + + expect(await entityCallBuildHeader(client)).toBeUndefined(); + }); + + test("a server-side client (no window) never sends it", async () => { + source.value = COMMIT; + const { createClient } = await loadSdk(); + const client = createClient({ serverUrl, appId }); + + expect(await entityCallBuildHeader(client)).toBeUndefined(); + }); + + test("a React Native client never sends it", async () => { + stubReactNativeGlobals(); + source.value = COMMIT; + const { createClient } = await loadSdk(); + const client = createClient({ serverUrl, appId }); + + expect(await entityCallBuildHeader(client)).toBeUndefined(); + }); + + test("a caller-supplied header still wins", async () => { + stubBrowserGlobals(); + source.value = COMMIT; + const other = "f".repeat(40); + const { createClient } = await loadSdk(); + const client = createClient({ + serverUrl, + appId, + analytics: { enabled: false }, + headers: { "X-Base44-Build": other }, + }); + + expect(await entityCallBuildHeader(client)).toBe(other); + }); +}); + +describe("build commit source", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test("the browser source reads VITE_BASE44_BUILD_COMMIT from import.meta.env", async () => { + const { readBuildCommitSource } = await vi.importActual< + typeof import("../../src/utils/build-commit-source.browser.ts") + >("../../src/utils/build-commit-source.browser.ts"); + + vi.stubEnv("VITE_BASE44_BUILD_COMMIT", COMMIT); + expect(readBuildCommitSource()).toBe(COMMIT); + }); + + test("the browser source keeps the exact expression Vite replaces", () => { + // Vite substitutes the value by matching this expression statically; a + // refactor that reaches it any other way silently stops the header. + const file = fileURLToPath( + new URL("../../src/utils/build-commit-source.browser.ts", import.meta.url) + ); + expect(readFileSync(file, "utf8")).toContain( + "import.meta.env.VITE_BASE44_BUILD_COMMIT" + ); + }); + + test("the default source never touches import.meta", async () => { + const file = fileURLToPath( + new URL("../../src/utils/build-commit-source.ts", import.meta.url) + ); + const code = readFileSync(file, "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); + expect(code).not.toContain("import.meta"); + + const { readBuildCommitSource } = await vi.importActual< + typeof import("../../src/utils/build-commit-source.ts") + >("../../src/utils/build-commit-source.ts"); + expect(readBuildCommitSource()).toBeUndefined(); + }); + + test("package.json routes browsers to the reader and React Native back to the stub", () => { + const pkgPath = fileURLToPath(new URL("../../package.json", import.meta.url)); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + const stub = "./dist/utils/build-commit-source.js"; + + expect(pkg.browser).toEqual({ + [stub]: "./dist/utils/build-commit-source.browser.js", + }); + // Metro reads `react-native` before `browser`; without this it would pick + // the reader, and Hermes can't parse `import.meta`. + expect(pkg["react-native"]).toEqual({ [stub]: stub }); + + // Each mapped dist file comes from a src file of the same name. + for (const target of [stub, pkg.browser[stub]]) { + const src = target.replace("./dist/", "../../src/").replace(/\.js$/, ".ts"); + expect(existsSync(fileURLToPath(new URL(src, import.meta.url)))).toBe(true); + } + }); +}); From e92a0f3ec3e52daf34c70c901e45b66f956472c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Egidijus=20Jucevi=C4=8Dius?= Date: Mon, 12 Oct 2026 23:22:05 +0300 Subject: [PATCH 2/3] fix(sdk): route the build-commit reader through a module-condition import The `browser` field broke Expo web: Metro's web resolver ignores the `react-native` field and follows `browser`, so it bundled the `import.meta` reader into a classic