From 8c1ac2a3383effd4555301bba5fdff055293715e Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Thu, 12 Mar 2026 11:09:59 +0200 Subject: [PATCH 01/10] feat(functions): add streaming-friendly functions.fetch API Add a native fetch-based functions.fetch(path, init) method for streaming use cases where invoke() buffers responses. Include JSON data convenience, endpoint fallback compatibility, and unit tests for routing and auth headers. Made-with: Cursor --- src/modules/functions.ts | 90 +++++++++++++++++++++++++++++++++- src/modules/functions.types.ts | 26 ++++++++++ tests/unit/functions.test.ts | 67 ++++++++++++++++++++++++- 3 files changed, 181 insertions(+), 2 deletions(-) diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 296465a..49e7786 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -1,5 +1,5 @@ import { AxiosInstance } from "axios"; -import { FunctionsModule } from "./functions.types"; +import { FunctionsFetchInit, FunctionsModule } from "./functions.types"; /** * Creates the functions module for the Base44 SDK. @@ -13,6 +13,44 @@ export function createFunctionsModule( axios: AxiosInstance, appId: string ): FunctionsModule { + const joinBaseUrl = (base: string | undefined, path: string) => { + if (!base) return path; + return `${String(base).replace(/\/$/, "")}${path}`; + }; + + const isBodyInit = (value: unknown): boolean => + value instanceof FormData || + value instanceof Blob || + value instanceof URLSearchParams || + value instanceof ReadableStream || + value instanceof ArrayBuffer || + ArrayBuffer.isView(value); + + const toHeaders = (inputHeaders?: HeadersInit): Headers => { + const headers = new Headers(); + + const appendHeaders = (source?: Record) => { + if (!source) return; + Object.entries(source).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + headers.set(key, String(value)); + } + }); + }; + + // Axios keeps defaults in method-specific buckets. + appendHeaders(axios.defaults.headers?.common as Record); + appendHeaders(axios.defaults.headers?.post as Record); + + if (inputHeaders) { + new Headers(inputHeaders).forEach((value, key) => { + headers.set(key, value); + }); + } + + return headers; + }; + return { // Invoke a custom backend function by name async invoke(functionName: string, data: Record) { @@ -53,5 +91,55 @@ export function createFunctionsModule( { headers: { "Content-Type": contentType } } ); }, + + // Fetch a backend function endpoint directly (supports streaming). + async fetch(path: string, init: FunctionsFetchInit = {}) { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const primaryPath = `/functions${normalizedPath}`; + const fallbackPath = `/apps/${appId}/functions${normalizedPath}`; + const { data, ...fetchInit } = init; + + const headers = toHeaders(fetchInit.headers); + if (!headers.has("X-App-Id")) { + headers.set("X-App-Id", appId); + } + let body: BodyInit | null | undefined = fetchInit.body; + + if (body === undefined && data !== undefined) { + if (data === null) { + body = null; + } else if ( + typeof data === "string" || + isBodyInit(data) + ) { + body = data as BodyInit; + } else { + body = JSON.stringify(data); + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + } + } + + const requestInit: RequestInit = { + ...fetchInit, + headers, + body, + }; + + let response = await fetch( + joinBaseUrl(axios.defaults.baseURL, primaryPath), + requestInit + ); + + if (response.status === 404) { + response = await fetch( + joinBaseUrl(axios.defaults.baseURL, fallbackPath), + requestInit + ); + } + + return response; + }, }; } diff --git a/src/modules/functions.types.ts b/src/modules/functions.types.ts index 68fec06..b4fc135 100644 --- a/src/modules/functions.types.ts +++ b/src/modules/functions.types.ts @@ -17,6 +17,17 @@ export type FunctionName = keyof FunctionNameRegistry extends never ? string : keyof FunctionNameRegistry; +/** + * Options for {@linkcode FunctionsModule.fetch}. + * + * Extends native `fetch` options with a `data` convenience property that + * is JSON-stringified when `body` is not provided. + */ +export type FunctionsFetchInit = RequestInit & { + /** Convenience payload for JSON requests when `body` is omitted. */ + data?: unknown; +}; + /** * Functions module for invoking custom backend functions. * @@ -71,4 +82,19 @@ export interface FunctionsModule { * ``` */ invoke(functionName: FunctionName, data?: Record): Promise; + + /** + * Performs a direct HTTP request to a backend function path and returns the native `Response`. + * + * Use this when you need streaming behavior (SSE, chunked text, NDJSON), + * because `invoke()` buffers the full response. + * + * Requests are sent to `/api/functions/`, with automatic fallback to + * `/api/apps//functions/` on `404` for compatibility. + * + * @param path - Function path, e.g. `/streaming_demo` or `/streaming_demo/deep/path` + * @param init - Native fetch options, plus optional `data` for JSON payloads. + * @returns Promise resolving to a native fetch `Response` + */ + fetch(path: string, init?: FunctionsFetchInit): Promise; } diff --git a/tests/unit/functions.test.ts b/tests/unit/functions.test.ts index f886d30..62e17d1 100644 --- a/tests/unit/functions.test.ts +++ b/tests/unit/functions.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import nock from "nock"; import { createClient } from "../../src/index.ts"; @@ -14,6 +14,7 @@ declare module "../../src/modules/functions.types.ts" { describe("Functions Module", () => { let base44: ReturnType; let scope; + let fetchMock: ReturnType; const appId = "test-app-id"; const serverUrl = "https://api.base44.com"; @@ -33,6 +34,9 @@ describe("Functions Module", () => { console.log(`Nock: No match for ${req.method} ${req.path}`); console.log("Headers:", req.getHeaders()); }); + + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { @@ -40,6 +44,8 @@ describe("Functions Module", () => { nock.cleanAll(); nock.emitter.removeAllListeners("no match"); nock.enableNetConnect(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); }); test("should call a function with JSON data", async () => { @@ -452,4 +458,63 @@ describe("Functions Module", () => { // Verify all mocks were called expect(scope.isDone()).toBe(true); }); + + test("should fetch function endpoint directly for streaming", async () => { + fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + + await base44.functions.fetch("/streaming_demo", { + method: "POST", + data: { mode: "sse" }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${serverUrl}/api/functions/streaming_demo`, + expect.any(Object) + ); + + const requestInit = fetchMock.mock.calls[0][1]; + const headers = new Headers(requestInit.headers); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.get("X-App-Id")).toBe(appId); + expect(requestInit.body).toBe(JSON.stringify({ mode: "sse" })); + }); + + test("should fallback to app-scoped functions endpoint on 404", async () => { + fetchMock + .mockResolvedValueOnce(new Response("Not found", { status: 404 })) + .mockResolvedValueOnce(new Response("ok", { status: 200 })); + + await base44.functions.fetch("/streaming_demo/deep/path", { + method: "POST", + data: { mode: "ndjson" }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][0]).toBe( + `${serverUrl}/api/functions/streaming_demo/deep/path` + ); + expect(fetchMock.mock.calls[1][0]).toBe( + `${serverUrl}/api/apps/${appId}/functions/streaming_demo/deep/path` + ); + }); + + test("should include Authorization header when using functions.fetch", async () => { + const userToken = "user-streaming-token"; + const authenticatedBase44 = createClient({ + serverUrl, + appId, + token: userToken, + }); + fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + + await authenticatedBase44.functions.fetch("streaming_demo", { + method: "POST", + data: { mode: "text" }, + }); + + const requestInit = fetchMock.mock.calls[0][1]; + const headers = new Headers(requestInit.headers); + expect(headers.get("Authorization")).toBe(`Bearer ${userToken}`); + }); }); From ab9d10d0fc49d468c82dfcfcaa178f351c76ab49 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:07:24 +0000 Subject: [PATCH 02/10] refactor(functions): simplify fetch API per review comments - Remove fallback path logic - only use /functions endpoint - Remove X-App-Id header - not needed - Remove automatic content-type handling - let fetch handle it natively - Update tests to reflect the simplified behavior Co-authored-by: Netanel Gilad --- src/modules/functions.ts | 16 +--------------- tests/unit/functions.test.ts | 16 +++++----------- 2 files changed, 6 insertions(+), 26 deletions(-) diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 49e7786..a11bd28 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -96,13 +96,9 @@ export function createFunctionsModule( async fetch(path: string, init: FunctionsFetchInit = {}) { const normalizedPath = path.startsWith("/") ? path : `/${path}`; const primaryPath = `/functions${normalizedPath}`; - const fallbackPath = `/apps/${appId}/functions${normalizedPath}`; const { data, ...fetchInit } = init; const headers = toHeaders(fetchInit.headers); - if (!headers.has("X-App-Id")) { - headers.set("X-App-Id", appId); - } let body: BodyInit | null | undefined = fetchInit.body; if (body === undefined && data !== undefined) { @@ -115,9 +111,6 @@ export function createFunctionsModule( body = data as BodyInit; } else { body = JSON.stringify(data); - if (!headers.has("Content-Type")) { - headers.set("Content-Type", "application/json"); - } } } @@ -127,18 +120,11 @@ export function createFunctionsModule( body, }; - let response = await fetch( + const response = await fetch( joinBaseUrl(axios.defaults.baseURL, primaryPath), requestInit ); - if (response.status === 404) { - response = await fetch( - joinBaseUrl(axios.defaults.baseURL, fallbackPath), - requestInit - ); - } - return response; }, }; diff --git a/tests/unit/functions.test.ts b/tests/unit/functions.test.ts index 62e17d1..cb77f4e 100644 --- a/tests/unit/functions.test.ts +++ b/tests/unit/functions.test.ts @@ -474,29 +474,23 @@ describe("Functions Module", () => { ); const requestInit = fetchMock.mock.calls[0][1]; - const headers = new Headers(requestInit.headers); - expect(headers.get("Content-Type")).toBe("application/json"); - expect(headers.get("X-App-Id")).toBe(appId); expect(requestInit.body).toBe(JSON.stringify({ mode: "sse" })); }); - test("should fallback to app-scoped functions endpoint on 404", async () => { + test("should not fallback to app-scoped functions endpoint on 404", async () => { fetchMock - .mockResolvedValueOnce(new Response("Not found", { status: 404 })) - .mockResolvedValueOnce(new Response("ok", { status: 200 })); + .mockResolvedValueOnce(new Response("Not found", { status: 404 })); - await base44.functions.fetch("/streaming_demo/deep/path", { + const response = await base44.functions.fetch("/streaming_demo/deep/path", { method: "POST", data: { mode: "ndjson" }, }); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls[0][0]).toBe( `${serverUrl}/api/functions/streaming_demo/deep/path` ); - expect(fetchMock.mock.calls[1][0]).toBe( - `${serverUrl}/api/apps/${appId}/functions/streaming_demo/deep/path` - ); + expect(response.status).toBe(404); }); test("should include Authorization header when using functions.fetch", async () => { From 9ba8ec9138d306baa32440cb0374345233b07389 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:12:20 +0000 Subject: [PATCH 03/10] refactor(functions): simplify fetch method per review comments - Remove body handling/transformation logic - fetch now acts as a pure wrapper - Only append common headers from axios defaults, not post-specific headers - Remove isBodyInit helper function as it's no longer needed - Update tests to use body instead of data property - Update type definitions to use RequestInit directly Co-authored-by: Netanel Gilad --- src/modules/functions.ts | 31 +++---------------------------- src/modules/functions.types.ts | 13 ++++--------- tests/unit/functions.test.ts | 6 +++--- 3 files changed, 10 insertions(+), 40 deletions(-) diff --git a/src/modules/functions.ts b/src/modules/functions.ts index a11bd28..722da6b 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -18,14 +18,6 @@ export function createFunctionsModule( return `${String(base).replace(/\/$/, "")}${path}`; }; - const isBodyInit = (value: unknown): boolean => - value instanceof FormData || - value instanceof Blob || - value instanceof URLSearchParams || - value instanceof ReadableStream || - value instanceof ArrayBuffer || - ArrayBuffer.isView(value); - const toHeaders = (inputHeaders?: HeadersInit): Headers => { const headers = new Headers(); @@ -38,9 +30,8 @@ export function createFunctionsModule( }); }; - // Axios keeps defaults in method-specific buckets. + // Append common headers from axios defaults appendHeaders(axios.defaults.headers?.common as Record); - appendHeaders(axios.defaults.headers?.post as Record); if (inputHeaders) { new Headers(inputHeaders).forEach((value, key) => { @@ -96,28 +87,12 @@ export function createFunctionsModule( async fetch(path: string, init: FunctionsFetchInit = {}) { const normalizedPath = path.startsWith("/") ? path : `/${path}`; const primaryPath = `/functions${normalizedPath}`; - const { data, ...fetchInit } = init; - - const headers = toHeaders(fetchInit.headers); - let body: BodyInit | null | undefined = fetchInit.body; - if (body === undefined && data !== undefined) { - if (data === null) { - body = null; - } else if ( - typeof data === "string" || - isBodyInit(data) - ) { - body = data as BodyInit; - } else { - body = JSON.stringify(data); - } - } + const headers = toHeaders(init.headers); const requestInit: RequestInit = { - ...fetchInit, + ...init, headers, - body, }; const response = await fetch( diff --git a/src/modules/functions.types.ts b/src/modules/functions.types.ts index b4fc135..c67aa59 100644 --- a/src/modules/functions.types.ts +++ b/src/modules/functions.types.ts @@ -20,13 +20,9 @@ export type FunctionName = keyof FunctionNameRegistry extends never /** * Options for {@linkcode FunctionsModule.fetch}. * - * Extends native `fetch` options with a `data` convenience property that - * is JSON-stringified when `body` is not provided. + * Uses native `fetch` options directly. */ -export type FunctionsFetchInit = RequestInit & { - /** Convenience payload for JSON requests when `body` is omitted. */ - data?: unknown; -}; +export type FunctionsFetchInit = RequestInit; /** * Functions module for invoking custom backend functions. @@ -89,11 +85,10 @@ export interface FunctionsModule { * Use this when you need streaming behavior (SSE, chunked text, NDJSON), * because `invoke()` buffers the full response. * - * Requests are sent to `/api/functions/`, with automatic fallback to - * `/api/apps//functions/` on `404` for compatibility. + * Requests are sent to `/api/functions/`. * * @param path - Function path, e.g. `/streaming_demo` or `/streaming_demo/deep/path` - * @param init - Native fetch options, plus optional `data` for JSON payloads. + * @param init - Native fetch options. * @returns Promise resolving to a native fetch `Response` */ fetch(path: string, init?: FunctionsFetchInit): Promise; diff --git a/tests/unit/functions.test.ts b/tests/unit/functions.test.ts index cb77f4e..e8bc132 100644 --- a/tests/unit/functions.test.ts +++ b/tests/unit/functions.test.ts @@ -464,7 +464,7 @@ describe("Functions Module", () => { await base44.functions.fetch("/streaming_demo", { method: "POST", - data: { mode: "sse" }, + body: JSON.stringify({ mode: "sse" }), }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -483,7 +483,7 @@ describe("Functions Module", () => { const response = await base44.functions.fetch("/streaming_demo/deep/path", { method: "POST", - data: { mode: "ndjson" }, + body: JSON.stringify({ mode: "ndjson" }), }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -504,7 +504,7 @@ describe("Functions Module", () => { await authenticatedBase44.functions.fetch("streaming_demo", { method: "POST", - data: { mode: "text" }, + body: JSON.stringify({ mode: "text" }), }); const requestInit = fetchMock.mock.calls[0][1]; From 36c00fe369f0bbff3d5f50468c854118b5ab337c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:11:32 +0000 Subject: [PATCH 04/10] refactor(functions): remove axios dependency from fetch method - Add FunctionsModuleConfig interface with getAuthHeaders and baseURL options - Pass auth header getter function from client to functions module - Remove direct axios usage in fetch method for better separation of concerns - Maintain backward compatibility by making config optional Co-authored-by: Netanel Gilad --- src/client.ts | 32 ++++++++++++++++++++++++++++++-- src/modules/functions.ts | 23 ++++++++++++++--------- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/client.ts b/src/client.ts index 5a8038b..862dd08 100644 --- a/src/client.ts +++ b/src/client.ts @@ -151,7 +151,21 @@ export function createClient(config: CreateClientConfig): Base44Client { }), integrations: createIntegrationsModule(axiosClient, appId), auth: userAuthModule, - functions: createFunctionsModule(functionsAxiosClient, appId), + functions: createFunctionsModule(functionsAxiosClient, appId, { + getAuthHeaders: () => { + const headers: Record = {}; + const commonHeaders = functionsAxiosClient.defaults?.headers?.common as Record; + if (commonHeaders) { + Object.entries(commonHeaders).forEach(([key, value]) => { + if (typeof value === 'string') { + headers[key] = value; + } + }); + } + return headers; + }, + baseURL: functionsAxiosClient.defaults?.baseURL, + }), agents: createAgentsModule({ axios: axiosClient, getSocket, @@ -184,7 +198,21 @@ export function createClient(config: CreateClientConfig): Base44Client { integrations: createIntegrationsModule(serviceRoleAxiosClient, appId), sso: createSsoModule(serviceRoleAxiosClient, appId, token), connectors: createConnectorsModule(serviceRoleAxiosClient, appId), - functions: createFunctionsModule(serviceRoleFunctionsAxiosClient, appId), + functions: createFunctionsModule(serviceRoleFunctionsAxiosClient, appId, { + getAuthHeaders: () => { + const headers: Record = {}; + const commonHeaders = serviceRoleFunctionsAxiosClient.defaults?.headers?.common as Record; + if (commonHeaders) { + Object.entries(commonHeaders).forEach(([key, value]) => { + if (typeof value === 'string') { + headers[key] = value; + } + }); + } + return headers; + }, + baseURL: serviceRoleFunctionsAxiosClient.defaults?.baseURL, + }), agents: createAgentsModule({ axios: serviceRoleAxiosClient, getSocket, diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 722da6b..9a1399a 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -1,17 +1,24 @@ import { AxiosInstance } from "axios"; import { FunctionsFetchInit, FunctionsModule } from "./functions.types"; +export interface FunctionsModuleConfig { + getAuthHeaders?: () => Record; + baseURL?: string; +} + /** * Creates the functions module for the Base44 SDK. * * @param axios - Axios instance * @param appId - Application ID + * @param config - Optional configuration for fetch functionality * @returns Functions module with methods to invoke custom backend functions * @internal */ export function createFunctionsModule( axios: AxiosInstance, - appId: string + appId: string, + config?: FunctionsModuleConfig ): FunctionsModule { const joinBaseUrl = (base: string | undefined, path: string) => { if (!base) return path; @@ -21,17 +28,15 @@ export function createFunctionsModule( const toHeaders = (inputHeaders?: HeadersInit): Headers => { const headers = new Headers(); - const appendHeaders = (source?: Record) => { - if (!source) return; - Object.entries(source).forEach(([key, value]) => { + // Get auth headers from the getter function if provided + if (config?.getAuthHeaders) { + const authHeaders = config.getAuthHeaders(); + Object.entries(authHeaders).forEach(([key, value]) => { if (value !== undefined && value !== null) { headers.set(key, String(value)); } }); - }; - - // Append common headers from axios defaults - appendHeaders(axios.defaults.headers?.common as Record); + } if (inputHeaders) { new Headers(inputHeaders).forEach((value, key) => { @@ -96,7 +101,7 @@ export function createFunctionsModule( }; const response = await fetch( - joinBaseUrl(axios.defaults.baseURL, primaryPath), + joinBaseUrl(config?.baseURL, primaryPath), requestInit ); From 4a7db0eab4e503729e1349b0ecae5f08b57c61f1 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Mon, 16 Mar 2026 14:12:09 +0200 Subject: [PATCH 05/10] Apply suggestion from @netanelgilad --- src/modules/functions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 9a1399a..e2599bf 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -91,7 +91,7 @@ export function createFunctionsModule( // Fetch a backend function endpoint directly (supports streaming). async fetch(path: string, init: FunctionsFetchInit = {}) { const normalizedPath = path.startsWith("/") ? path : `/${path}`; - const primaryPath = `/functions${normalizedPath}`; + const primaryPath = `/api/functions${normalizedPath}`; const headers = toHeaders(init.headers); From 2c48d30b9dc56b9d6684769ec540320dc95293f5 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Mon, 16 Mar 2026 14:12:16 +0200 Subject: [PATCH 06/10] Apply suggestion from @netanelgilad --- src/modules/functions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/functions.ts b/src/modules/functions.ts index e2599bf..31d60e6 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -88,7 +88,7 @@ export function createFunctionsModule( ); }, - // Fetch a backend function endpoint directly (supports streaming). + // Fetch a backend function endpoint directly. async fetch(path: string, init: FunctionsFetchInit = {}) { const normalizedPath = path.startsWith("/") ? path : `/${path}`; const primaryPath = `/api/functions${normalizedPath}`; From d096d6f9f5f8966b5b555a41cccc4953f4258c47 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:23:02 +0000 Subject: [PATCH 07/10] refactor: compute auth headers independently of axios - Updated getAuthHeaders functions in client.ts to compute Authorization headers directly - User modules now use token from config or getAccessToken() utility - Service role modules use serviceToken directly - Fixed double '/api' prefix in functions.ts since baseURL already contains it This reduces dependency on axios internals in preparation for eventual axios removal. Co-authored-by: Netanel Gilad --- src/client.ts | 21 +++++++-------------- src/modules/functions.ts | 2 +- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/client.ts b/src/client.ts index 862dd08..e0a24ce 100644 --- a/src/client.ts +++ b/src/client.ts @@ -154,13 +154,10 @@ export function createClient(config: CreateClientConfig): Base44Client { functions: createFunctionsModule(functionsAxiosClient, appId, { getAuthHeaders: () => { const headers: Record = {}; - const commonHeaders = functionsAxiosClient.defaults?.headers?.common as Record; - if (commonHeaders) { - Object.entries(commonHeaders).forEach(([key, value]) => { - if (typeof value === 'string') { - headers[key] = value; - } - }); + // Get current token from storage or initial config + const currentToken = token || getAccessToken(); + if (currentToken) { + headers["Authorization"] = `Bearer ${currentToken}`; } return headers; }, @@ -201,13 +198,9 @@ export function createClient(config: CreateClientConfig): Base44Client { functions: createFunctionsModule(serviceRoleFunctionsAxiosClient, appId, { getAuthHeaders: () => { const headers: Record = {}; - const commonHeaders = serviceRoleFunctionsAxiosClient.defaults?.headers?.common as Record; - if (commonHeaders) { - Object.entries(commonHeaders).forEach(([key, value]) => { - if (typeof value === 'string') { - headers[key] = value; - } - }); + // Use service token for authorization + if (serviceToken) { + headers["Authorization"] = `Bearer ${serviceToken}`; } return headers; }, diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 31d60e6..a993f16 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -91,7 +91,7 @@ export function createFunctionsModule( // Fetch a backend function endpoint directly. async fetch(path: string, init: FunctionsFetchInit = {}) { const normalizedPath = path.startsWith("/") ? path : `/${path}`; - const primaryPath = `/api/functions${normalizedPath}`; + const primaryPath = `/functions${normalizedPath}`; const headers = toHeaders(init.headers); From 5862f7323eda89fbdf6ea3c4337cb9b6341170f6 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:35:52 +0000 Subject: [PATCH 08/10] test: address review comments - remove and simplify tests - Remove test for 404 fallback behavior (no longer relevant) - Simplify streaming test to just test basic fetch functionality Co-authored-by: Netanel Gilad --- tests/unit/functions.test.ts | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/tests/unit/functions.test.ts b/tests/unit/functions.test.ts index e8bc132..720fe40 100644 --- a/tests/unit/functions.test.ts +++ b/tests/unit/functions.test.ts @@ -459,39 +459,20 @@ describe("Functions Module", () => { expect(scope.isDone()).toBe(true); }); - test("should fetch function endpoint directly for streaming", async () => { + test("should fetch function endpoint directly", async () => { fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); - await base44.functions.fetch("/streaming_demo", { - method: "POST", - body: JSON.stringify({ mode: "sse" }), + await base44.functions.fetch("/my_function", { + method: "GET", }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( - `${serverUrl}/api/functions/streaming_demo`, + `${serverUrl}/api/functions/my_function`, expect.any(Object) ); - - const requestInit = fetchMock.mock.calls[0][1]; - expect(requestInit.body).toBe(JSON.stringify({ mode: "sse" })); }); - test("should not fallback to app-scoped functions endpoint on 404", async () => { - fetchMock - .mockResolvedValueOnce(new Response("Not found", { status: 404 })); - - const response = await base44.functions.fetch("/streaming_demo/deep/path", { - method: "POST", - body: JSON.stringify({ mode: "ndjson" }), - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0][0]).toBe( - `${serverUrl}/api/functions/streaming_demo/deep/path` - ); - expect(response.status).toBe(404); - }); test("should include Authorization header when using functions.fetch", async () => { const userToken = "user-streaming-token"; From a6b85037d631e6fd03d6027aa8dd91e9078d3970 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:18:21 +0000 Subject: [PATCH 09/10] docs: update functions.fetch JSDoc to be more general Updated the JSDoc comment to describe all use cases for the fetch method, not just streaming. Now mentions custom HTTP methods, headers, raw response access, and direct body control as additional reasons to use fetch over invoke. Co-authored-by: Netanel Gilad --- src/modules/functions.types.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/modules/functions.types.ts b/src/modules/functions.types.ts index c67aa59..60960d8 100644 --- a/src/modules/functions.types.ts +++ b/src/modules/functions.types.ts @@ -82,8 +82,13 @@ export interface FunctionsModule { /** * Performs a direct HTTP request to a backend function path and returns the native `Response`. * - * Use this when you need streaming behavior (SSE, chunked text, NDJSON), - * because `invoke()` buffers the full response. + * Use this method when you need low-level control over the request/response that the higher-level + * `invoke()` abstraction doesn't provide, such as: + * - Streaming responses (SSE, chunked text, NDJSON) + * - Custom HTTP methods (PUT, PATCH, DELETE, etc.) + * - Custom headers or request configuration + * - Access to raw response metadata (status, headers) + * - Direct control over request/response bodies * * Requests are sent to `/api/functions/`. * From 80366cd89e634a3a781584161332ad2b1691e164 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:54:18 +0000 Subject: [PATCH 10/10] refactor: move FunctionsModuleConfig to types file and add missing tests - Moved FunctionsModuleConfig interface from functions.ts to functions.types.ts - Added test for path normalization (with/without leading slash) - Added test for service role auth using asServiceRole.functions.fetch Co-authored-by: Netanel Gilad --- src/modules/functions.ts | 7 +------ src/modules/functions.types.ts | 9 +++++++++ tests/unit/functions.test.ts | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/modules/functions.ts b/src/modules/functions.ts index a993f16..1a72c1d 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -1,10 +1,5 @@ import { AxiosInstance } from "axios"; -import { FunctionsFetchInit, FunctionsModule } from "./functions.types"; - -export interface FunctionsModuleConfig { - getAuthHeaders?: () => Record; - baseURL?: string; -} +import { FunctionsFetchInit, FunctionsModule, FunctionsModuleConfig } from "./functions.types"; /** * Creates the functions module for the Base44 SDK. diff --git a/src/modules/functions.types.ts b/src/modules/functions.types.ts index 60960d8..5e3d776 100644 --- a/src/modules/functions.types.ts +++ b/src/modules/functions.types.ts @@ -24,6 +24,15 @@ export type FunctionName = keyof FunctionNameRegistry extends never */ export type FunctionsFetchInit = RequestInit; +/** + * Configuration for the functions module. + * @internal + */ +export interface FunctionsModuleConfig { + getAuthHeaders?: () => Record; + baseURL?: string; +} + /** * Functions module for invoking custom backend functions. * diff --git a/tests/unit/functions.test.ts b/tests/unit/functions.test.ts index 720fe40..9a55379 100644 --- a/tests/unit/functions.test.ts +++ b/tests/unit/functions.test.ts @@ -492,4 +492,40 @@ describe("Functions Module", () => { const headers = new Headers(requestInit.headers); expect(headers.get("Authorization")).toBe(`Bearer ${userToken}`); }); + + test("should normalize path with and without leading slash", async () => { + // Test with leading slash + fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + await base44.functions.fetch("/my_function"); + expect(fetchMock).toHaveBeenCalledWith( + `${serverUrl}/api/functions/my_function`, + expect.any(Object) + ); + + // Test without leading slash + fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + await base44.functions.fetch("my_function"); + expect(fetchMock).toHaveBeenCalledWith( + `${serverUrl}/api/functions/my_function`, + expect.any(Object) + ); + }); + + test("should include service role Authorization header when using asServiceRole.functions.fetch", async () => { + const serviceToken = "service-role-token"; + const serviceRoleBase44 = createClient({ + serverUrl, + appId, + serviceToken, + }); + fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + + await serviceRoleBase44.asServiceRole.functions.fetch("/service_function", { + method: "GET", + }); + + const requestInit = fetchMock.mock.calls[0][1]; + const headers = new Headers(requestInit.headers); + expect(headers.get("Authorization")).toBe(`Bearer ${serviceToken}`); + }); });