Add callAppRoute to RestApiClient (#22863)
Adds a `callAppRoute` method to `RestApiClient` in `twenty-client-sdk/rest`. It calls one of the app's own HTTP routes using the injected `TWENTY_FUNCTIONS_URL`, resolved internally the same way the client already resolves `TWENTY_API_URL`, so app code no longer reads env vars or knows how function routes are hosted. Both app runtimes already go through `RestApiClient` for route calls (logic functions and front components), so both get this in one place; front components keep the existing 401 token-refresh flow. Pairs with #22825, which makes the injected `TWENTY_FUNCTIONS_URL` callable in every topology (app custom domain -> workspace isolated functions domain -> `SERVER_URL/s`). Once this ships in an SDK release, Call Recorder's own-route plumbing (logic-function and front-component utils) drops its URL resolution and calls `client.callAppRoute(path, body)`. --------- Co-authored-by: martmull <martmull@hotmail.fr> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
+4
-1
@@ -1,5 +1,6 @@
|
||||
import { type CSSProperties, useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
|
||||
@@ -93,7 +94,9 @@ const DocumentViewer = () => {
|
||||
return <div style={styles.empty}>{loading ? 'Loading…' : 'Open a document to preview it here.'}</div>;
|
||||
}
|
||||
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
const webUrl = new RestApiClient().resolveUrl('/s/documents/view', {
|
||||
query: { id: recordId },
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
|
||||
+3
-38
@@ -6,6 +6,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
closeSidePanel,
|
||||
@@ -61,41 +62,6 @@ const readValue = (event: SyntheticEvent<HTMLElement>): string | undefined => {
|
||||
return object.detail?.value ?? object.target?.value;
|
||||
};
|
||||
|
||||
const callAppRoute = async <TResponse,>(
|
||||
path: string,
|
||||
method: 'GET' | 'POST',
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<TResponse> => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token =
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
|
||||
if (!apiBaseUrl || !token) {
|
||||
throw new Error('App is missing API URL or access token configuration.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/s${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = (await response.json().catch(() => null)) as {
|
||||
message?: string;
|
||||
} | null;
|
||||
|
||||
throw new Error(
|
||||
errorBody?.message ?? `Request failed with status ${response.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TResponse>;
|
||||
};
|
||||
|
||||
const styles: Record<string, CSSProperties> = {
|
||||
container: {
|
||||
fontFamily: theme.fontFamily,
|
||||
@@ -236,9 +202,8 @@ const GenerateDocumentForm = () => {
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await callAppRoute<GenerateResponse>(
|
||||
'/documents/generate',
|
||||
'POST',
|
||||
const result = await new RestApiClient().post<GenerateResponse>(
|
||||
'/s/documents/generate',
|
||||
{ templateId, recordId },
|
||||
);
|
||||
|
||||
|
||||
+4
-12
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
|
||||
import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util';
|
||||
@@ -29,22 +29,14 @@ describe('requestCallRecordingSummaryGeneration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('posts to the injected functions origin without the legacy prefix', async () => {
|
||||
vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com');
|
||||
|
||||
it('posts the /s-prefixed route path and lets the client resolve the url', async () => {
|
||||
await requestCallRecordingSummaryGeneration({
|
||||
calendarEventIds: ['calendar-event-1'],
|
||||
});
|
||||
|
||||
expect(restApiClientMock).toHaveBeenCalledWith({
|
||||
baseUrl: 'https://acme.functions.example.com',
|
||||
});
|
||||
expect(restApiClientMock).toHaveBeenCalledWith();
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH,
|
||||
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
|
||||
{ calendarEventIds: ['calendar-event-1'] },
|
||||
);
|
||||
});
|
||||
|
||||
+2
-13
@@ -1,9 +1,7 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
import { enqueueSnackbar } from 'twenty-sdk/front-component';
|
||||
|
||||
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
|
||||
import { TWENTY_FUNCTIONS_URL_ENV_VAR_NAME } from 'src/constants/twenty-functions-url-env-var-name';
|
||||
|
||||
type GenerateSummariesResponse = {
|
||||
outcome?: string;
|
||||
@@ -66,17 +64,8 @@ export const requestCallRecordingSummaryGeneration = async ({
|
||||
}
|
||||
|
||||
try {
|
||||
// The host injects the isolated functions origin; the legacy /s route
|
||||
// 410s post-cutoff functions and only remains for self-hosting.
|
||||
const functionsBaseUrl = process.env[TWENTY_FUNCTIONS_URL_ENV_VAR_NAME];
|
||||
const client = isNonEmptyString(functionsBaseUrl)
|
||||
? new RestApiClient({ baseUrl: functionsBaseUrl })
|
||||
: new RestApiClient();
|
||||
|
||||
const response = await client.post<GenerateSummariesResponse>(
|
||||
isNonEmptyString(functionsBaseUrl)
|
||||
? GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH
|
||||
: `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
|
||||
const response = await new RestApiClient().post<GenerateSummariesResponse>(
|
||||
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
|
||||
{ calendarEventIds },
|
||||
);
|
||||
|
||||
|
||||
@@ -39,13 +39,13 @@ describe('RestApiClient', () => {
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
const result = await client.get('/s/my-app/my-route');
|
||||
const result = await client.get('/rest/companies');
|
||||
|
||||
expect(result).toEqual({ id: '42' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, requestInit] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.twenty.test/s/my-app/my-route');
|
||||
expect(url).toBe('https://api.twenty.test/rest/companies');
|
||||
expect((requestInit.headers as Headers).get('Authorization')).toBe(
|
||||
'Bearer app-access-token',
|
||||
);
|
||||
@@ -57,7 +57,7 @@ describe('RestApiClient', () => {
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
await client.post('/s/my-app/my-route', { name: 'Twenty' });
|
||||
await client.post('/rest/companies', { name: 'Twenty' });
|
||||
|
||||
const [, requestInit] = fetchMock.mock.calls[0];
|
||||
expect(requestInit.method).toBe('POST');
|
||||
@@ -72,13 +72,13 @@ describe('RestApiClient', () => {
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
await client.get('/s/my-app/my-route', {
|
||||
await client.get('/rest/companies', {
|
||||
query: { limit: 10, search: undefined, includeArchived: false },
|
||||
});
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
'https://api.twenty.test/s/my-app/my-route?limit=10&includeArchived=false',
|
||||
'https://api.twenty.test/rest/companies?limit=10&includeArchived=false',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('RestApiClient', () => {
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
await expect(client.get('/s/my-app/my-route')).rejects.toBeInstanceOf(
|
||||
await expect(client.get('/rest/companies')).rejects.toBeInstanceOf(
|
||||
RestApiClientError,
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
@@ -102,7 +102,7 @@ describe('RestApiClient', () => {
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
await expect(client.get('/s/my-app/my-route')).rejects.toBeInstanceOf(
|
||||
await expect(client.get('/rest/companies')).rejects.toBeInstanceOf(
|
||||
RestApiClientError,
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
@@ -118,12 +118,170 @@ describe('RestApiClient', () => {
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
await expect(client.get('/s/my-app/my-route')).rejects.toMatchObject({
|
||||
await expect(client.get('/rest/companies')).rejects.toMatchObject({
|
||||
status: 404,
|
||||
body: { message: 'Not found' },
|
||||
});
|
||||
});
|
||||
|
||||
describe('app route paths', () => {
|
||||
it('should strip the /s prefix and send app route paths to an isolated-domain functions url at the root', async () => {
|
||||
(globalThis as Record<string, unknown>).process = {
|
||||
env: {
|
||||
TWENTY_API_URL: 'https://api.twenty.test',
|
||||
TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
|
||||
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
|
||||
},
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
await client.post('/s/my-app/my-route', { remainingIds: ['a'] });
|
||||
|
||||
const [url, requestInit] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://acme.functions.twenty.test/my-app/my-route');
|
||||
expect(requestInit.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('should join a same-site functions url that already contains /s without doubling slashes', async () => {
|
||||
(globalThis as Record<string, unknown>).process = {
|
||||
env: {
|
||||
TWENTY_API_URL: 'https://api.twenty.test',
|
||||
TWENTY_FUNCTIONS_URL: 'https://api.twenty.test/s/',
|
||||
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
|
||||
},
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
await client.post('/s/my-app/my-route');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.twenty.test/s/my-app/my-route');
|
||||
});
|
||||
|
||||
it('should fall back to the api url /s route when the functions url is not injected', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
await client.post('/s/my-app/my-route');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.twenty.test/s/my-app/my-route');
|
||||
});
|
||||
|
||||
it('should treat an empty functions url as not injected', async () => {
|
||||
(globalThis as Record<string, unknown>).process = {
|
||||
env: {
|
||||
TWENTY_API_URL: 'https://api.twenty.test',
|
||||
TWENTY_FUNCTIONS_URL: '',
|
||||
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
|
||||
},
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
await client.post('/s/my-app/my-route');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.twenty.test/s/my-app/my-route');
|
||||
});
|
||||
|
||||
it('should keep rest paths on the api url when a functions url is injected', async () => {
|
||||
(globalThis as Record<string, unknown>).process = {
|
||||
env: {
|
||||
TWENTY_API_URL: 'https://api.twenty.test',
|
||||
TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
|
||||
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
|
||||
},
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
await client.get('/rest/companies');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.twenty.test/rest/companies');
|
||||
});
|
||||
|
||||
it('should keep unprefixed paths on the api url when a functions url is injected', async () => {
|
||||
(globalThis as Record<string, unknown>).process = {
|
||||
env: {
|
||||
TWENTY_API_URL: 'https://api.twenty.test',
|
||||
TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
|
||||
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
|
||||
},
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
await client.post('/my-app/my-route');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.twenty.test/my-app/my-route');
|
||||
});
|
||||
|
||||
it('should prefer an explicit baseUrl over the functions url and keep the path untouched', async () => {
|
||||
(globalThis as Record<string, unknown>).process = {
|
||||
env: {
|
||||
TWENTY_API_URL: 'https://api.twenty.test',
|
||||
TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
|
||||
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
|
||||
},
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
|
||||
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://explicit.twenty.test',
|
||||
fetch: fetchMock,
|
||||
});
|
||||
|
||||
await client.post('/s/my-app/my-route');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://explicit.twenty.test/s/my-app/my-route');
|
||||
});
|
||||
|
||||
it('should resolve an app route url without sending a request', () => {
|
||||
(globalThis as Record<string, unknown>).process = {
|
||||
env: {
|
||||
TWENTY_API_URL: 'https://api.twenty.test',
|
||||
TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
|
||||
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
|
||||
},
|
||||
};
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
const url = client.resolveUrl('/s/documents/view', {
|
||||
query: { id: 'record-1' },
|
||||
});
|
||||
|
||||
expect(url).toBe(
|
||||
'https://acme.functions.twenty.test/documents/view?id=record-1',
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resolve a rest url on the api base without sending a request', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
const url = client.resolveUrl('/rest/companies');
|
||||
|
||||
expect(url).toBe('https://api.twenty.test/rest/companies');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should refresh the access token once on a 401 and retry the request', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
@@ -139,7 +297,7 @@ describe('RestApiClient', () => {
|
||||
|
||||
const client = new RestApiClient({ fetch: fetchMock });
|
||||
|
||||
const result = await client.get('/s/my-app/my-route');
|
||||
const result = await client.get('/rest/companies');
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
DEFAULT_API_KEY_NAME,
|
||||
DEFAULT_API_URL_NAME,
|
||||
DEFAULT_APP_ACCESS_TOKEN_NAME,
|
||||
DEFAULT_FUNCTIONS_URL_NAME,
|
||||
} from 'twenty-shared/application';
|
||||
|
||||
const isDefined = <T>(value: T): value is NonNullable<T> =>
|
||||
@@ -53,6 +54,13 @@ const getProcessEnvironment = (): ProcessEnvironment => {
|
||||
return processObject?.env ?? {};
|
||||
};
|
||||
|
||||
const isAppRoutePath = (path: string): boolean => /^\/?s\//.test(path);
|
||||
|
||||
// The server serves app routes under /s/; isolated functions domains serve
|
||||
// them at the root, so the marker prefix is stripped before joining.
|
||||
const stripAppRoutePrefix = (path: string): string =>
|
||||
path.replace(/^(\/?)s\//, '$1');
|
||||
|
||||
const buildRequestUrl = (
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
@@ -111,6 +119,15 @@ export class RestApiClient {
|
||||
return this.execute<TResponse>('GET', path, undefined, options);
|
||||
}
|
||||
|
||||
resolveUrl(
|
||||
path: string,
|
||||
requestOptions?: Pick<RestApiRequestOptions, 'query'>,
|
||||
): string {
|
||||
const target = this.resolveTarget(path);
|
||||
|
||||
return buildRequestUrl(target.baseUrl, target.path, requestOptions?.query);
|
||||
}
|
||||
|
||||
post<TResponse = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
@@ -152,6 +169,31 @@ export class RestApiClient {
|
||||
return baseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
private resolveFunctionsBaseUrl(): string | undefined {
|
||||
const functionsBaseUrl =
|
||||
getProcessEnvironment()[DEFAULT_FUNCTIONS_URL_NAME];
|
||||
|
||||
if (!isDefined(functionsBaseUrl) || functionsBaseUrl.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return functionsBaseUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
private resolveTarget(path: string): { baseUrl: string; path: string } {
|
||||
if (isDefined(this.baseUrl) || !isAppRoutePath(path)) {
|
||||
return { baseUrl: this.resolveBaseUrl(), path };
|
||||
}
|
||||
|
||||
// /s/ marks an app HTTP route. TWENTY_FUNCTIONS_URL is a complete base
|
||||
// URL (isolated domains serve routes at the root, self-host bakes /s in);
|
||||
// fall back to the same-site /s route when it is not injected.
|
||||
return {
|
||||
baseUrl: this.resolveFunctionsBaseUrl() ?? `${this.resolveBaseUrl()}/s`,
|
||||
path: stripAppRoutePrefix(path),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveToken(): string {
|
||||
if (!isDefined(this.authorizationToken)) {
|
||||
const processEnvironment = getProcessEnvironment();
|
||||
@@ -304,9 +346,10 @@ export class RestApiClient {
|
||||
body: unknown,
|
||||
requestOptions?: RestApiRequestOptions,
|
||||
): Promise<TResponse> {
|
||||
const target = this.resolveTarget(path);
|
||||
const url = buildRequestUrl(
|
||||
this.resolveBaseUrl(),
|
||||
path,
|
||||
target.baseUrl,
|
||||
target.path,
|
||||
requestOptions?.query,
|
||||
);
|
||||
const token = this.resolveToken();
|
||||
|
||||
@@ -236,29 +236,22 @@ export default defineFrontComponent({
|
||||
|
||||
Front components run browser-side in a Web Worker sandboxed inside an opaque-origin iframe, while [logic functions](/developers/extend/apps/logic/logic-functions) run server-side. There is no direct in-process call between the two — instead, a front component reaches a logic function over HTTP.
|
||||
|
||||
A logic function declared with `httpRouteTriggerSettings` is reachable over HTTP at its route path. Twenty injects the base URL your functions are served from into the worker as `TWENTY_FUNCTIONS_URL`, together with the `TWENTY_APP_ACCESS_TOKEN` that authenticates the call. There is no dedicated SDK client for invoking your own functions yet, so call them with a plain `fetch`:
|
||||
A logic function declared with `httpRouteTriggerSettings` is reachable over HTTP at its route path. `RestApiClient` treats paths starting with `/s/` as app routes, resolves them to the URL your functions are served from, and authenticates them with `TWENTY_APP_ACCESS_TOKEN`.
|
||||
|
||||
> **On Twenty Cloud, HTTP-triggered logic functions are served on a dedicated per-workspace domain** at `https://<your-workspace-subdomain>.withtwenty.com<path>` — this is exactly what `TWENTY_FUNCTIONS_URL` resolves to. For external callers, copy the exact URL from the function's **HTTP trigger** settings or the application's **Settings** tab.
|
||||
|
||||
<Warning>
|
||||
The legacy `/s/` function route is **deprecated** and will be **deactivated on 2026-07-24**. Use `TWENTY_FUNCTIONS_URL` (above) instead, and migrate any hard-coded `/s/` URLs before that date. The `/s/` route remains available for self-hosting.
|
||||
</Warning>
|
||||
> **On Twenty Cloud, HTTP-triggered logic functions are served on a dedicated per-workspace domain** at `https://<your-workspace-subdomain>.withtwenty.com<path>`. For external callers, copy the exact URL from the function's **HTTP trigger** settings or the application's **Settings** tab.
|
||||
|
||||
A headless front component can run the call on mount via the `Command` component, then unmount automatically:
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/front-component';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
await new RestApiClient().post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -274,7 +267,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
The path appended to `TWENTY_FUNCTIONS_URL` is the logic function's `httpRouteTriggerSettings.path`. Keep `isAuthRequired: true`; the `TWENTY_APP_ACCESS_TOKEN` Twenty mints for your component authenticates the request:
|
||||
The path passed to `RestApiClient` is the logic function's `httpRouteTriggerSettings.path`, prefixed with `/s`. Keep `isAuthRequired: true`; the `TWENTY_APP_ACCESS_TOKEN` Twenty mints for your component authenticates the request:
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -299,12 +292,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
`TWENTY_FUNCTIONS_URL` and `TWENTY_APP_ACCESS_TOKEN` are injected automatically — see [Application variables](#application-variables). Because secret application variables are never exposed to front components, keep API keys and other sensitive logic in the logic function, not in the front component.
|
||||
`TWENTY_APP_ACCESS_TOKEN` is injected automatically — see [Application variables](#application-variables). Because secret application variables are never exposed to front components, keep API keys and other sensitive logic in the logic function, not in the front component.
|
||||
</Note>
|
||||
|
||||
### Calling the Twenty REST API
|
||||
|
||||
To read or write Twenty records from a front component, use `RestApiClient` from `twenty-client-sdk/rest`. It belongs to the same client family as `CoreApiClient` and `MetadataApiClient`, but targets the Twenty REST API (`/rest/...`) instead of the GraphQL API, reading its base URL from `TWENTY_API_URL`.
|
||||
To call app HTTP routes or read and write Twenty records from a front component, use `RestApiClient` from `twenty-client-sdk/rest`. It sends `/s/...` paths to your workspace's functions base URL and every other path, including `/rest/...`, to `TWENTY_API_URL`.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
@@ -314,6 +307,7 @@ To read or write Twenty records from a front component, use `RestApiClient` from
|
||||
| `patch(path, body?, options?)` | Sends a `PATCH` request |
|
||||
| `delete(path, options?)` | Sends a `DELETE` request |
|
||||
| `request(method, path, options?)` | Generic request with any HTTP method |
|
||||
| `resolveUrl(path, options?)` | Resolves a path to its full URL without sending a request (for links) |
|
||||
|
||||
`options` accepts `headers`, `query` (a record of query-string params; nullish values are skipped), and an `AbortSignal` via `signal`. A non-`FormData` object `body` is JSON-serialized automatically. On a `401`, the client refreshes the access token once through the host and retries the request.
|
||||
|
||||
@@ -419,10 +413,21 @@ The following system variables are always available via `process.env`:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TWENTY_FUNCTIONS_URL` | Base URL your app's HTTP logic functions are served from |
|
||||
| `TWENTY_API_URL` | Base URL of the Twenty core API |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Short-lived token scoped to your app's role |
|
||||
|
||||
### `TWENTY_FUNCTIONS_URL`
|
||||
|
||||
Twenty also injects `TWENTY_FUNCTIONS_URL` into front components and logic functions: the base URL your app's HTTP-triggered logic functions are served from.
|
||||
|
||||
It exists because that URL is not always the Twenty server itself. On Twenty Cloud, app routes are served on a dedicated per-workspace domain (`https://<your-workspace-subdomain>.withtwenty.com`, or the application's primary public domain when one is configured) so that app-authored responses run on an isolated origin rather than on the Twenty app origin. Self-hosted and local instances serve app routes under the `/s` prefix on the server itself and may not set the variable at all. Since the base URL varies per workspace and per instance, your code cannot hard-code it — the server injects the right value at runtime.
|
||||
|
||||
You rarely need to read it directly. Call your routes through `RestApiClient` with a `/s/`-prefixed path and the client resolves the URL for you: it strips the `/s` prefix and targets `TWENTY_FUNCTIONS_URL`, falling back to `<TWENTY_API_URL>/s` when the variable is not set. Use `resolveUrl('/s/<path>')` to get the absolute URL without sending a request, e.g. for a link. Read the variable directly only when building a URL by hand:
|
||||
|
||||
```ts
|
||||
const routeUrl = `${process.env.TWENTY_FUNCTIONS_URL || `${process.env.TWENTY_API_URL}/s`}/documents/generate`;
|
||||
```
|
||||
|
||||
## Host communication API
|
||||
|
||||
Front components can trigger navigation, modals, and notifications using functions from `twenty-sdk`:
|
||||
|
||||
@@ -51,12 +51,7 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
Available trigger types:
|
||||
- **httpRoute**: Exposes your function on an HTTP path and method at your workspace's **functions base URL** — the value Twenty injects as `TWENTY_FUNCTIONS_URL` (on Twenty Cloud, a dedicated per-workspace domain):
|
||||
> e.g. `path: '/post-card/create'` is callable at `https://your-workspace.withtwenty.com/post-card/create`
|
||||
|
||||
<Warning>
|
||||
The legacy `/s/` prefix route (`https://your-twenty-server.com/s/post-card/create`) is **deprecated on Twenty Cloud** and will be deactivated on **2026-07-24**. It remains available for self-hosted and local instances that don't configure an isolated functions domain — use `TWENTY_FUNCTIONS_URL` when it's set, and fall back to `<server-url>/s/<path>` otherwise.
|
||||
</Warning>
|
||||
- **httpRoute**: Exposes your function on an HTTP path and method. In app code, prefix the route path with `/s/` when using `RestApiClient`; the deployed URL uses the injected `TWENTY_FUNCTIONS_URL` base (or `<server-url>/s` when it is not set).
|
||||
|
||||
<Note>
|
||||
To invoke a route-triggered logic function from a (headless) front component, see [Calling a logic function](/developers/extend/apps/layout/front-components#calling-a-logic-function).
|
||||
|
||||
@@ -42,7 +42,7 @@ A logic function picks one or more triggers — every entry below is a separate
|
||||
|
||||
| Trigger | When it runs | Setting |
|
||||
|---------|--------------|---------|
|
||||
| **HTTP route** | A request hits your function's public URL | `httpRouteTriggerSettings` |
|
||||
| **HTTP route** | A request hits your `/s/<path>` endpoint | `httpRouteTriggerSettings` |
|
||||
| **Cron** | A CRON expression matches | `cronTriggerSettings` |
|
||||
| **Database event** | A workspace record is created, updated, or deleted | `databaseEventTriggerSettings` |
|
||||
| **AI tool** | A Twenty AI feature decides to call your function | `toolTriggerSettings` |
|
||||
|
||||
+9
-12
@@ -69,6 +69,7 @@ chapter.
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
@@ -91,15 +92,10 @@ const GenerateDocumentForm = () => {
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
// Prefer the injected functions URL; fall back to the legacy /s prefix (self-hosted/local)
|
||||
const functionsBaseUrl =
|
||||
process.env.TWENTY_FUNCTIONS_URL || `${process.env.TWENTY_API_URL}/s`;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${functionsBaseUrl}/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
const res = await new RestApiClient().post<{ success: boolean }>(
|
||||
'/s/documents/generate',
|
||||
{ templateId, recordId },
|
||||
);
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
@@ -180,6 +176,7 @@ helper.
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
@@ -188,9 +185,9 @@ const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const functionsBaseUrl =
|
||||
process.env.TWENTY_FUNCTIONS_URL || `${process.env.TWENTY_API_URL ?? ''}/s`;
|
||||
const webUrl = `${functionsBaseUrl}/documents/view?id=${recordId}`;
|
||||
const webUrl = new RestApiClient().resolveUrl('/s/documents/view', {
|
||||
query: { id: recordId },
|
||||
});
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
|
||||
+4
-9
@@ -9,15 +9,10 @@ The same handler can also answer HTTP requests. We'll add two routes:
|
||||
- a **POST** endpoint the UI calls to generate a document, and
|
||||
- a public **GET** endpoint that renders a document as a printable web page.
|
||||
|
||||
Both use `httpRouteTriggerSettings`. On the local dev server, app routes are
|
||||
served under the `/s` prefix (e.g. `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
<Note>
|
||||
On Twenty Cloud, routes are served on the workspace's dedicated functions domain
|
||||
— the URL Twenty injects as `TWENTY_FUNCTIONS_URL`, with no `/s` prefix. The `/s`
|
||||
prefix is deprecated there and only remains for self-hosted and local instances.
|
||||
See [Calling a logic function](/developers/extend/apps/layout/front-components#calling-a-logic-function).
|
||||
</Note>
|
||||
Both use `httpRouteTriggerSettings`. App routes are addressed with an `/s/` path; on local
|
||||
and self-hosted instances, that path is served by your Twenty server
|
||||
(e.g. `http://localhost:2020/s/documents/generate`). On Twenty Cloud, `RestApiClient`
|
||||
resolves the path through the workspace's functions domain.
|
||||
|
||||
## POST route — generate on demand
|
||||
|
||||
|
||||
Reference in New Issue
Block a user