UNPKG

@translated/lara

Version:

Official Lara SDK for JavaScript and Node.js

290 lines (289 loc) 13.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.LaraClient = void 0; const credentials_1 = require("../../credentials"); const crypto_1 = __importDefault(require("../../crypto")); const errors_1 = require("../../errors"); const parse_content_1 = require("../../utils/parse-content"); const sdk_version_1 = require("../../utils/sdk-version"); /** @internal */ class LaraClient { constructor(auth) { this.crypto = (0, crypto_1.default)(); this.extraHeaders = {}; if (auth instanceof credentials_1.AccessKey) { this.accessKey = auth; } else if (auth instanceof credentials_1.AuthToken) { this.authToken = auth; } else { throw new Error("Invalid authentication method provided"); } } setExtraHeader(name, value) { this.extraHeaders[name] = value; } // ───────────────────────────────────────────────────────────────────────────── // Public HTTP Methods // ───────────────────────────────────────────────────────────────────────────── get(path, queryParams, headers) { return this.request("GET", this.buildPathWithQuery(path, queryParams), undefined, undefined, headers); } delete(path, queryParams, body, headers) { return this.request("DELETE", this.buildPathWithQuery(path, queryParams), body, undefined, headers); } post(path, body, files, headers, streamResponse) { return this.request("POST", path, body, files, headers, undefined, streamResponse); } async *postAndGetStream(path, body, files, headers) { for await (const chunk of this.requestStream("POST", path, body, files, headers)) { yield chunk; } } put(path, body, files, headers) { return this.request("PUT", path, body, files, headers); } // ───────────────────────────────────────────────────────────────────────────── // Request Handling // ───────────────────────────────────────────────────────────────────────────── async request(method, path, body, files, headers, retryCount = 0, streamResponse) { await this.ensureAuthenticated(); const normalizedPath = path.startsWith("/") ? path : `/${path}`; const requestHeaders = await this.buildRequestHeaders(body, files, headers); const requestBody = this.buildRequestBody(body, files); requestHeaders.Authorization = `Bearer ${this.token}`; const response = await this.send(method, normalizedPath, requestHeaders, requestBody, streamResponse); if (this.isSuccessResponse(response)) { return streamResponse ? response.body : (0, parse_content_1.parseContent)(response.body); } // Handle 401 - token expired, refresh and retry once if (response.statusCode === 401 && retryCount < 1) { this.token = undefined; await this.refreshOrReauthenticate(); return this.request(method, path, body, files, headers, retryCount + 1, streamResponse); } throw this.createApiError(response); } async *requestStream(method, path, body, files, headers, retryCount = 0) { await this.ensureAuthenticated(); const normalizedPath = path.startsWith("/") ? path : `/${path}`; const requestHeaders = await this.buildRequestHeaders(body, files, headers); const requestBody = this.buildRequestBody(body, files); requestHeaders.Authorization = `Bearer ${this.token}`; for await (const chunk of this.sendAndGetStream(method, normalizedPath, requestHeaders, requestBody)) { // Handle 401 - token expired, refresh and retry once if (chunk.statusCode === 401 && retryCount < 1) { this.token = undefined; await this.refreshOrReauthenticate(); yield* this.requestStream(method, path, body, files, headers, retryCount + 1); return; } // Handle other errors if (!this.isSuccessResponse(chunk)) { throw this.createApiError(chunk); } yield (0, parse_content_1.parseContent)(chunk.body); } } // ───────────────────────────────────────────────────────────────────────────── // Authentication // ───────────────────────────────────────────────────────────────────────────── isTokenExpired(bufferMs = 5000) { if (!this.token) return true; try { const parts = this.token.split("."); if (parts.length !== 3) return true; let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); while (b64.length % 4) b64 += "="; const decoded = typeof Buffer !== "undefined" ? // Node solution Buffer.from(b64, "base64").toString("utf-8") : // Browser solution atob(b64); const { exp } = JSON.parse(decoded); return typeof exp === "number" && exp * 1000 <= Date.now() + bufferMs; } catch { return true; } } async ensureAuthenticated() { if (this.token && !this.isTokenExpired()) return; this.token = undefined; // Use existing promise if authentication is already in progress (mutex pattern) if (this.authenticationPromise) { return this.authenticationPromise; } // Start new authentication with single retry on failure this.authenticationPromise = this.performAuthentication(); try { await this.authenticationPromise; this.authenticationPromise = undefined; } catch (error) { // Clear promise and retry once this.authenticationPromise = undefined; // Only retry if the error is not a 401 or 403 if (error instanceof errors_1.LaraApiError && (error.statusCode === 401 || error.statusCode === 403)) { throw error; } try { this.authenticationPromise = this.performAuthentication(); await this.authenticationPromise; this.authenticationPromise = undefined; } catch (retryError) { this.authenticationPromise = undefined; throw retryError instanceof errors_1.LaraApiError ? retryError : new errors_1.LaraApiError(500, "AuthenticationError", retryError.message); } } } async performAuthentication() { // If we have a pre-existing auth token, use it directly (one-shot, consumed before any network call) if (this.authToken) { this.token = this.authToken.token; this.refreshToken = this.authToken.refreshToken; this.authToken = undefined; return; } return this.doRefreshOrReauthenticate(); } refreshOrReauthenticate() { if (this.refreshPromise) return this.refreshPromise; this.refreshPromise = this.doRefreshOrReauthenticate().finally(() => { this.refreshPromise = undefined; }); return this.refreshPromise; } async doRefreshOrReauthenticate() { if (this.refreshToken) { try { await this.refreshTokens(); return; } catch (error) { this.refreshToken = undefined; if (!this.accessKey) throw error; } } if (this.accessKey) { await this.authenticateWithAccessKey(); return; } throw new Error("No authentication method available for token renewal"); } async refreshTokens() { const headers = { Authorization: `Bearer ${this.refreshToken}`, "X-Lara-Date": new Date().toUTCString(), ...this.extraHeaders }; const response = await this.send("POST", "/v2/auth/refresh", headers); this.handleAuthResponse(response); } async authenticateWithAccessKey() { if (!this.accessKey) { throw new Error("No access key provided"); } const body = { id: this.accessKey.id }; const contentMD5 = await this.crypto.digestBase64(JSON.stringify(body)); const headers = { "Content-Type": "application/json", "X-Lara-Date": new Date().toUTCString(), "Content-MD5": contentMD5, ...this.extraHeaders }; headers.Authorization = `Lara:${await this.sign("POST", "/v2/auth", headers)}`; const response = await this.send("POST", "/v2/auth", headers, body); this.handleAuthResponse(response); } // ───────────────────────────────────────────────────────────────────────────── // Helper Methods // ───────────────────────────────────────────────────────────────────────────── buildPathWithQuery(path, queryParams) { const filteredParams = this.filterNullish(queryParams); if (!filteredParams) return path; return `${path}?${new URLSearchParams(filteredParams).toString()}`; } async buildRequestHeaders(body, files, customHeaders) { const headers = { "X-Lara-Date": new Date().toUTCString(), "X-Lara-SDK-Name": "lara-node", "X-Lara-SDK-Version": sdk_version_1.version, ...this.filterNullish(this.extraHeaders), ...this.filterNullish(customHeaders) }; const filteredBody = this.filterNullish(body); if (files) { headers["Content-Type"] = "multipart/form-data"; } else if (filteredBody) { headers["Content-Type"] = "application/json"; const jsonBody = JSON.stringify(filteredBody, undefined, 0); // The content length header is needed to send delete requests with a body in some environments headers["Content-Length"] = new TextEncoder().encode(jsonBody).length.toString(); } return headers; } buildRequestBody(body, files) { const filteredBody = this.filterNullish(body); if (files) { // Validate and wrap files const wrappedFiles = {}; for (const [key, file] of Object.entries(files)) { wrappedFiles[key] = this.wrapMultiPartFile(file); } return { ...wrappedFiles, ...filteredBody }; } return filteredBody; } filterNullish(obj) { if (!obj) return undefined; const filtered = Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined && v !== null)); return Object.keys(filtered).length > 0 ? filtered : undefined; } isSuccessResponse(response) { return response.statusCode >= 200 && response.statusCode < 300; } handleAuthResponse(response) { if (this.isSuccessResponse(response)) { this.token = response.body.token; const newRefreshToken = response.headers["x-lara-refresh-token"]; if (newRefreshToken) { this.refreshToken = newRefreshToken; } return; } throw this.createApiError(response); } createApiError(response) { const error = response.body || {}; return new errors_1.LaraApiError(response.statusCode, error.type || "UnknownError", error.message || "An unknown error occurred"); } async sign(method, path, headers) { if (!this.accessKey) { throw new Error("Access key not provided for signing"); } const date = headers["X-Lara-Date"].trim(); const contentMD5 = (headers["Content-MD5"] || "").trim(); const contentType = (headers["Content-Type"] || "").trim(); const httpMethod = (headers["X-HTTP-Method-Override"] || method).trim().toUpperCase(); const challenge = `${httpMethod}\n${path}\n${contentMD5}\n${contentType}\n${date}`; return this.crypto.hmac(this.accessKey.secret, challenge); } } exports.LaraClient = LaraClient;