@metacall/protocol
Version:
Tool for deploying into MetaCall FaaS platform.
346 lines (344 loc) • 12.7 kB
JavaScript
;
/*
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;