From 93317efe29adc859bc01c1366ed9f2062d1a7354 Mon Sep 17 00:00:00 2001 From: Vladislav Kibenko Date: Fri, 9 Oct 2026 20:59:13 +0500 Subject: [PATCH] feat(sdk): add serverless component Add serverless component with call and callFp methods calling endpoints of the bot's Telegram Serverless project, mirroring WebApp.Serverless of the official SDK, and ServerlessError. --- .changeset/calm-serverless-calls.md | 5 + apps/docs/.vitepress/packages.ts | 1 + .../tma-js-sdk/features/serverless.md | 60 +++++++ packages/sdk/src/errors.ts | 25 +++ .../features/Serverless/Serverless.test.ts | 112 +++++++++++++ .../sdk/src/features/Serverless/Serverless.ts | 150 ++++++++++++++++++ .../sdk/src/features/Serverless/exports.ts | 7 + .../sdk/src/features/Serverless/instance.ts | 16 ++ packages/sdk/src/index.ts | 1 + 9 files changed, 377 insertions(+) create mode 100644 .changeset/calm-serverless-calls.md create mode 100644 apps/docs/packages/tma-js-sdk/features/serverless.md create mode 100644 packages/sdk/src/features/Serverless/Serverless.test.ts create mode 100644 packages/sdk/src/features/Serverless/Serverless.ts create mode 100644 packages/sdk/src/features/Serverless/exports.ts create mode 100644 packages/sdk/src/features/Serverless/instance.ts diff --git a/.changeset/calm-serverless-calls.md b/.changeset/calm-serverless-calls.md new file mode 100644 index 000000000..4cb3064ab --- /dev/null +++ b/.changeset/calm-serverless-calls.md @@ -0,0 +1,5 @@ +--- +"@tma.js/sdk": minor +--- + +Add `serverless` component allowing to call endpoints of the bot's Serverless project, and `ServerlessError`. diff --git a/apps/docs/.vitepress/packages.ts b/apps/docs/.vitepress/packages.ts index 6966f261c..6493e3232 100644 --- a/apps/docs/.vitepress/packages.ts +++ b/apps/docs/.vitepress/packages.ts @@ -67,6 +67,7 @@ export const packagesLinksGenerator = (prefix: string = '') => { utils('privacy'), component('qr-scanner', 'QR Scanner'), component('secondary-button'), + component('serverless'), component('settings-button'), component('swipe-behavior'), component('theme-params'), diff --git a/apps/docs/packages/tma-js-sdk/features/serverless.md b/apps/docs/packages/tma-js-sdk/features/serverless.md new file mode 100644 index 000000000..0e14ae7ed --- /dev/null +++ b/apps/docs/packages/tma-js-sdk/features/serverless.md @@ -0,0 +1,60 @@ +# 💠Serverless + +A component allowing a Mini App hosted on [Telegram Serverless](https://corefork.telegram.org/bots/serverless) +to call the endpoints of the bot's Serverless project. + +The platform validates the Mini App init data before running an endpoint, so the endpoint is able +to identify the caller. That's why the component works only when the app is launched from +Telegram. + +## Calling Endpoints + +To call an endpoint, use the `call` method. It accepts the endpoint name, an optional +JSON-serializable object passed to the endpoint as its first argument, and optional call options. +The method resolves with the value returned by the endpoint. + +```ts +import { serverless } from '@tma.js/sdk'; + +interface Profile { + id: number; + name: string; +} + +const profile = await serverless.call('getProfile', { lang: 'en' }); +``` + +To abort the request, pass the `abortSignal` option: + +```ts +const controller = new AbortController(); +const profile = await serverless.call('getProfile', {}, { + abortSignal: controller.signal, +}); +``` + +## Handling Errors + +If the call fails, the method rejects with `ServerlessError`. Its `data` property contains +the following fields: + +- `status: number` — the HTTP status code of the response, or `0` if the request could not be + completed. +- `type?: string` — `ENDPOINT_ERROR` if the endpoint refused the call by throwing + an `EndpointError`. +- `parameters?: unknown` — additional data passed by the endpoint along with its `EndpointError`. + +```ts +import { serverless, ServerlessError } from '@tma.js/sdk'; + +try { + await serverless.call('getProfile'); +} catch (e) { + if (ServerlessError.is(e)) { + console.error(e.message, e.data.status, e.data.type, e.data.parameters); + } +} +``` + +If init data is unavailable, the method rejects with `InvalidEnvError`. If the passed input is +not an object, it rejects with `InvalidArgumentsError`. diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 6863c08f9..5bccd9f4b 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -101,6 +101,31 @@ export class RequestChatError extends /* #__PURE__ */ errorClass<[error: string] }) { } +export interface ServerlessErrorData { + /** + * HTTP status code of the response, or 0 if the request could not be completed. + */ + status: number; + /** + * "ENDPOINT_ERROR" if the endpoint refused the call by throwing an EndpointError. + */ + type?: string; + /** + * Additional data passed by the endpoint along with its EndpointError. + */ + parameters?: unknown; +} + +export class ServerlessError extends /* #__PURE__ */ errorClassWithData< + ServerlessErrorData, + [message: string, options: ServerlessErrorData & { cause?: unknown }] +>({ + name: 'ServerlessError', + data: (_, { status, type, parameters }) => ({ status, type, parameters }), + super: (message, { cause }) => [message, { cause }], +}) { +} + export class UnknownThemeParamsKeyError extends /* #__PURE__ */ errorClass<[key: string]>({ name: 'UnknownThemeParamsKeyError', super: key => [`Unknown theme params key passed: ${key}`], diff --git a/packages/sdk/src/features/Serverless/Serverless.test.ts b/packages/sdk/src/features/Serverless/Serverless.test.ts new file mode 100644 index 000000000..2f2a241a3 --- /dev/null +++ b/packages/sdk/src/features/Serverless/Serverless.test.ts @@ -0,0 +1,112 @@ +import { either as E, option as O } from 'fp-ts'; +import { describe, expect, it, vi } from 'vitest'; + +import { Serverless, type ServerlessOptions } from '@/features/Serverless/Serverless.js'; + +const RAW_INIT_DATA = 'auth_date=1&hash=abc'; + +function jsonResponse(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { status }); +} + +function instantiate({ + isTma = true, + version = '9.6', + retrieveRawInitData = () => E.right(O.some(RAW_INIT_DATA)), + fetch = vi.fn(() => Promise.resolve(jsonResponse({ ok: true, result: 'r' }))), +}: Partial = {}) { + return new Serverless({ isTma, version, retrieveRawInitData, fetch }); +} + +describe('call', () => { + it('should return FunctionNotAvailableError outside Mini Apps', async () => { + await expect(instantiate({ isTma: false }).callFp('a')()).resolves.toMatchObject({ + _tag: 'Left', + left: { name: 'FunctionNotAvailableError' }, + }); + }); + + it('should send POST request to /api/{name} with init data and input', async () => { + const fetch = vi.fn(() => Promise.resolve(jsonResponse({ ok: true, result: 1 }))); + await instantiate({ fetch }).callFp('getProfile', { lang: 'en' })(); + expect(fetch).toHaveBeenCalledExactlyOnceWith('/api/getProfile', { + method: 'POST', + headers: { + Authorization: `TMA ${btoa(RAW_INIT_DATA)}`, + 'Content-Type': 'application/json', + }, + body: '{"lang":"en"}', + credentials: 'omit', + signal: undefined, + }); + }); + + it('should send empty object if input is omitted', async () => { + const fetch = vi.fn(() => Promise.resolve(jsonResponse({ ok: true }))); + await instantiate({ fetch }).callFp('a')(); + expect(fetch.mock.calls[0]).toMatchObject([expect.anything(), { body: '{}' }]); + }); + + it('should resolve with the endpoint result', async () => { + const fetch = () => Promise.resolve(jsonResponse({ ok: true, result: { id: 1 } })); + await expect(instantiate({ fetch }).callFp('a')()).resolves.toStrictEqual(E.right({ id: 1 })); + await expect(instantiate({ fetch }).call('a')).resolves.toStrictEqual({ id: 1 }); + }); + + it('should return InvalidArgumentsError if input is not an object', async () => { + await expect(instantiate().callFp('a', [])()).resolves.toMatchObject({ + _tag: 'Left', + left: { name: 'InvalidArgumentsError' }, + }); + }); + + it('should return InvalidEnvError if init data is missing', async () => { + const fetch = vi.fn(); + await expect( + instantiate({ fetch, retrieveRawInitData: () => E.right(O.none) }).callFp('a')(), + ).resolves.toMatchObject({ _tag: 'Left', left: { name: 'InvalidEnvError' } }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return ServerlessError with endpoint error data', async () => { + const fetch = () => Promise.resolve(jsonResponse({ + ok: false, + description: 'Not allowed', + error_code: 403, + error_type: 'ENDPOINT_ERROR', + parameters: { reason: 'x' }, + }, 400)); + await expect(instantiate({ fetch }).callFp('a')()).resolves.toMatchObject({ + _tag: 'Left', + left: { + name: 'ServerlessError', + message: 'Not allowed', + data: { status: 403, type: 'ENDPOINT_ERROR', parameters: { reason: 'x' } }, + }, + }); + }); + + it('should return ServerlessError with HTTP status if response is unexpected', async () => { + const fetch = () => Promise.resolve(new Response('oops', { status: 502 })); + await expect(instantiate({ fetch }).callFp('a')()).resolves.toMatchObject({ + _tag: 'Left', + left: { + name: 'ServerlessError', + message: 'Unexpected response from endpoint a (HTTP 502)', + data: { status: 502 }, + }, + }); + }); + + it('should return ServerlessError with status 0 on network error', async () => { + const fetch = () => Promise.reject(new Error('offline')); + await expect(instantiate({ fetch }).callFp('a')()).resolves.toMatchObject({ + _tag: 'Left', + left: { + name: 'ServerlessError', + message: 'Network error calling endpoint a: offline', + data: { status: 0 }, + }, + }); + }); +}); diff --git a/packages/sdk/src/features/Serverless/Serverless.ts b/packages/sdk/src/features/Serverless/Serverless.ts new file mode 100644 index 000000000..acd0d56fd --- /dev/null +++ b/packages/sdk/src/features/Serverless/Serverless.ts @@ -0,0 +1,150 @@ +import type { BetterPromise } from 'better-promises'; +import { either as E, option as O, taskEither as TE, function as fn } from 'fp-ts'; + +import { InvalidArgumentsError, InvalidEnvError, ServerlessError } from '@/errors.js'; +import type { SharedFeatureOptions } from '@/fn-options/sharedFeatureOptions.js'; +import type { WithVersion } from '@/fn-options/withVersion.js'; +import { throwifyWithChecksFp } from '@/with-checks/throwifyWithChecksFp.js'; +import { withChecksFp, type WithChecks, type WithChecksFp } from '@/with-checks/withChecksFp.js'; + +export interface ServerlessCallOptions { + /** + * Signal to abort the request. + */ + abortSignal?: AbortSignal; +} + +export type ServerlessCallError = InvalidArgumentsError | InvalidEnvError | ServerlessError; + +type CallFp = ( + name: string, + input?: object, + options?: ServerlessCallOptions, +) => TE.TaskEither; + +type Call = ( + name: string, + input?: object, + options?: ServerlessCallOptions, +) => BetterPromise; + +export interface ServerlessOptions extends SharedFeatureOptions, WithVersion { + /** + * Retrieves raw init data from the current environment. + */ + retrieveRawInitData: () => E.Either>; + /** + * Function to perform HTTP requests. + */ + fetch: typeof fetch; +} + +/** + * Converts the endpoint response to the call result. + * @param name - endpoint name. + * @param response - endpoint response. + */ +function parseResponse(name: string, response: Response): TE.TaskEither { + return fn.pipe( + // Response body may be missing or not a JSON. In this case we consider it unexpected. + TE.tryCatch(async () => JSON.parse(await response.text()) as unknown, () => undefined), + TE.orElseW(() => TE.right(undefined)), + TE.chain(data => { + if (!data || typeof data !== 'object' || typeof (data as any).ok !== 'boolean') { + return TE.left(new ServerlessError( + `Unexpected response from endpoint ${name} (HTTP ${response.status})`, + { status: response.status }, + )); + } + const envelope = data as { + ok: boolean; + result?: unknown; + description?: string; + error_code?: number; + error_type?: string; + parameters?: unknown; + }; + return envelope.ok + ? TE.right(envelope.result) + : TE.left(new ServerlessError( + envelope.description || `Endpoint ${name} failed (HTTP ${response.status})`, + { + status: envelope.error_code || response.status, + type: envelope.error_type || undefined, + parameters: envelope.parameters, + }, + )); + }), + ); +} + +/** + * Component allowing a Mini App hosted on Telegram Serverless to call the bot's endpoints. + * @see https://corefork.telegram.org/bots/serverless + */ +export class Serverless { + constructor({ retrieveRawInitData, fetch, ...rest }: ServerlessOptions) { + const callFp: CallFp = (name, input = {}, options = {}) => { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return TE.left(new InvalidArgumentsError('Endpoint input must be an object')); + } + return fn.pipe( + TE.fromEither(retrieveRawInitData()), + TE.chainW(TE.fromOption(() => { + return new InvalidEnvError('Init data is unavailable. Open the app from Telegram'); + })), + TE.chainW(initData => TE.tryCatch( + () => fetch(`/api/${name}`, { + method: 'POST', + headers: { + Authorization: `TMA ${btoa(initData)}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + credentials: 'omit', + signal: options.abortSignal, + }), + e => new ServerlessError( + `Network error calling endpoint ${name}${ + e instanceof Error && e.message ? `: ${e.message}` : '' + }`, + { status: 0, cause: e }, + ), + )), + TE.chainW(response => parseResponse(name, response)), + ) as TE.TaskEither; + }; + + // Casting is required to keep the result type generic. + const callFpWithChecks = withChecksFp(callFp, { ...rest, returns: 'task' }); + this.callFp = callFpWithChecks as typeof this.callFp; + this.call = throwifyWithChecksFp(callFpWithChecks) as typeof this.call; + } + + /** + * Calls the bot's Serverless project endpoint with the specified name. + * + * The platform validates the Mini App init data before running the endpoint, so the endpoint + * is able to identify the caller. + * @param name - endpoint name. + * @param input - JSON-serializable object passed to the endpoint as its first argument. + * @param options - additional options. + * @returns The value returned by the endpoint. + * @example + * fn.pipe( + * serverless.callFp('getProfile', { lang: 'en' }), + * TE.match( + * error => console.error(error.message), + * profile => console.log(profile), + * ), + * ); + */ + readonly callFp: WithChecksFp & CallFp; + + /** + * @see callFp + * @example + * const profile = await serverless.call('getProfile', { lang: 'en' }); + */ + readonly call: WithChecks & Call; +} diff --git a/packages/sdk/src/features/Serverless/exports.ts b/packages/sdk/src/features/Serverless/exports.ts new file mode 100644 index 000000000..6d3a8c79c --- /dev/null +++ b/packages/sdk/src/features/Serverless/exports.ts @@ -0,0 +1,7 @@ +export { + Serverless, + type ServerlessCallError, + type ServerlessCallOptions, + type ServerlessOptions, +} from './Serverless.js'; +export { serverless } from './instance.js'; diff --git a/packages/sdk/src/features/Serverless/instance.ts b/packages/sdk/src/features/Serverless/instance.ts new file mode 100644 index 000000000..d5467d4f2 --- /dev/null +++ b/packages/sdk/src/features/Serverless/instance.ts @@ -0,0 +1,16 @@ +import { retrieveRawInitDataFp } from '@tma.js/bridge'; +import { function as fn } from 'fp-ts'; + +import { Serverless } from '@/features/Serverless/Serverless.js'; +import { sharedFeatureOptions } from '@/fn-options/sharedFeatureOptions.js'; +import { withVersion } from '@/fn-options/withVersion.js'; + +function instantiate() { + return new Serverless({ + ...fn.pipe(sharedFeatureOptions(), withVersion), + retrieveRawInitData: retrieveRawInitDataFp, + fetch: (...args) => fetch(...args), + }); +} + +export const serverless = /* @__PURE__*/ instantiate(); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6d3b89d18..6fa044c68 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -17,6 +17,7 @@ export * from './features/privacy/exports.js'; export * from './features/QrScanner/exports.js'; export * from './features/SecondaryButton/exports.js'; export * from './features/SecureStorage/exports.js'; +export * from './features/Serverless/exports.js'; export * from './features/SettingsButton/exports.js'; export * from './features/SwipeBehavior/exports.js'; export * from './features/ThemeParams/exports.js';