diff --git a/src/client.ts b/src/client.ts index 5a8038b..e0a24ce 100644 --- a/src/client.ts +++ b/src/client.ts @@ -151,7 +151,18 @@ export function createClient(config: CreateClientConfig): Base44Client { }), integrations: createIntegrationsModule(axiosClient, appId), auth: userAuthModule, - functions: createFunctionsModule(functionsAxiosClient, appId), + functions: createFunctionsModule(functionsAxiosClient, appId, { + getAuthHeaders: () => { + const headers: Record = {}; + // Get current token from storage or initial config + const currentToken = token || getAccessToken(); + if (currentToken) { + headers["Authorization"] = `Bearer ${currentToken}`; + } + return headers; + }, + baseURL: functionsAxiosClient.defaults?.baseURL, + }), agents: createAgentsModule({ axios: axiosClient, getSocket, @@ -184,7 +195,17 @@ 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 = {}; + // Use service token for authorization + if (serviceToken) { + headers["Authorization"] = `Bearer ${serviceToken}`; + } + return headers; + }, + baseURL: serviceRoleFunctionsAxiosClient.defaults?.baseURL, + }), agents: createAgentsModule({ axios: serviceRoleAxiosClient, getSocket, diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 296465a..1a72c1d 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -1,18 +1,47 @@ import { AxiosInstance } from "axios"; -import { FunctionsModule } from "./functions.types"; +import { FunctionsFetchInit, FunctionsModule, FunctionsModuleConfig } from "./functions.types"; /** * 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; + return `${String(base).replace(/\/$/, "")}${path}`; + }; + + const toHeaders = (inputHeaders?: HeadersInit): Headers => { + const headers = new Headers(); + + // 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)); + } + }); + } + + 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 +82,25 @@ export function createFunctionsModule( { headers: { "Content-Type": contentType } } ); }, + + // Fetch a backend function endpoint directly. + async fetch(path: string, init: FunctionsFetchInit = {}) { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const primaryPath = `/functions${normalizedPath}`; + + const headers = toHeaders(init.headers); + + const requestInit: RequestInit = { + ...init, + headers, + }; + + const response = await fetch( + joinBaseUrl(config?.baseURL, primaryPath), + requestInit + ); + + return response; + }, }; } diff --git a/src/modules/functions.types.ts b/src/modules/functions.types.ts index 68fec06..5e3d776 100644 --- a/src/modules/functions.types.ts +++ b/src/modules/functions.types.ts @@ -17,6 +17,22 @@ export type FunctionName = keyof FunctionNameRegistry extends never ? string : keyof FunctionNameRegistry; +/** + * Options for {@linkcode FunctionsModule.fetch}. + * + * Uses native `fetch` options directly. + */ +export type FunctionsFetchInit = RequestInit; + +/** + * Configuration for the functions module. + * @internal + */ +export interface FunctionsModuleConfig { + getAuthHeaders?: () => Record; + baseURL?: string; +} + /** * Functions module for invoking custom backend functions. * @@ -71,4 +87,23 @@ 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 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/`. + * + * @param path - Function path, e.g. `/streaming_demo` or `/streaming_demo/deep/path` + * @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 f886d30..9a55379 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,74 @@ describe("Functions Module", () => { // Verify all mocks were called expect(scope.isDone()).toBe(true); }); + + test("should fetch function endpoint directly", async () => { + fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + + await base44.functions.fetch("/my_function", { + method: "GET", + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${serverUrl}/api/functions/my_function`, + expect.any(Object) + ); + }); + + + 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", + body: JSON.stringify({ mode: "text" }), + }); + + const requestInit = fetchMock.mock.calls[0][1]; + 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}`); + }); });