UNPKG

@metacall/protocol

Version:

Tool for deploying into MetaCall FaaS platform.

346 lines (344 loc) 12.7 kB
"use strict"; /* 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 */ Object.defineProperty(exports, "__esModule", { value: true }); exports.waitFor = exports.MaxFuncLength = exports.MaxRetryInterval = exports.MaxRetries = exports.InvokeType = exports.ResourceType = exports.isProtocolError = exports.ProtocolError = void 0; const stream_1 = require("stream"); const url_1 = require("url"); const deployment_1 = require("./deployment"); class ProtocolError extends Error { constructor(message, status, data) { super(message); this.name = 'ProtocolError'; this.status = status; this.data = data; } } exports.ProtocolError = ProtocolError; /** * Type guard for protocol-specific errors. * @param err - The unknown error to check. * @returns True if the error is an ProtocolError, false otherwise. */ const isProtocolError = (err) => err instanceof ProtocolError; exports.isProtocolError = isProtocolError; var ResourceType; (function (ResourceType) { ResourceType["Package"] = "Package"; ResourceType["Repository"] = "Repository"; })(ResourceType = exports.ResourceType || (exports.ResourceType = {})); var InvokeType; (function (InvokeType) { InvokeType["Call"] = "call"; InvokeType["Await"] = "await"; })(InvokeType = exports.InvokeType || (exports.InvokeType = {})); class Request { constructor(token, baseURL) { this.token = token; this.baseURL = baseURL; this.impl = { url: '', headers: new Headers({ Authorization: 'jwt ' + this.token }), method: 'GET', body: undefined }; } url(path) { this.impl.url = new url_1.URL(path, this.baseURL).toString(); return this; } headers(headers = {}) { this.impl.headers = new Headers({ Authorization: 'jwt ' + this.token, ...headers }); return this; } method(method) { this.impl.method = method; return this; } bodyRaw(body) { this.impl.body = body; return this; } body(body) { this.impl.body = JSON.stringify(body); this.impl.headers.set('Content-Type', 'application/json'); return this; } async execute() { const config = { 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() { const res = await this.execute(); return res.json(); } async asText() { const res = await this.execute(); return res.text(); } async asStatus() { const res = await this.execute(); return res.status; } async asResponse() { return await this.execute(); } } exports.default = (token, baseURL) => { const request = (url = baseURL) => new Request(token, url); const hostname = new url_1.URL(baseURL).hostname; const api = { refresh: () => request().url('/api/account/refresh-token').asText(), ready: () => request() .url('/api/readiness') .asStatus() .then(status => status === 200), validate: () => request().url('/validate').asJson(), deployEnabled: () => request().url('/api/account/deploy-enabled').asJson(), listSubscriptions: async () => { const subscriptionsList = await request() .url('/api/billing/list-subscriptions') .asJson(); const subscriptions = {}; for (const id of subscriptionsList) { if (subscriptions[id] === undefined) { subscriptions[id] = 1; } else { ++subscriptions[id]; } } return subscriptions; }, listSubscriptionsDeploys: () => request() .url('/api/billing/list-subscriptions') .asJson(), inspect: () => request().url('/api/inspect').asJson(), inspectByName: async (suffix) => { 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, data, jsons = [], runners = []) => { 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 stream_1.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 = []; 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(); }, add: (url, branch, jsons = []) => request() .url('/api/repository/add') .method('POST') .body({ url, branch, jsons }) .asJson(), branchList: (url) => request() .url('/api/repository/branchlist') .method('POST') .body({ url }) .asJson(), deploy: (name, env, plan, resourceType, release = Date.now().toString(16), version = 'v1') => request() .url('/api/deploy/create') .method('POST') .body({ resourceType, suffix: name, release, env, plan, version }) .asJson(), deployDelete: (prefix, suffix, version = 'v1') => request() .url('/api/deploy/delete') .method('POST') .body({ prefix, suffix, version }) .asJson(), availableJobLogs: (suffix) => request() .url('/api/deploy/availablejoblogs') .method('POST') .body({ suffix }) .asJson(), logs: (container, type = deployment_1.LogType.Deploy, prefix, suffix, version = 'v1') => request() .url('/api/deploy/logs') .method('POST') .body({ container, type, prefix, suffix, version }) .asText(), fileList: (url, branch) => request() .url('/api/repository/filelist') .method('POST') .body({ url, branch }) .asJson() .then(res => res['files']), invoke: async (type, prefix, suffix, version = 'v1', name, args) => { 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(); }, call: (prefix, suffix, version = 'v1', name, args) => api.invoke(InvokeType.Call, prefix, suffix, version, name, args), await: (prefix, suffix, version = 'v1', name, args) => api.invoke(InvokeType.Await, prefix, suffix, version, name, args) }; return api; }; exports.MaxRetries = 100; exports.MaxRetryInterval = 5000; exports.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) * ); * ``` */ const waitFor = async (fn, maxRetries = exports.MaxRetries, interval = exports.MaxRetryInterval) => { let retry = 0; let cancellation = undefined; const cancel = (message) => { retry = exports.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 > exports.MaxFuncLength ? fnStr.slice(0, exports.MaxFuncLength) + '...' : fnStr); const message = cancellation !== undefined ? cancellation : exports.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)); } } }; exports.waitFor = waitFor;