UNPKG

@metacall/protocol

Version:

Tool for deploying into MetaCall FaaS platform.

598 lines (525 loc) 14.2 kB
/* This is just a client that implements all the rest API from the FaaS, so each function it contains is an endpoint in the FaaS for deploying: refresh: updates the auth token validate: validates the auth token deployEnabled: checks if you're able to deploy listSubscriptions: gives you a list of the subscription available listSubscriptionsDeploys: gives you a list of the subscription being used in deploys inspect: gives you are deploys with it's endpoints upload: uploads a zip (package) into the faas deploy: deploys the previously uploaded zip into the faas deployDelete: deletes the deploy and the zip logs: retrieve the logs of a deploy by runner or deployment branchList: get the branches of a repository fileList: get files of a repository by branch */ import { Readable } from 'stream'; import { URL } from 'url'; import { Create, Deployment, LogType, MetaCallJSON } from './deployment'; import { Plans } from './plan'; export class ProtocolError extends Error { status?: number; data?: unknown; constructor(message: string, status?: number, data?: unknown) { super(message); this.name = 'ProtocolError'; this.status = status; this.data = data; } } /** * Type guard for protocol-specific errors. * @param err - The unknown error to check. * @returns True if the error is an ProtocolError, false otherwise. */ export const isProtocolError = (err: unknown): err is ProtocolError => err instanceof ProtocolError; type SubscriptionMap = Record<string, number>; export interface SubscriptionDeploy { id: string; plan: Plans; date: number; deploy: string; } export enum ResourceType { Package = 'Package', Repository = 'Repository' } export interface Resource { id: string; } export interface Branches { branches: [string]; } export enum InvokeType { Call = 'call', Await = 'await' } export interface DeployCreateRequest { suffix: string; resourceType: ResourceType; release: string; env: { name: string; value: string }[]; plan: Plans; version: string; } export interface DeployDeleteRequest { prefix: string; suffix: string; version: string; } export interface RepositoryAddRequest { url: string; branch: string; jsons: MetaCallJSON[]; } export interface RepositoryBranchListRequest { url: string; } export interface RepositoryFileListRequest { url: string; branch: string; } export interface API { refresh(): Promise<string>; ready(): Promise<boolean>; validate(): Promise<boolean>; deployEnabled(): Promise<boolean>; listSubscriptions(): Promise<SubscriptionMap>; listSubscriptionsDeploys(): Promise<SubscriptionDeploy[]>; inspect(): Promise<Deployment[]>; inspectByName(suffix: string): Promise<Deployment>; upload( name: string, blob: Blob | Readable, jsons?: MetaCallJSON[], runners?: string[] ): Promise<Resource>; add(url: string, branch: string, jsons: MetaCallJSON[]): Promise<Resource>; deploy( name: string, env: { name: string; value: string }[], plan: Plans, resourceType: ResourceType, release?: string, version?: string ): Promise<Create>; deployDelete( prefix: string, suffix: string, version: string ): Promise<string>; availableJobLogs(suffix: string): Promise<string[]>; logs( container: string, type: LogType, prefix: string, suffix: string, version?: string ): Promise<string>; branchList(url: string): Promise<Branches>; fileList(url: string, branch: string): Promise<string[]>; invoke<Result, Args = unknown>( type: InvokeType, prefix: string, suffix: string, version: string, name: string, args?: Args ): Promise<Result>; call<Result, Args = unknown>( prefix: string, suffix: string, version: string, name: string, args?: Args ): Promise<Result>; await<Result, Args = unknown>( prefix: string, suffix: string, version: string, name: string, args?: Args ): Promise<Result>; } interface RequestImpl { url: string; headers: Headers; method: string; body?: BodyInit; } class Request { private token: string; private baseURL: string; private impl: RequestImpl; constructor(token: string, baseURL: string) { this.token = token; this.baseURL = baseURL; this.impl = { url: '', headers: new Headers({ Authorization: 'jwt ' + this.token }), method: 'GET', body: undefined }; } url(path: string): Request { this.impl.url = new URL(path, this.baseURL).toString(); return this; } headers(headers = {}): Request { this.impl.headers = new Headers({ Authorization: 'jwt ' + this.token, ...headers }); return this; } method(method: string): Request { this.impl.method = method; return this; } bodyRaw(body: BodyInit): Request { this.impl.body = body; return this; } body(body: unknown): Request { this.impl.body = JSON.stringify(body); this.impl.headers.set('Content-Type', 'application/json'); return this; } private async execute(): Promise<Response> { const config: RequestInit = { method: this.impl.method, headers: this.impl.headers }; if (this.impl.body !== undefined) { config.body = this.impl.body; } const res = await fetch(this.impl.url, config); if (!res.ok) { const data = await res.text().catch(() => undefined); throw new ProtocolError( `Request to ${this.impl.url} failed: ${res.statusText}.`, res.status, data ); } return res; } async asJson<T>(): Promise<T> { const res = await this.execute(); return res.json() as Promise<T>; } async asText(): Promise<string> { const res = await this.execute(); return res.text(); } async asStatus(): Promise<number> { const res = await this.execute(); return res.status; } async asResponse(): Promise<Response> { return await this.execute(); } } export default (token: string, baseURL: string): API => { const request = (url = baseURL) => new Request(token, url); const hostname = new URL(baseURL).hostname; const api: API = { refresh: (): Promise<string> => request().url('/api/account/refresh-token').asText(), ready: (): Promise<boolean> => request() .url('/api/readiness') .asStatus() .then(status => status === 200), validate: (): Promise<boolean> => request().url('/validate').asJson<boolean>(), deployEnabled: (): Promise<boolean> => request().url('/api/account/deploy-enabled').asJson<boolean>(), listSubscriptions: async (): Promise<SubscriptionMap> => { const subscriptionsList = await request() .url('/api/billing/list-subscriptions') .asJson<string[]>(); const subscriptions: SubscriptionMap = {}; for (const id of subscriptionsList) { if (subscriptions[id] === undefined) { subscriptions[id] = 1; } else { ++subscriptions[id]; } } return subscriptions; }, listSubscriptionsDeploys: (): Promise<SubscriptionDeploy[]> => request() .url('/api/billing/list-subscriptions') .asJson<SubscriptionDeploy[]>(), inspect: (): Promise<Deployment[]> => request().url('/api/inspect').asJson<Deployment[]>(), inspectByName: async (suffix: string): Promise<Deployment> => { const deployments = await api.inspect(); const deploy = deployments.find(deploy => deploy.suffix == suffix); if (!deploy) { throw new Error(`Deployment with suffix '${suffix}' not found`); } return deploy; }, upload: async ( name: string, data: Blob | Readable, jsons: MetaCallJSON[] = [], runners: string[] = [] ): Promise<Resource> => { const fd = new FormData(); fd.append('id', name); fd.append('type', 'application/x-zip-compressed'); fd.append('jsons', JSON.stringify(jsons)); fd.append('runners', JSON.stringify(runners)); if (data instanceof Blob) { fd.append('raw', data, `${name}.zip`); } else if (data instanceof Readable) { // This is terrible but NodeJS does not ensure that streaming and zero // copy will be performed anyway, as the sizes are not really big (150mb is the limit) // we can do this nasty intermediate buffer creation and forget about it const chunks: Uint8Array[] = []; for await (const chunk of data) { chunks.push( typeof chunk === 'string' ? Buffer.from(chunk) : chunk ); } const buffer = Buffer.concat(chunks); fd.append('raw', new Blob([buffer]), `${name}.zip`); } else { throw new ProtocolError( `Type ${typeof data} not supported, use Blob or Readable`, 422, data ); } return await request() .url('/api/package/create') .method('POST') .bodyRaw(fd) .asJson<Resource>(); }, add: ( url: string, branch: string, jsons: MetaCallJSON[] = [] ): Promise<Resource> => request() .url('/api/repository/add') .method('POST') .body({ url, branch, jsons }) .asJson<Resource>(), branchList: (url: string): Promise<Branches> => request() .url('/api/repository/branchlist') .method('POST') .body({ url }) .asJson<Branches>(), deploy: ( name: string, env: { name: string; value: string }[], plan: Plans, resourceType: ResourceType, release: string = Date.now().toString(16), version = 'v1' ): Promise<Create> => request() .url('/api/deploy/create') .method('POST') .body({ resourceType, suffix: name, release, env, plan, version }) .asJson<Create>(), deployDelete: ( prefix: string, suffix: string, version = 'v1' ): Promise<string> => request() .url('/api/deploy/delete') .method('POST') .body({ prefix, suffix, version }) .asJson<string>(), availableJobLogs: (suffix: string): Promise<string[]> => request() .url('/api/deploy/availablejoblogs') .method('POST') .body({ suffix }) .asJson<string[]>(), logs: ( container: string, type: LogType = LogType.Deploy, prefix: string, suffix: string, version = 'v1' ): Promise<string> => request() .url('/api/deploy/logs') .method('POST') .body({ container, type, prefix, suffix, version }) .asText(), fileList: (url: string, branch: string): Promise<string[]> => request() .url('/api/repository/filelist') .method('POST') .body({ url, branch }) .asJson<{ [k: string]: string[] }>() .then(res => res['files']), invoke: async <Result, Args = unknown>( type: InvokeType, prefix: string, suffix: string, version = 'v1', name: string, args?: Args ): Promise<Result> => { const req = (() => { if (hostname === 'localhost') { // Old API in commercial FaaS and current API of FaaS reimplementation return request().url( `/${prefix}/${suffix}/${version}/${type}/${name}` ); } else { // New API used by commercial FaaS return request( `https://${version}-${suffix}-${prefix}.api.metacall.io` ).url(`/${type}/${name}`); } })(); if (args === undefined) { req.method('GET'); } else { req.method('POST').body(args); } return await req.asJson<Result>(); }, call: <Result, Args = unknown>( prefix: string, suffix: string, version = 'v1', name: string, args?: Args ): Promise<Result> => api.invoke(InvokeType.Call, prefix, suffix, version, name, args), await: <Result, Args = unknown>( prefix: string, suffix: string, version = 'v1', name: string, args?: Args ): Promise<Result> => api.invoke(InvokeType.Await, prefix, suffix, version, name, args) }; return api; }; export const MaxRetries = 100; export const MaxRetryInterval = 5000; export const MaxFuncLength = 64; /** * Executes an asynchronous function with automatic retry logic. * * The function will be retried up to `maxRetries` times, waiting `interval` * milliseconds between each attempt. If all retries fail, the last error is * wrapped in a new `Error` with a descriptive message, including: * - Function name (or string representation truncated to `MaxFuncLength` chars if anonymous) * - Number of retries attempted * - Original error message * * Error handling is fully type-safe: * - If the error is an ProtocolError (checked via `isProtocolError`), its * message is used. * - If the error is a standard `Error`, its `message` is used. * - Otherwise, the error is converted to a string. * * @typeParam T - The return type of the function being retried. * @param fn - A lambda or bound function returning a `Promise<T>`. The * function should contain the logic you want to retry. * @param maxRetries - Maximum number of retry attempts. Default: `MaxRetries`. * @param interval - Delay between retries in milliseconds. Default: `MaxRetryInterval`. * @returns A `Promise` resolving to the return value of `fn` if successful. * @throws Error If all retry attempts fail, throws a new Error containing * information about the function and the last error. * * @example * ```ts * const deployment = await waitFor(() => api.inspectByName('my-suffix')); * ``` * * @example * ```ts * const result = await waitFor( * () => api.deploy(name, env, plan, resourceType) * ); * ``` */ export const waitFor = async <T>( fn: (cancel: (message: string) => void) => Promise<T>, maxRetries: number = MaxRetries, interval: number = MaxRetryInterval ): Promise<T> => { let retry = 0; let cancellation = undefined; const cancel = (message: string) => { retry = MaxRetries; cancellation = `Operation cancelled with message: ${message}`; }; for (;;) { try { return await fn(cancel); } catch (error) { retry++; if (retry >= maxRetries) { const fnStr = fn.toString(); const func = fn.name || (fnStr.length > MaxFuncLength ? fnStr.slice(0, MaxFuncLength) + '...' : fnStr); const message = cancellation !== undefined ? cancellation : isProtocolError(error) ? error.message : error instanceof Error ? error.message : String(error); throw new Error( `Failed to execute '${func}' after ${maxRetries} retries: ${message}` ); } await new Promise(r => setTimeout(r, interval)); } } };