diff --git a/packages/twenty-apps/examples/document-generator/src/front-components/document-viewer.front-component.tsx b/packages/twenty-apps/examples/document-generator/src/front-components/document-viewer.front-component.tsx index 85285b7aac..8168592391 100644 --- a/packages/twenty-apps/examples/document-generator/src/front-components/document-viewer.front-component.tsx +++ b/packages/twenty-apps/examples/document-generator/src/front-components/document-viewer.front-component.tsx @@ -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
{loading ? 'Loading…' : 'Open a document to preview it here.'}
; } - const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`; + const webUrl = new RestApiClient().resolveUrl('/s/documents/view', { + query: { id: recordId }, + }); return (
diff --git a/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx b/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx index 541b5086eb..36e232ab5a 100644 --- a/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx +++ b/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx @@ -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): string | undefined => { return object.detail?.value ?? object.target?.value; }; -const callAppRoute = async ( - path: string, - method: 'GET' | 'POST', - body?: Record, -): Promise => { - 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; -}; - const styles: Record = { container: { fontFamily: theme.fontFamily, @@ -236,9 +202,8 @@ const GenerateDocumentForm = () => { setSubmitting(true); try { - const result = await callAppRoute( - '/documents/generate', - 'POST', + const result = await new RestApiClient().post( + '/s/documents/generate', { templateId, recordId }, ); diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts index 288a0d39c6..4cd61b66f5 100644 --- a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts @@ -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'] }, ); }); diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts index 5a2f68488d..684b3347dc 100644 --- a/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts @@ -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( - isNonEmptyString(functionsBaseUrl) - ? GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH - : `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`, + const response = await new RestApiClient().post( + `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`, { calendarEventIds }, ); diff --git a/packages/twenty-client-sdk/src/rest/__tests__/RestApiClient.test.ts b/packages/twenty-client-sdk/src/rest/__tests__/RestApiClient.test.ts index efc158a860..bf1af913a4 100644 --- a/packages/twenty-client-sdk/src/rest/__tests__/RestApiClient.test.ts +++ b/packages/twenty-client-sdk/src/rest/__tests__/RestApiClient.test.ts @@ -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).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).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).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).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).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).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).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); diff --git a/packages/twenty-client-sdk/src/rest/index.ts b/packages/twenty-client-sdk/src/rest/index.ts index 73ec97ee02..14cf614d16 100644 --- a/packages/twenty-client-sdk/src/rest/index.ts +++ b/packages/twenty-client-sdk/src/rest/index.ts @@ -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 = (value: T): value is NonNullable => @@ -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('GET', path, undefined, options); } + resolveUrl( + path: string, + requestOptions?: Pick, + ): string { + const target = this.resolveTarget(path); + + return buildRequestUrl(target.baseUrl, target.path, requestOptions?.query); + } + post( 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 { + const target = this.resolveTarget(path); const url = buildRequestUrl( - this.resolveBaseUrl(), - path, + target.baseUrl, + target.path, requestOptions?.query, ); const token = this.resolveToken(); diff --git a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx index 914ddeedcb..7cd31a71ff 100644 --- a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx +++ b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx @@ -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://.withtwenty.com` — 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. - - - 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. - +> **On Twenty Cloud, HTTP-triggered logic functions are served on a dedicated per-workspace domain** at `https://.withtwenty.com`. 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({ ``` -`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. ### 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://.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 `/s` when the variable is not set. Use `resolveUrl('/s/')` 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`: diff --git a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx index 286c8119a8..8d6ed74c98 100644 --- a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx +++ b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx @@ -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` - - -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 `/s/` otherwise. - +- **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 `/s` when it is not set). 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). diff --git a/packages/twenty-docs/developers/extend/apps/logic/overview.mdx b/packages/twenty-docs/developers/extend/apps/logic/overview.mdx index 44122a1e87..fb0107689e 100644 --- a/packages/twenty-docs/developers/extend/apps/logic/overview.mdx +++ b/packages/twenty-docs/developers/extend/apps/logic/overview.mdx @@ -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/` 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` | diff --git a/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/building-the-ui.mdx b/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/building-the-ui.mdx index e554e1785c..b2309049ef 100644 --- a/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/building-the-ui.mdx +++ b/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/building-the-ui.mdx @@ -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. diff --git a/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/http-routes.mdx b/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/http-routes.mdx index ac2e61fa95..9e6ae750b2 100644 --- a/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/http-routes.mdx +++ b/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/http-routes.mdx @@ -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`). - - -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). - +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