diff --git a/src/client.ts b/src/client.ts index 3965f29..9830829 100644 --- a/src/client.ts +++ b/src/client.ts @@ -71,6 +71,8 @@ export function createClient(config: CreateClientConfig): Base44Client { options, functionsVersion, headers: optionalHeaders, + disableAnalytics = false, + adapter, } = config; // Normalize appBaseUrl to always be a string (empty if not provided or invalid) @@ -112,6 +114,7 @@ export function createClient(config: CreateClientConfig): Base44Client { headers, token, onError: options?.onError, + adapter, }); const functionsAxiosClient = createAxiosClient({ @@ -120,6 +123,7 @@ export function createClient(config: CreateClientConfig): Base44Client { token, interceptResponses: false, onError: options?.onError, + adapter, }); const serviceRoleHeaders = { @@ -132,6 +136,7 @@ export function createClient(config: CreateClientConfig): Base44Client { headers: serviceRoleHeaders, token: serviceToken, onError: options?.onError, + adapter, }); const serviceRoleFunctionsAxiosClient = createAxiosClient({ @@ -139,6 +144,7 @@ export function createClient(config: CreateClientConfig): Base44Client { headers: functionHeaders, token: serviceToken, interceptResponses: false, + adapter, }); const userAuthModule = createAuthModule( @@ -197,6 +203,7 @@ export function createClient(config: CreateClientConfig): Base44Client { serverUrl, appId, userAuthModule, + disabled: disableAnalytics, }), cleanup: () => { userModules.analytics.cleanup(); diff --git a/src/client.types.ts b/src/client.types.ts index 44c2a6c..8c98c1e 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -1,3 +1,4 @@ +import type { AxiosRequestConfig } from "axios"; import type { EntitiesModule } from "./modules/entities.types.js"; import type { IntegrationsModule } from "./modules/integrations.types.js"; import type { AuthModule } from "./modules/auth.types.js"; @@ -73,6 +74,28 @@ export interface CreateClientConfig { * @internal */ headers?: Record; + /** + * Disables the {@link AnalyticsModule | analytics module} entirely. + * + * When `true`, `base44.analytics.track()` becomes a no-op and no background + * processing or heartbeat timers are started. Set this for server-side + * clients (SSR, edge runtimes) where background timers must not run. + * {@linkcode createServerClient | createServerClient()} sets this automatically. + * + * Analytics is also automatically disabled outside a browser environment. + * + * @defaultValue `false` + */ + disableAnalytics?: boolean; + /** + * Axios adapter override for HTTP requests. + * + * By default, axios picks an adapter based on the runtime. Set this to + * `'fetch'` in edge runtimes such as Cloudflare Workers, where the default + * adapter detection can pick the wrong adapter. + * {@linkcode createServerClient | createServerClient()} sets this automatically. + */ + adapter?: AxiosRequestConfig["adapter"]; /** * Additional client options. */ diff --git a/src/index.ts b/src/index.ts index bc531d8..2b76660 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,10 @@ import { type CreateClientConfig, type CreateClientOptions, } from "./client.js"; +import { + createServerClient, + type CreateServerClientOptions, +} from "./server.js"; import { Base44Error, type Base44ErrorJSON } from "./utils/axios-client.js"; import { getAccessToken, @@ -16,6 +20,7 @@ import { export { createClient, createClientFromRequest, + createServerClient, Base44Error, getAccessToken, saveAccessToken, @@ -27,6 +32,7 @@ export type { Base44Client, CreateClientConfig, CreateClientOptions, + CreateServerClientOptions, Base44ErrorJSON, }; diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index 3ad8b64..0ede76a 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -59,6 +59,8 @@ export interface AnalyticsModuleArgs { serverUrl: string; appId: string; userAuthModule: AuthModule; + /** Skips all analytics processing when true (e.g. for server-side clients). */ + disabled?: boolean; } export const createAnalyticsModule = ({ @@ -66,11 +68,19 @@ export const createAnalyticsModule = ({ serverUrl, appId, userAuthModule, + disabled = false, }: AnalyticsModuleArgs) => { // prevent overflow of events // const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config; - if (!analyticsSharedState.config?.enabled) { + // Analytics is browser-only. Outside a browser (SSR, Cloudflare Workers, + // Node) timers would leak — or throw at global scope in Workers — so the + // module becomes a no-op there, and when explicitly disabled. + if ( + disabled || + typeof window === "undefined" || + !analyticsSharedState.config?.enabled + ) { return { track: () => {}, cleanup: () => {}, diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 13c35d7..fbfe81a 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -6,6 +6,10 @@ import { ChangePasswordParams, ResetPasswordParams, } from "./auth.types"; +import { + setAccessTokenCookie, + clearAccessTokenCookie, +} from "../utils/auth-utils.js"; function isInsideIframe(): boolean { if (typeof window === "undefined") return false; @@ -171,6 +175,9 @@ export function createAuthModule( } } + // Clear the cookie that mirrors the token for SSR + clearAccessTokenCookie(); + // Determine the from_url parameter const fromUrl = redirectUrl || window.location.href; @@ -203,6 +210,9 @@ export function createAuthModule( } catch (e) { console.error("Failed to save token to localStorage:", e); } + + // Mirror the token into a cookie so document requests carry it to SSR + setAccessTokenCookie(token); } }, diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..89605d0 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,252 @@ +import { createClient } from "./client.js"; +import type { Base44Client } from "./client.types.js"; + +const ACCESS_TOKEN_COOKIE_NAME = "base44_access_token"; + +/** + * Options for creating a server-side Base44 client with {@linkcode createServerClient | createServerClient()}. + */ +export interface CreateServerClientOptions { + /** + * The incoming Fetch API `Request`. + * + * Used to resolve configuration from `Base44-*` headers, the user token from + * the `Authorization` header, and the `base44_access_token` cookie set by the + * browser SDK. + */ + request: Request; + /** + * Environment variables record, such as a Cloudflare Worker `env` binding or + * Node's `process.env`. + * + * Recognized variables: `BASE44_APP_ID`, `BASE44_API_URL`, + * `BASE44_SERVICE_TOKEN`, and `BASE44_FUNCTIONS_VERSION`. + */ + env?: Record; + /** + * The Base44 app ID. Takes precedence over `env.BASE44_APP_ID` and the + * `Base44-App-Id` request header. + */ + appId?: string; + /** + * The Base44 server URL. Must be an absolute URL. Takes precedence over + * `env.BASE44_API_URL` and the `Base44-Api-Url` request header. + * + * @defaultValue `"https://base44.app"` + */ + serverUrl?: string; + /** + * User authentication token. Takes precedence over the request's + * `Authorization: Bearer` header and the `base44_access_token` cookie. + */ + token?: string; + /** + * Service role authentication token. Takes precedence over + * `env.BASE44_SERVICE_TOKEN` and the `Base44-Service-Authorization` request + * header. + */ + serviceToken?: string; + /** + * Version string for the functions API. Takes precedence over + * `env.BASE44_FUNCTIONS_VERSION` and the `Base44-Functions-Version` request + * header. + * @internal + */ + functionsVersion?: string; +} + +/** + * Returns the first truthy value, treating empty strings the same as unset. + */ +function firstNonEmpty( + ...values: Array +): string | undefined { + for (const value of values) { + if (value) { + return value; + } + } + return undefined; +} + +/** + * Extracts the token from a `Bearer ` authorization header value. + * Returns undefined when the header is missing or malformed. + */ +function parseBearerToken(header: string | null): string | undefined { + if (!header) { + return undefined; + } + const parts = header.split(" "); + if (parts.length === 2 && parts[0] === "Bearer" && parts[1]) { + return parts[1]; + } + return undefined; +} + +/** + * Minimal cookie parser (no dependency): reads a single cookie value from a + * `Cookie` request header, handling quoted values and URL-encoding. + */ +function getCookieValue( + cookieHeader: string | null, + name: string +): string | undefined { + if (!cookieHeader) { + return undefined; + } + for (const part of cookieHeader.split(";")) { + const separatorIndex = part.indexOf("="); + if (separatorIndex === -1) { + continue; + } + if (part.slice(0, separatorIndex).trim() !== name) { + continue; + } + let value = part.slice(separatorIndex + 1).trim(); + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + try { + return decodeURIComponent(value); + } catch { + return value; + } + } + return undefined; +} + +function isAbsoluteUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } +} + +/** + * Creates a Base44 client for server-side rendering (SSR) and edge runtimes. + * + * Use this function in Cloudflare Workers, framework loaders, and other + * server environments that handle Fetch API requests. Unlike + * {@linkcode createClient | createClient()}, the returned client is safe to + * create per request outside a browser: analytics is fully disabled (no + * background timers), HTTP requests use the `fetch` adapter, and no browser + * storage is touched. + * + * Each configuration value is resolved in order from: the explicit option, + * the `env` record (`BASE44_APP_ID`, `BASE44_API_URL`, `BASE44_SERVICE_TOKEN`, + * `BASE44_FUNCTIONS_VERSION`), and finally the request headers — the same + * `Base44-*` headers read by + * {@linkcode createClientFromRequest | createClientFromRequest()}. The user + * token is resolved from the explicit option, then the request's + * `Authorization: Bearer` header, then the `base44_access_token` cookie that + * the browser SDK mirrors from localStorage. + * + * @param options - Server client options, including the incoming request and optional environment record. + * @returns A configured Base44 client instance scoped to the incoming request. + * @throws {Error} When no app ID can be resolved from the options, environment, or request headers. + * @throws {Error} When the resolved server URL isn't an absolute URL. + * + * @example + * ```typescript + * // Cloudflare Worker fetch handler + * import { createServerClient } from '@base44/sdk'; + * + * export default { + * async fetch(request, env) { + * const base44 = createServerClient({ request, env }); + * + * // Reads data as the user identified by the request's cookie or + * // Authorization header (anonymous when neither is present) + * const products = await base44.entities.Products.list(); + * + * return Response.json({ products }); + * } + * }; + * ``` + * + * @example + * ```typescript + * // Framework loader (React Router, Remix, and similar) + * import { createServerClient } from '@base44/sdk'; + * + * export async function loader({ request }) { + * const base44 = createServerClient({ + * request, + * appId: 'my-app-id' + * }); + * + * const user = await base44.auth.me().catch(() => null); + * return { user }; + * } + * ``` + */ +export function createServerClient( + options: CreateServerClientOptions +): Base44Client { + const { request, env = {} } = options; + const headers = request.headers; + + const appId = firstNonEmpty( + options.appId, + env.BASE44_APP_ID, + headers.get("Base44-App-Id") + ); + if (!appId) { + throw new Error( + "createServerClient: unable to resolve an app ID. Pass appId explicitly, set the BASE44_APP_ID environment variable, or forward the Base44-App-Id request header." + ); + } + + const serverUrl = + firstNonEmpty( + options.serverUrl, + env.BASE44_API_URL, + headers.get("Base44-Api-Url") + ) ?? "https://base44.app"; + if (!isAbsoluteUrl(serverUrl)) { + throw new Error( + `createServerClient: serverUrl must be an absolute URL, got "${serverUrl}"` + ); + } + + const token = firstNonEmpty( + options.token, + parseBearerToken(headers.get("Authorization")), + getCookieValue(headers.get("Cookie"), ACCESS_TOKEN_COOKIE_NAME) + ); + + const serviceToken = firstNonEmpty( + options.serviceToken, + env.BASE44_SERVICE_TOKEN, + parseBearerToken(headers.get("Base44-Service-Authorization")) + ); + + const functionsVersion = firstNonEmpty( + options.functionsVersion, + env.BASE44_FUNCTIONS_VERSION, + headers.get("Base44-Functions-Version") + ); + + // Propagate Base44-State like createClientFromRequest, so the server client + // degrades gracefully behind the existing Base44 proxy + const stateHeader = headers.get("Base44-State"); + const additionalHeaders: Record = {}; + if (stateHeader) { + additionalHeaders["Base44-State"] = stateHeader; + } + + return createClient({ + serverUrl, + appId, + token, + serviceToken, + functionsVersion, + headers: additionalHeaders, + requiresAuth: false, + disableAnalytics: true, + adapter: "fetch", + }); +} diff --git a/src/utils/auth-utils.ts b/src/utils/auth-utils.ts index ff82c4c..fc08419 100644 --- a/src/utils/auth-utils.ts +++ b/src/utils/auth-utils.ts @@ -5,6 +5,86 @@ import { GetLoginUrlOptions, } from "./auth-utils.types.js"; +const ACCESS_TOKEN_COOKIE_ATTRIBUTES = "path=/; SameSite=Lax"; + +/** + * Builds the cookie string that mirrors the access token so subsequent + * document requests carry it to server-side rendering (SSR) code. + * + * @internal + */ +export function buildAccessTokenCookie( + token: string, + { + name = "base44_access_token", + secure = false, + }: { name?: string; secure?: boolean } = {} +) { + return `${name}=${encodeURIComponent( + token + )}; ${ACCESS_TOKEN_COOKIE_ATTRIBUTES}${secure ? "; Secure" : ""}`; +} + +/** + * Builds the cookie string that clears the mirrored access token cookie. + * + * @internal + */ +export function buildClearAccessTokenCookie({ + name = "base44_access_token", + secure = false, +}: { name?: string; secure?: boolean } = {}) { + return `${name}=; ${ACCESS_TOKEN_COOKIE_ATTRIBUTES}; Max-Age=0${ + secure ? "; Secure" : "" + }`; +} + +function isHttpsPage() { + return ( + typeof window !== "undefined" && window.location?.protocol === "https:" + ); +} + +/** + * Mirrors the access token into a cookie (in addition to localStorage) so + * subsequent document requests carry the token to SSR. No-op outside a + * browser environment. + * + * @internal + */ +export function setAccessTokenCookie(token: string, name?: string) { + if (typeof document === "undefined" || !token) { + return; + } + try { + document.cookie = buildAccessTokenCookie(token, { + name, + secure: isHttpsPage(), + }); + } catch (e) { + console.error("Error saving token cookie:", e); + } +} + +/** + * Clears the mirrored access token cookie. No-op outside a browser environment. + * + * @internal + */ +export function clearAccessTokenCookie(name?: string) { + if (typeof document === "undefined") { + return; + } + try { + document.cookie = buildClearAccessTokenCookie({ + name, + secure: isHttpsPage(), + }); + } catch (e) { + console.error("Error removing token cookie:", e); + } +} + /** * Retrieves an access token from URL parameters or local storage. * @@ -137,6 +217,8 @@ export function saveAccessToken( window.localStorage.setItem(storageKey, token); // Set "token" that is set by the built-in SDK of platform version 2 window.localStorage.setItem("token", token); + // Mirror the token into a cookie so document requests carry it to SSR + setAccessTokenCookie(token, storageKey); return true; } catch (e) { console.error("Error saving token to local storage:", e); @@ -177,6 +259,8 @@ export function removeAccessToken(options: RemoveAccessTokenOptions) { try { window.localStorage.removeItem(storageKey); + // Clear the cookie that mirrors the token for SSR + clearAccessTokenCookie(storageKey); return true; } catch (e) { console.error("Error removing token from local storage:", e); diff --git a/src/utils/axios-client.ts b/src/utils/axios-client.ts index 3c9e9d1..f4a2bd5 100644 --- a/src/utils/axios-client.ts +++ b/src/utils/axios-client.ts @@ -1,4 +1,4 @@ -import axios from "axios"; +import axios, { AxiosRequestConfig } from "axios"; import { isInIFrame } from "./common.js"; import { v4 as uuidv4 } from "uuid"; import { getAnalyticsSessionId } from "../modules/analytics.js"; @@ -152,15 +152,18 @@ export function createAxiosClient({ token, interceptResponses = true, onError, + adapter, }: { baseURL: string; headers?: Record; token?: string; interceptResponses?: boolean; onError?: (error: Error) => void; + adapter?: AxiosRequestConfig["adapter"]; }) { const client = axios.create({ baseURL, + ...(adapter !== undefined ? { adapter } : {}), headers: { "Content-Type": "application/json", Accept: "application/json", diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 4c0f647..b105a3d 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -14,12 +14,40 @@ describe("Analytics Module", () => { let sharedState: null | { requestsQueue: TrackEventData[]; isProcessing: boolean; + isHeartBeatProcessing: boolean; + wasInitializationTracked: boolean; sessionContext: SessionContext; config: AnalyticsModuleOptions; }; const appId = "test-app-id"; const serverUrl = "https://api.base44.com"; + const stubBrowserGlobals = () => { + // Analytics is browser-only, so simulate the browser globals it relies on + vi.stubGlobal("window", { + location: { + search: "", + pathname: "/", + href: "https://app.example.com/", + protocol: "https:", + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + localStorage: { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + }, + history: { replaceState: vi.fn() }, + }); + vi.stubGlobal("document", { + referrer: "", + title: "", + visibilityState: "visible", + cookie: "", + }); + }; + beforeEach(() => { vi.mock("../../src/utils/axios-client.ts", () => ({ createAxiosClient: vi.fn().mockImplementation( @@ -34,13 +62,19 @@ describe("Analytics Module", () => { } as unknown as AxiosInstance) ), })); + stubBrowserGlobals(); sharedState = getSharedInstance("analytics", () => ({ requestsQueue: [], isProcessing: false, + isHeartBeatProcessing: false, + wasInitializationTracked: false, sessionContext: {}, config: {}, })); sharedState.isProcessing = false; + sharedState.isHeartBeatProcessing = false; + // suppress the one-time initialization event so tests stay deterministic + sharedState.wasInitializationTracked = true; sharedState.requestsQueue = []; sharedState.sessionContext = { user_id: "test-user-id", @@ -62,6 +96,8 @@ describe("Analytics Module", () => { afterEach(() => { vi.clearAllMocks(); base44.cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); sharedState = null; }); @@ -103,4 +139,58 @@ describe("Analytics Module", () => { await vi.advanceTimersByTimeAsync(1000); expect(sharedState?.isProcessing).toBe(false); }); + + test("should start the heartbeat interval in a browser when configured", () => { + sharedState!.config.heartBeatInterval = 60 * 1000; + vi.useFakeTimers(); + + const client = createClient({ serverUrl, appId }); + expect(vi.getTimerCount()).toBe(1); + expect(sharedState?.isHeartBeatProcessing).toBe(true); + + client.cleanup(); + expect(vi.getTimerCount()).toBe(0); + expect(sharedState?.isHeartBeatProcessing).toBe(false); + }); + + test("should become a no-op when disableAnalytics is set", () => { + sharedState!.config.heartBeatInterval = 60 * 1000; + vi.useFakeTimers(); + + const client = createClient({ serverUrl, appId, disableAnalytics: true }); + + expect(vi.getTimerCount()).toBe(0); + client.analytics.track({ eventName: "ignored-event" }); + expect(sharedState?.requestsQueue.length).toBe(0); + expect(sharedState?.isProcessing).toBe(false); + + client.cleanup(); + }); +}); + +describe("Analytics Module without a browser environment", () => { + const appId = "test-app-id"; + const serverUrl = "https://api.base44.com"; + + test("should be a no-op and start no timers (worker/SSR safety)", () => { + // node test environment: no window global, like Cloudflare Workers SSR + expect(typeof window).toBe("undefined"); + vi.useFakeTimers(); + + const client = createClient({ serverUrl, appId }); + const sharedState = getSharedInstance("analytics", () => ({ + requestsQueue: [] as TrackEventData[], + isProcessing: false, + })); + sharedState.requestsQueue.splice(0); + + expect(vi.getTimerCount()).toBe(0); + expect(() => client.analytics.track({ eventName: "test-event" })).not.toThrow(); + expect(sharedState.requestsQueue.length).toBe(0); + expect(sharedState.isProcessing).toBe(false); + expect(vi.getTimerCount()).toBe(0); + + client.cleanup(); + vi.useRealTimers(); + }); }); diff --git a/tests/unit/auth-utils-cookie.test.ts b/tests/unit/auth-utils-cookie.test.ts new file mode 100644 index 0000000..2813796 --- /dev/null +++ b/tests/unit/auth-utils-cookie.test.ts @@ -0,0 +1,107 @@ +import { describe, test, expect, afterEach, vi } from "vitest"; +import { + buildAccessTokenCookie, + buildClearAccessTokenCookie, + setAccessTokenCookie, + clearAccessTokenCookie, +} from "../../src/utils/auth-utils.ts"; +import { saveAccessToken, removeAccessToken } from "../../src/index.ts"; + +describe("access token cookie builders", () => { + test("builds the cookie string with path and SameSite attributes", () => { + expect(buildAccessTokenCookie("my-token")).toBe( + "base44_access_token=my-token; path=/; SameSite=Lax" + ); + }); + + test("adds the Secure attribute when requested", () => { + expect(buildAccessTokenCookie("my-token", { secure: true })).toBe( + "base44_access_token=my-token; path=/; SameSite=Lax; Secure" + ); + }); + + test("URL-encodes the token value", () => { + expect(buildAccessTokenCookie("a token;=value")).toBe( + "base44_access_token=a%20token%3B%3Dvalue; path=/; SameSite=Lax" + ); + }); + + test("supports a custom cookie name", () => { + expect(buildAccessTokenCookie("my-token", { name: "custom_key" })).toBe( + "custom_key=my-token; path=/; SameSite=Lax" + ); + }); + + test("builds the clearing cookie string with Max-Age=0", () => { + expect(buildClearAccessTokenCookie()).toBe( + "base44_access_token=; path=/; SameSite=Lax; Max-Age=0" + ); + expect(buildClearAccessTokenCookie({ secure: true })).toBe( + "base44_access_token=; path=/; SameSite=Lax; Max-Age=0; Secure" + ); + }); +}); + +describe("access token cookie mirror in a simulated browser", () => { + const stubBrowser = (protocol: string) => { + const doc = { cookie: "" }; + const storage = new Map(); + vi.stubGlobal("document", doc); + vi.stubGlobal("window", { + location: { protocol }, + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + }); + return { doc, storage }; + }; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("saveAccessToken mirrors the token into a cookie alongside localStorage", () => { + const { doc, storage } = stubBrowser("https:"); + + expect(saveAccessToken("my-token", {})).toBe(true); + + expect(storage.get("base44_access_token")).toBe("my-token"); + expect(storage.get("token")).toBe("my-token"); + expect(doc.cookie).toBe( + "base44_access_token=my-token; path=/; SameSite=Lax; Secure" + ); + }); + + test("saveAccessToken omits Secure on http pages", () => { + const { doc } = stubBrowser("http:"); + + saveAccessToken("my-token", {}); + + expect(doc.cookie).toBe( + "base44_access_token=my-token; path=/; SameSite=Lax" + ); + }); + + test("removeAccessToken clears the cookie and localStorage", () => { + const { doc, storage } = stubBrowser("https:"); + saveAccessToken("my-token", {}); + + expect(removeAccessToken({})).toBe(true); + + expect(storage.has("base44_access_token")).toBe(false); + expect(doc.cookie).toBe( + "base44_access_token=; path=/; SameSite=Lax; Max-Age=0; Secure" + ); + }); + + test("setAccessTokenCookie and clearAccessTokenCookie are no-ops without a document", () => { + // node test environment: no document global + expect(typeof document).toBe("undefined"); + expect(() => setAccessTokenCookie("my-token")).not.toThrow(); + expect(() => clearAccessTokenCookie()).not.toThrow(); + expect(saveAccessToken("my-token", {})).toBe(false); + expect(removeAccessToken({})).toBe(false); + }); +}); diff --git a/tests/unit/server-client.test.ts b/tests/unit/server-client.test.ts new file mode 100644 index 0000000..8ca5ad9 --- /dev/null +++ b/tests/unit/server-client.test.ts @@ -0,0 +1,243 @@ +import { describe, test, expect, beforeEach, vi } from "vitest"; +import { createServerClient } from "../../src/index.ts"; +import { createAxiosClient } from "../../src/utils/axios-client.ts"; + +vi.mock("../../src/utils/axios-client.ts", () => ({ + createAxiosClient: vi.fn(() => ({ request: vi.fn() })), +})); + +// createClient builds four axios clients, in this order +const MAIN_CLIENT_CALL = 0; +const FUNCTIONS_CLIENT_CALL = 1; +const SERVICE_ROLE_CLIENT_CALL = 2; + +const axiosClientArgs = (callIndex: number) => + vi.mocked(createAxiosClient).mock.calls[callIndex][0]; + +const makeRequest = (headers: Record = {}) => + new Request("https://my-app.example.com/some/page", { headers }); + +describe("createServerClient", () => { + beforeEach(() => { + vi.mocked(createAxiosClient).mockClear(); + }); + + test("resolves config from explicit options over env and request headers", () => { + const request = makeRequest({ + Authorization: "Bearer header-token", + "Base44-Service-Authorization": "Bearer header-service-token", + "Base44-App-Id": "header-app-id", + "Base44-Api-Url": "https://header.example.com", + "Base44-Functions-Version": "header-functions-version", + Cookie: "base44_access_token=cookie-token", + }); + + const client = createServerClient({ + request, + env: { + BASE44_APP_ID: "env-app-id", + BASE44_API_URL: "https://env.example.com", + BASE44_SERVICE_TOKEN: "env-service-token", + BASE44_FUNCTIONS_VERSION: "env-functions-version", + }, + appId: "option-app-id", + serverUrl: "https://option.example.com", + token: "option-token", + serviceToken: "option-service-token", + functionsVersion: "option-functions-version", + }); + + expect(client.getConfig()).toEqual({ + serverUrl: "https://option.example.com", + appId: "option-app-id", + requiresAuth: false, + }); + expect(axiosClientArgs(MAIN_CLIENT_CALL)).toMatchObject({ + baseURL: "https://option.example.com/api", + token: "option-token", + }); + expect(axiosClientArgs(FUNCTIONS_CLIENT_CALL).headers).toMatchObject({ + "Base44-Functions-Version": "option-functions-version", + }); + expect(axiosClientArgs(SERVICE_ROLE_CLIENT_CALL).token).toBe( + "option-service-token" + ); + }); + + test("resolves config from env over request headers", () => { + const request = makeRequest({ + "Base44-Service-Authorization": "Bearer header-service-token", + "Base44-App-Id": "header-app-id", + "Base44-Api-Url": "https://header.example.com", + "Base44-Functions-Version": "header-functions-version", + }); + + const client = createServerClient({ + request, + env: { + BASE44_APP_ID: "env-app-id", + BASE44_API_URL: "https://env.example.com", + BASE44_SERVICE_TOKEN: "env-service-token", + BASE44_FUNCTIONS_VERSION: "env-functions-version", + }, + }); + + expect(client.getConfig()).toMatchObject({ + serverUrl: "https://env.example.com", + appId: "env-app-id", + }); + expect(axiosClientArgs(FUNCTIONS_CLIENT_CALL).headers).toMatchObject({ + "Base44-Functions-Version": "env-functions-version", + }); + expect(axiosClientArgs(SERVICE_ROLE_CLIENT_CALL).token).toBe( + "env-service-token" + ); + }); + + test("falls back to the Base44-* request headers (existing proxy contract)", () => { + const request = makeRequest({ + Authorization: "Bearer header-token", + "Base44-Service-Authorization": "Bearer header-service-token", + "Base44-App-Id": "header-app-id", + "Base44-Api-Url": "https://header.example.com", + "Base44-Functions-Version": "header-functions-version", + }); + + const client = createServerClient({ request }); + + expect(client.getConfig()).toMatchObject({ + serverUrl: "https://header.example.com", + appId: "header-app-id", + }); + expect(axiosClientArgs(MAIN_CLIENT_CALL).token).toBe("header-token"); + expect(axiosClientArgs(FUNCTIONS_CLIENT_CALL).headers).toMatchObject({ + "Base44-Functions-Version": "header-functions-version", + }); + expect(axiosClientArgs(SERVICE_ROLE_CLIENT_CALL).token).toBe( + "header-service-token" + ); + }); + + test("defaults serverUrl to https://base44.app", () => { + const client = createServerClient({ + request: makeRequest(), + appId: "my-app-id", + }); + + expect(client.getConfig()).toMatchObject({ + serverUrl: "https://base44.app", + appId: "my-app-id", + }); + }); + + test("prefers the Authorization header over the cookie for the user token", () => { + createServerClient({ + request: makeRequest({ + Authorization: "Bearer header-token", + Cookie: "base44_access_token=cookie-token", + }), + appId: "my-app-id", + }); + + expect(axiosClientArgs(MAIN_CLIENT_CALL).token).toBe("header-token"); + }); + + test("falls back to the base44_access_token cookie for the user token", () => { + createServerClient({ + request: makeRequest({ + Cookie: "other=1; base44_access_token=cookie-token; another=2", + }), + appId: "my-app-id", + }); + + expect(axiosClientArgs(MAIN_CLIENT_CALL).token).toBe("cookie-token"); + }); + + test("parses quoted and URL-encoded cookie values", () => { + createServerClient({ + request: makeRequest({ + Cookie: 'base44_access_token="cookie%20token%3Dvalue"', + }), + appId: "my-app-id", + }); + + expect(axiosClientArgs(MAIN_CLIENT_CALL).token).toBe("cookie token=value"); + }); + + test("ignores malformed Authorization headers and cookies without the token", () => { + createServerClient({ + request: makeRequest({ + Authorization: "NotBearerFormat", + Cookie: "unrelated=value", + }), + appId: "my-app-id", + }); + + expect(axiosClientArgs(MAIN_CLIENT_CALL).token).toBeUndefined(); + }); + + test("throws a clear error when no app ID can be resolved", () => { + expect(() => createServerClient({ request: makeRequest() })).toThrow( + "createServerClient: unable to resolve an app ID. Pass appId explicitly, set the BASE44_APP_ID environment variable, or forward the Base44-App-Id request header." + ); + }); + + test("throws when the resolved serverUrl is not an absolute URL", () => { + expect(() => + createServerClient({ + request: makeRequest(), + appId: "my-app-id", + serverUrl: "/relative/path", + }) + ).toThrow('createServerClient: serverUrl must be an absolute URL, got "/relative/path"'); + + expect(() => + createServerClient({ + request: makeRequest(), + appId: "my-app-id", + env: { BASE44_API_URL: "not-a-url" }, + }) + ).toThrow("createServerClient: serverUrl must be an absolute URL"); + }); + + test("forces the axios fetch adapter on all clients", () => { + createServerClient({ request: makeRequest(), appId: "my-app-id" }); + + const calls = vi.mocked(createAxiosClient).mock.calls; + expect(calls.length).toBe(4); + for (const [args] of calls) { + expect(args.adapter).toBe("fetch"); + } + }); + + test("propagates the Base44-State header like createClientFromRequest", () => { + createServerClient({ + request: makeRequest({ "Base44-State": "192.168.1.100" }), + appId: "my-app-id", + }); + + expect(axiosClientArgs(MAIN_CLIENT_CALL).headers).toMatchObject({ + "Base44-State": "192.168.1.100", + }); + }); + + test("does not start analytics timers or queue events (worker safety)", () => { + // node test environment: no window global, like a Cloudflare Worker + expect(typeof window).toBe("undefined"); + vi.useFakeTimers(); + + const client = createServerClient({ + request: makeRequest(), + appId: "my-app-id", + }); + + expect(vi.getTimerCount()).toBe(0); + expect(() => + client.analytics.track({ eventName: "server-event" }) + ).not.toThrow(); + expect(vi.getTimerCount()).toBe(0); + + client.cleanup(); + vi.useRealTimers(); + }); +});