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
5 changes: 5 additions & 0 deletions .changeset/calm-serverless-calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tma.js/sdk": minor
---

Add `serverless` component allowing to call endpoints of the bot's Serverless project, and `ServerlessError`.
1 change: 1 addition & 0 deletions apps/docs/.vitepress/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
60 changes: 60 additions & 0 deletions apps/docs/packages/tma-js-sdk/features/serverless.md
Original file line number Diff line number Diff line change
@@ -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<Profile>('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`.
25 changes: 25 additions & 0 deletions packages/sdk/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
}) {
}

export class InvalidArgumentsError extends /* #__PURE__ */ errorClass<[message: string, cause?: unknown]>({

Check warning on line 62 in packages/sdk/src/errors.ts

View workflow job for this annotation

GitHub Actions / Test pull request

This line has a length of 107. Maximum allowed is 100
name: 'InvalidArgumentsError',
super: (message, cause) => [message, { cause }],
}) {
Expand Down Expand Up @@ -101,6 +101,31 @@
}) {
}

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}`],
Expand Down
112 changes: 112 additions & 0 deletions packages/sdk/src/features/Serverless/Serverless.test.ts
Original file line number Diff line number Diff line change
@@ -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<ServerlessOptions> = {}) {
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 },
},
});
});
});
150 changes: 150 additions & 0 deletions packages/sdk/src/features/Serverless/Serverless.ts
Original file line number Diff line number Diff line change
@@ -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 = <T = unknown>(
name: string,
input?: object,
options?: ServerlessCallOptions,
) => TE.TaskEither<ServerlessCallError, T>;

type Call = <T = unknown>(
name: string,
input?: object,
options?: ServerlessCallOptions,
) => BetterPromise<T>;

export interface ServerlessOptions extends SharedFeatureOptions, WithVersion {
/**
* Retrieves raw init data from the current environment.
*/
retrieveRawInitData: () => E.Either<Error, O.Option<string>>;
/**
* 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<ServerlessError, any> {
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<ServerlessCallError, any>;
};

// 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<Profile>('getProfile', { lang: 'en' }),
* TE.match(
* error => console.error(error.message),
* profile => console.log(profile),
* ),
* );
*/
readonly callFp: WithChecksFp<CallFp, false> & CallFp;

/**
* @see callFp
* @example
* const profile = await serverless.call<Profile>('getProfile', { lang: 'en' });
*/
readonly call: WithChecks<Call, false> & Call;
}
7 changes: 7 additions & 0 deletions packages/sdk/src/features/Serverless/exports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
Serverless,
type ServerlessCallError,
type ServerlessCallOptions,
type ServerlessOptions,
} from './Serverless.js';
export { serverless } from './instance.js';
16 changes: 16 additions & 0 deletions packages/sdk/src/features/Serverless/instance.ts
Original file line number Diff line number Diff line change
@@ -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();
Loading
Loading