Skip to content
Merged
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
25 changes: 23 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
// 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,
Expand Down Expand Up @@ -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<string, string> = {};
// Use service token for authorization
if (serviceToken) {
headers["Authorization"] = `Bearer ${serviceToken}`;
}
return headers;
},
baseURL: serviceRoleFunctionsAxiosClient.defaults?.baseURL,
}),
agents: createAgentsModule({
axios: serviceRoleAxiosClient,
getSocket,
Expand Down
53 changes: 51 additions & 2 deletions src/modules/functions.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>) {
Expand Down Expand Up @@ -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;
},
};
}
35 changes: 35 additions & 0 deletions src/modules/functions.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
baseURL?: string;
}

/**
* Functions module for invoking custom backend functions.
*
Expand Down Expand Up @@ -71,4 +87,23 @@ export interface FunctionsModule {
* ```
*/
invoke(functionName: FunctionName, data?: Record<string, any>): Promise<any>;

/**
* 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/<path>`.
*
* @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<Response>;
}
78 changes: 77 additions & 1 deletion tests/unit/functions.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -14,6 +14,7 @@ declare module "../../src/modules/functions.types.ts" {
describe("Functions Module", () => {
let base44: ReturnType<typeof createClient>;
let scope;
let fetchMock: ReturnType<typeof vi.fn>;
const appId = "test-app-id";
const serverUrl = "https://api.base44.com";

Expand All @@ -33,13 +34,18 @@ 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(() => {
// Clean up any pending mocks
nock.cleanAll();
nock.emitter.removeAllListeners("no match");
nock.enableNetConnect();
vi.unstubAllGlobals();
vi.clearAllMocks();
});

test("should call a function with JSON data", async () => {
Expand Down Expand Up @@ -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}`);
});
});
Loading