Skip to content

Commit 7743fe8

Browse files
authored
feat(functions): add low-level functions.fetch API (#141)
1 parent db93fd0 commit 7743fe8

4 files changed

Lines changed: 186 additions & 5 deletions

File tree

src/client.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,18 @@ export function createClient(config: CreateClientConfig): Base44Client {
155155
integrations: createIntegrationsModule(axiosClient, appId),
156156
connectors: createUserConnectorsModule(axiosClient, appId),
157157
auth: userAuthModule,
158-
functions: createFunctionsModule(functionsAxiosClient, appId),
158+
functions: createFunctionsModule(functionsAxiosClient, appId, {
159+
getAuthHeaders: () => {
160+
const headers: Record<string, string> = {};
161+
// Get current token from storage or initial config
162+
const currentToken = token || getAccessToken();
163+
if (currentToken) {
164+
headers["Authorization"] = `Bearer ${currentToken}`;
165+
}
166+
return headers;
167+
},
168+
baseURL: functionsAxiosClient.defaults?.baseURL,
169+
}),
159170
agents: createAgentsModule({
160171
axios: axiosClient,
161172
getSocket,
@@ -188,7 +199,17 @@ export function createClient(config: CreateClientConfig): Base44Client {
188199
integrations: createIntegrationsModule(serviceRoleAxiosClient, appId),
189200
sso: createSsoModule(serviceRoleAxiosClient, appId, token),
190201
connectors: createConnectorsModule(serviceRoleAxiosClient, appId),
191-
functions: createFunctionsModule(serviceRoleFunctionsAxiosClient, appId),
202+
functions: createFunctionsModule(serviceRoleFunctionsAxiosClient, appId, {
203+
getAuthHeaders: () => {
204+
const headers: Record<string, string> = {};
205+
// Use service token for authorization
206+
if (serviceToken) {
207+
headers["Authorization"] = `Bearer ${serviceToken}`;
208+
}
209+
return headers;
210+
},
211+
baseURL: serviceRoleFunctionsAxiosClient.defaults?.baseURL,
212+
}),
192213
agents: createAgentsModule({
193214
axios: serviceRoleAxiosClient,
194215
getSocket,

src/modules/functions.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,47 @@
11
import { AxiosInstance } from "axios";
2-
import { FunctionsModule } from "./functions.types";
2+
import { FunctionsFetchInit, FunctionsModule, FunctionsModuleConfig } from "./functions.types";
33

44
/**
55
* Creates the functions module for the Base44 SDK.
66
*
77
* @param axios - Axios instance
88
* @param appId - Application ID
9+
* @param config - Optional configuration for fetch functionality
910
* @returns Functions module with methods to invoke custom backend functions
1011
* @internal
1112
*/
1213
export function createFunctionsModule(
1314
axios: AxiosInstance,
14-
appId: string
15+
appId: string,
16+
config?: FunctionsModuleConfig
1517
): FunctionsModule {
18+
const joinBaseUrl = (base: string | undefined, path: string) => {
19+
if (!base) return path;
20+
return `${String(base).replace(/\/$/, "")}${path}`;
21+
};
22+
23+
const toHeaders = (inputHeaders?: HeadersInit): Headers => {
24+
const headers = new Headers();
25+
26+
// Get auth headers from the getter function if provided
27+
if (config?.getAuthHeaders) {
28+
const authHeaders = config.getAuthHeaders();
29+
Object.entries(authHeaders).forEach(([key, value]) => {
30+
if (value !== undefined && value !== null) {
31+
headers.set(key, String(value));
32+
}
33+
});
34+
}
35+
36+
if (inputHeaders) {
37+
new Headers(inputHeaders).forEach((value, key) => {
38+
headers.set(key, value);
39+
});
40+
}
41+
42+
return headers;
43+
};
44+
1645
return {
1746
// Invoke a custom backend function by name
1847
async invoke(functionName: string, data: Record<string, any>) {
@@ -53,5 +82,25 @@ export function createFunctionsModule(
5382
{ headers: { "Content-Type": contentType } }
5483
);
5584
},
85+
86+
// Fetch a backend function endpoint directly.
87+
async fetch(path: string, init: FunctionsFetchInit = {}) {
88+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
89+
const primaryPath = `/functions${normalizedPath}`;
90+
91+
const headers = toHeaders(init.headers);
92+
93+
const requestInit: RequestInit = {
94+
...init,
95+
headers,
96+
};
97+
98+
const response = await fetch(
99+
joinBaseUrl(config?.baseURL, primaryPath),
100+
requestInit
101+
);
102+
103+
return response;
104+
},
56105
};
57106
}

src/modules/functions.types.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,22 @@ export type FunctionName = keyof FunctionNameRegistry extends never
1717
? string
1818
: keyof FunctionNameRegistry;
1919

20+
/**
21+
* Options for {@linkcode FunctionsModule.fetch}.
22+
*
23+
* Uses native `fetch` options directly.
24+
*/
25+
export type FunctionsFetchInit = RequestInit;
26+
27+
/**
28+
* Configuration for the functions module.
29+
* @internal
30+
*/
31+
export interface FunctionsModuleConfig {
32+
getAuthHeaders?: () => Record<string, string>;
33+
baseURL?: string;
34+
}
35+
2036
/**
2137
* Functions module for invoking custom backend functions.
2238
*
@@ -71,4 +87,23 @@ export interface FunctionsModule {
7187
* ```
7288
*/
7389
invoke(functionName: FunctionName, data?: Record<string, any>): Promise<any>;
90+
91+
/**
92+
* Performs a direct HTTP request to a backend function path and returns the native `Response`.
93+
*
94+
* Use this method when you need low-level control over the request/response that the higher-level
95+
* `invoke()` abstraction doesn't provide, such as:
96+
* - Streaming responses (SSE, chunked text, NDJSON)
97+
* - Custom HTTP methods (PUT, PATCH, DELETE, etc.)
98+
* - Custom headers or request configuration
99+
* - Access to raw response metadata (status, headers)
100+
* - Direct control over request/response bodies
101+
*
102+
* Requests are sent to `/api/functions/<path>`.
103+
*
104+
* @param path - Function path, e.g. `/streaming_demo` or `/streaming_demo/deep/path`
105+
* @param init - Native fetch options.
106+
* @returns Promise resolving to a native fetch `Response`
107+
*/
108+
fetch(path: string, init?: FunctionsFetchInit): Promise<Response>;
74109
}

tests/unit/functions.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, test, expect, beforeEach, afterEach } from "vitest";
1+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
22
import nock from "nock";
33
import { createClient } from "../../src/index.ts";
44

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

@@ -33,13 +34,18 @@ describe("Functions Module", () => {
3334
console.log(`Nock: No match for ${req.method} ${req.path}`);
3435
console.log("Headers:", req.getHeaders());
3536
});
37+
38+
fetchMock = vi.fn();
39+
vi.stubGlobal("fetch", fetchMock);
3640
});
3741

3842
afterEach(() => {
3943
// Clean up any pending mocks
4044
nock.cleanAll();
4145
nock.emitter.removeAllListeners("no match");
4246
nock.enableNetConnect();
47+
vi.unstubAllGlobals();
48+
vi.clearAllMocks();
4349
});
4450

4551
test("should call a function with JSON data", async () => {
@@ -452,4 +458,74 @@ describe("Functions Module", () => {
452458
// Verify all mocks were called
453459
expect(scope.isDone()).toBe(true);
454460
});
461+
462+
test("should fetch function endpoint directly", async () => {
463+
fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 }));
464+
465+
await base44.functions.fetch("/my_function", {
466+
method: "GET",
467+
});
468+
469+
expect(fetchMock).toHaveBeenCalledTimes(1);
470+
expect(fetchMock).toHaveBeenCalledWith(
471+
`${serverUrl}/api/functions/my_function`,
472+
expect.any(Object)
473+
);
474+
});
475+
476+
477+
test("should include Authorization header when using functions.fetch", async () => {
478+
const userToken = "user-streaming-token";
479+
const authenticatedBase44 = createClient({
480+
serverUrl,
481+
appId,
482+
token: userToken,
483+
});
484+
fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 }));
485+
486+
await authenticatedBase44.functions.fetch("streaming_demo", {
487+
method: "POST",
488+
body: JSON.stringify({ mode: "text" }),
489+
});
490+
491+
const requestInit = fetchMock.mock.calls[0][1];
492+
const headers = new Headers(requestInit.headers);
493+
expect(headers.get("Authorization")).toBe(`Bearer ${userToken}`);
494+
});
495+
496+
test("should normalize path with and without leading slash", async () => {
497+
// Test with leading slash
498+
fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 }));
499+
await base44.functions.fetch("/my_function");
500+
expect(fetchMock).toHaveBeenCalledWith(
501+
`${serverUrl}/api/functions/my_function`,
502+
expect.any(Object)
503+
);
504+
505+
// Test without leading slash
506+
fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 }));
507+
await base44.functions.fetch("my_function");
508+
expect(fetchMock).toHaveBeenCalledWith(
509+
`${serverUrl}/api/functions/my_function`,
510+
expect.any(Object)
511+
);
512+
});
513+
514+
test("should include service role Authorization header when using asServiceRole.functions.fetch", async () => {
515+
const serviceToken = "service-role-token";
516+
const serviceRoleBase44 = createClient({
517+
serverUrl,
518+
appId,
519+
serviceToken,
520+
});
521+
fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 }));
522+
523+
await serviceRoleBase44.asServiceRole.functions.fetch("/service_function", {
524+
method: "GET",
525+
});
526+
527+
const requestInit = fetchMock.mock.calls[0][1];
528+
const headers = new Headers(requestInit.headers);
529+
expect(headers.get("Authorization")).toBe(`Bearer ${serviceToken}`);
530+
});
455531
});

0 commit comments

Comments
 (0)