Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
"description": "JavaScript SDK for Base44 API",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"imports": {
"#build-commit-source": {
"module": "./dist/utils/build-commit-source.js",
"default": "./dist/utils/build-commit-source.fallback.js"
}
},
"type": "module",
"files": [
"dist"
Expand All @@ -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",
Expand Down
104 changes: 104 additions & 0 deletions packages/sdk/scripts/verify-build-commit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/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: Android (Hermes) and web
#
# 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 Metro bundles the SDK without the reader on native and web.
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" <<EOF
{ "name": "verify-app", "private": true, "type": "module",
"dependencies": { "@base44/sdk": "file:$TARBALL", "react": "^18.2.0", "react-dom": "^18.2.0" },
"devDependencies": { "vite": "$2", "@vitejs/plugin-react": "$3" } }
EOF
cat >"$1/vite.config.js" <<'EOF'
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [react()] });
EOF
echo '<!doctype html><div id="root"></div><script type="module" src="/src/main.jsx"></script>' >"$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
npx expo install react-dom react-native-web @expo/metro-runtime >/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"

# Metro's web build ships a classic <script>, where `import.meta` is a
# SyntaxError that blanks the page: parse the bundle the way a browser would.
npx expo export --platform web --output-dir out-web >/dev/null 2>&1 \
|| fail "expo 54 web: build failed"
web=$(cat out-web/_expo/static/js/web/*.js)
grep -q "X-App-Id" <<<"$web" || fail "expo 54 web: SDK not in bundle"
! grep -q "VITE_BASE44_BUILD_COMMIT" <<<"$web" || fail "expo 54 web: picked the import.meta reader"
for f in out-web/_expo/static/js/web/*.js; do
node -e 'new (require("vm").Script)(require("fs").readFileSync(process.argv[1], "utf8"))' "$f" \
|| fail "expo 54 web: $f doesn't parse as a classic script"
done
echo "ok expo 54 web bundle has no import.meta reader and parses as a classic script"
fi
33 changes: 30 additions & 3 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
} from "./modules/connectors.js";
import { getAccessToken } from "./utils/auth-utils.js";
import { createFetchWithAuth } from "./utils/fetch-with-auth.js";
import {
BUILD_COMMIT_HEADER,
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";
Expand Down Expand Up @@ -75,6 +79,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,
Expand Down Expand Up @@ -110,7 +121,21 @@ 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. One set by the caller, in any
// casing, replaces it; resolving that once here keeps every call path,
// functions.fetch() included, reporting the same build.
const callerBuildCommit = Object.entries(optionalHeaders ?? {}).find(
([name]) => name.toLowerCase() === BUILD_COMMIT_HEADER.toLowerCase()
);
const buildCommitHeaders: Record<string, string> = callerBuildCommit
? { [BUILD_COMMIT_HEADER]: callerBuildCommit[1] }
: sendBuildCommit
? getBuildCommitHeaders()
: {};

const headers = {
...(callerBuildCommit ? {} : buildCommitHeaders),
...optionalHeaders,
"X-App-Id": String(appId),
};
Expand Down Expand Up @@ -237,7 +262,7 @@ export function createClient(config: CreateClientConfig): Base44Client {
auth: userAuthModule,
functions: createFunctionsModule(functionsAxiosClient, appId, {
getAuthHeaders: () => {
const headers: Record<string, string> = {};
const headers: Record<string, string> = { ...buildCommitHeaders };
// Get current token from storage or initial config
const currentToken = token || getAccessToken();
if (currentToken) {
Expand Down Expand Up @@ -538,12 +563,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 });
}
7 changes: 7 additions & 0 deletions packages/sdk/src/import-meta-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Vite statically replaces `import.meta.env.*` when it bundles the SDK; see
// utils/build-commit-source.ts. Elsewhere `import.meta.env` is undefined.
interface ImportMeta {
readonly env: {
readonly VITE_BASE44_BUILD_COMMIT?: string;
};
}
16 changes: 16 additions & 0 deletions packages/sdk/src/utils/build-commit-source.fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* What `#build-commit-source` resolves to without the `module` condition: no
* build commit.
*
* The package's `imports` field gives the `import.meta` reader
* (`build-commit-source.ts`) only to bundlers that match `module`: Vite,
* webpack and esbuild. Everything else gets this file, so `import.meta` never
* reaches a runtime or bundler that can't take it. That includes Node, Deno,
* and Metro on every platform: Hermes rejects `import.meta` outright, and
* Metro's web build ships a classic script, where it is a SyntaxError.
*
* @internal
*/
export function readBuildCommitSource(): unknown {
return undefined;
}
25 changes: 25 additions & 0 deletions packages/sdk/src/utils/build-commit-source.ts
Original file line number Diff line number Diff line change
@@ -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 bundler that isn't Vite leaves `import.meta.env` undefined, so the lookup
* throws, and that means "no build commit".
*
* Only reached as `#build-commit-source` under the `module` condition; see
* `build-commit-source.fallback.ts`.
*
* @internal
*/
export function readBuildCommitSource(): unknown {
try {
return import.meta.env.VITE_BASE44_BUILD_COMMIT;
} catch {
return undefined;
}
}
26 changes: 26 additions & 0 deletions packages/sdk/src/utils/build-commit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { isNode, isReactNative } from "./common.js";
import { readBuildCommitSource } from "#build-commit-source";

/** 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<string, string> {
if (isNode || isReactNative) return {};
const commit = readBuildCommitSource();
return typeof commit === "string" && BUILD_COMMIT_PATTERN.test(commit)
? { [BUILD_COMMIT_HEADER]: commit }
: {};
}
Loading