UNPKG

@cerbos/embedded

Version:

Client library for interacting with embedded Cerbos policy decision points generated by Cerbos Hub from server-side Node.js and browser-based applications

262 lines 9.08 kB
import { setErrorNameAndStack, userAgent } from "@cerbos/core/~internal"; import pkg from "../package.json" with { type: "json" }; import { Bundle, download } from "./bundle.js"; import { constrainAutoUpdateInterval } from "./interval.js"; import { DecisionLogger } from "./logger.js"; import { cancelBody } from "./response.js"; import { Transport } from "./transport.js"; const defaultUserAgent = `cerbos-sdk-javascript-embedded/${pkg.version}`; async function resolve(result) { const { bundle, error } = await result; if (error) { throw error; } return bundle; } /** * Error thrown when a {@link Loader} fails to load an embedded policy decision point bundle. */ export class LoadError extends Error { cause; /** @internal */ constructor( /** * The error that caused loading the embedded policy decision point bundle to fail. */ cause) { const message = "Failed to load embedded policy decision point bundle"; super(cause instanceof Error ? `${message}: ${cause.message}` : message, { cause, }); this.cause = cause; setErrorNameAndStack(this); } } /** * Loads an embedded policy decision point bundle from a given source. */ export class Loader { /** @internal */ ["~options"]; /** @internal */ ["~userAgent"]; /** @internal */ ["~transport"] = new Transport(async () => await resolve(this["~active"])); /** @internal */ ["~active"]; logger; /** * Load an embedded policy decision point (PDP) bundle from a given source. * * @param source - WebAssembly binary code of an embedded PDP bundle, or a URL or HTTP response from which to stream it. * @param options - Additional settings. * * @remarks * Bundle download URLs are available in the "Embedded" section of the "Decision points" page of your Cerbos Hub workspace. * * The bundle will be loaded in the background when the loader is created. * If loading fails, then the first request from the client using the loader will throw an error. * To detect failure to load the bundle before making any requests, provide an {@link Options.onError} callback or await the {@link Loader.active} method. * * @example * Fetch an embedded PDP bundle via HTTP in a {@link https://caniuse.com/wasm | supported browser} or Node.js: * * ```typescript * const loader = new Loader("https://lite.cerbos.cloud/bundle?workspace=...&label=..."); * ``` * * @example * Read an embedded PDP bundle from disk in Node.js: * * ```typescript * import { readFile } from "fs/promises"; * * const loader = new Loader(readFile("policies.wasm")); * ``` * * @example * Load an embedded PDP bundle from a precompiled WebAssembly module (requires a bundler): * * ```typescript * import bundle from "bundle.wasm"; * * const loader = new Loader(bundle); * ``` */ constructor(source, options = {}) { this["~options"] = options; this["~userAgent"] = userAgent(options.userAgent, defaultUserAgent); if (options.onDecision) { this.logger = new DecisionLogger(options.onDecision, this["~userAgent"]); } this["~active"] = this["~load"](source, url(source), true); } /** * Resolves to the metadata of the loaded bundle, or rejects with the error that was encountered when loading the bundle. */ async active() { return (await resolve(this["~active"])).metadata; } /** @internal */ get ["~updateSignal"]() { return undefined; } /** @internal */ async ["~load"](source, url, initial = false) { try { const bundle = await Bundle.from(source, url, this.logger, this["~userAgent"], this["~options"]); await this["~onLoad"](bundle, initial); return { bundle }; } catch (cause) { const error = new LoadError(cause); await this["~onError"](error); return { error }; } } /** @internal */ async ["~onLoad"](bundle, _initial) { await this["~options"].onLoad?.(bundle.metadata); } /** @internal */ async ["~onError"](error) { await this["~options"].onError?.(error); } } const notModified = new Error("HTTP 304"); /** * Loads an embedded policy decision point bundle from a given URL, and polls for updates. */ export class AutoUpdatingLoader extends Loader { url; activateOnLoad; interval; activations = 0; _pending; etag; running = true; abortController; timeout; /** * Load an embedded policy decision point bundle from a given URL. * * @param url - URL from which to stream bundles. * @param options - Additional settings. * * @remarks * Bundle download URLs are available in the "Embedded" section of the "Decision points" page of your Cerbos Hub workspace. * * The bundle will be loaded in the background when the loader is created. * If initial loading fails, then the first request from the client using the loader will throw an error. * To detect failure to load the bundle before making any requests, provide an {@link Options.onError} callback or await the {@link Loader.active} method. * * Failure to load updates after the initial load will not cause requests from the client to throw errors, * but errors will be passed to the {@link Options.onError} callback. */ constructor(url, { activateOnLoad = true, interval, ...options } = {}) { super(url, options); this.url = url; this.activateOnLoad = activateOnLoad; this.interval = constrainAutoUpdateInterval(interval); this.scheduleUpdate(); } /** * The metadata of a new embedded policy decision point bundle that has been downloaded but is not yet being used to evaluate policy decisions. * * @remarks * Only set if {@link AutoUpdateOptions.activateOnLoad} is `false` and an update has been downloaded. * * Use {@link AutoUpdatingLoader.activate} to start using the pending bundle to evaluate policy decisions. */ get pending() { return this._pending?.metadata; } /** * Promote the {@link AutoUpdatingLoader.pending | pending} embedded policy decision point bundle (if any) to active, so that it is used to evaluate policy decisions. * * @remarks * This method is a no-op if an update has not been downloaded, or if {@link AutoUpdateOptions.activateOnLoad} is `true` (the default). */ activate() { if (this._pending) { this.activations++; this["~active"] = { bundle: this._pending }; this._pending = undefined; } } /** * Stops polling for new embedded policy decision point bundles, and aborts any in-flight updates. */ stop() { this.running = false; this.abortController?.abort(); if (this.timeout) { clearTimeout(this.timeout); } } /** @internal */ get ["~updateSignal"]() { return this.activations; } /** @internal */ async ["~onLoad"](bundle, initial) { this.etag = bundle.etag; if (!initial) { this._pending = bundle; if (this.activateOnLoad) { this.activate(); } } await super["~onLoad"](bundle, initial); } /** @internal */ async ["~onError"](error) { if (!this.suppressError(error.cause)) { await super["~onError"](error); } } scheduleUpdate() { if (!this.running) { return; } if (this.timeout?.refresh) { this.timeout.refresh(); } else { this.timeout = setTimeout(() => { void this.update(); }, this.interval); } } async update() { this.abortController?.abort(); this.abortController = new AbortController(); await this["~load"](this.download(this.abortController.signal), this.url.toString()); this.scheduleUpdate(); } async download(signal) { const request = { signal }; if (this.etag) { request.headers = { "If-None-Match": this.etag }; } const response = await download(this.url, this["~userAgent"], request); if (response.status === 304) { cancelBody(response); throw notModified; } return response; } suppressError(cause) { return cause === notModified || (isAbortError(cause) && !this.running); } } function isAbortError(error) { return error instanceof DOMException && error.name === "AbortError"; } function url(source) { if (typeof source === "string" || source instanceof URL) { return source.toString(); } return undefined; } //# sourceMappingURL=loader.js.map