# jg-packages > Small, tested TypeScript packages for coding agents to build on: errors, HTTP requests, storage, files, and processes, with docs inside each one. --- # jg-exception > Coded errors for every jg package: an Exception with a stable code and JSON data, message extraction, code checks, and test assertions. - **Version:** `1.5.0` - **Runtimes:** Universal - **Install:** `npm install jg-exception` - **On npm:** [jg-exception](https://www.npmjs.com/package/jg-exception) - **Source:** [packages/jg-exception](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-exception) - **Recipes:** [jg-exception recipes](https://jg-packages.jesusgraterol.dev/packages/jg-exception/recipes/index.md) ## Usage Throw an `Exception` with a stable code, and optional data that helps whoever handles or debugs it: ```ts import { Exception } from 'jg-exception'; throw new Exception('The user was not found.', { code: 'USER_NOT_FOUND', data: { id } }); ``` Wrap the error underneath as the `cause`. The message says what failed at this step, and the cause keeps the rest: ```ts try { await loadUser(id); } catch (error) { throw new Exception("Couldn't load the user.", { code: 'USER_LOAD_FAILED', cause: error }); } ``` Read the code back wherever the error ends up. It works on any error with a string `code`, such as a Node.js system error: ```ts import { getErrorCode, hasErrorCode, hasErrorCodePrefix, isException } from 'jg-exception'; hasErrorCode(error, 'USER_NOT_FOUND'); // true or false hasErrorCodePrefix(error, 'USER_'); // true for any USER_ code getErrorCode(error); // 'USER_NOT_FOUND', or null when the error has no code isException(error); // true, even for an Exception from another installed copy of the package ``` An Exception turns into JSON with its name, code, message, and data, so it can be logged or sent as is: ```ts JSON.stringify( new Exception('The user was not found.', { code: 'USER_NOT_FOUND', data: { id: 1 } }), ); // '{"name":"Exception","code":"USER_NOT_FOUND","message":"The user was not found.","data":{"id":1}}' String(error); // 'Exception [USER_NOT_FOUND]: The user was not found.' ``` An app's throw sites can also say how serious a failure is, for the code that reports it: `severity` is `'expected'` or `'critical'`, and `shouldLog: true` asks for the error to be logged in full. Neither is serialized, and packages never set them: ```ts throw new Exception('The user was not found.', { code: 'USER_NOT_FOUND', severity: 'expected' }); ``` `data` must be a JSON value, so a `BigInt`, a `Date`, or a class instance is a type error. An object's values may be `undefined`, so `{ email }` works when `email` is optional. An interface has no index signature, so pass an interface-typed value as `{ ...user }`, or declare its type with `type`. Whatever can't be serialized at runtime, such as an object that refers back to itself, becomes `null` in `toJSON()`, and `NaN` and `Infinity` become `null` as in any JSON. Extract a readable message from anything a `catch` block receives: strings, errors with their causes, ZodErrors, and objects with a `message`, `error`, or `reason`: ```ts import { extractMessage } from 'jg-exception'; extractMessage(new Error('Request failed', { cause: new Error('Timed out') })); // 'Request failed; [CAUSE]: Timed out' ``` Turn an error into what users read with `createErrorFeedback`: the copy you wrote for its code, or a fallback, never the error's message, which is written for whoever debugs it: ```ts import { createErrorFeedback } from 'jg-exception'; const getErrorFeedback = createErrorFeedback({ fallbackMessage: 'Something went wrong.', messagesByCode: { SESSION_EXPIRED: 'Your session expired. Sign in again to continue.' }, }); getErrorFeedback(error).message; // the copy for the error's code, or the fallback ``` Assert error codes in tests with any test runner: ```ts import { expectToRejectCode, expectToThrowCode } from 'jg-exception/testing'; expectToThrowCode(() => parseCursor('%%'), 'INVALID_CURSOR'); await expectToRejectCode(loadUser('missing'), 'USER_NOT_FOUND', 'not found'); ``` ### Keeping secrets out of an error The error underneath can hold a secret in its message, stack, or fields, such as the token a request was sent with, and logs, `console.log`, and error trackers print the whole chain. When you wrap it, pass that value as `redact`. It's replaced by `[redacted]` throughout the new exception: its message and data, and each cause's message, stack, and fields, all the way down: ```ts try { await verifyToken(token); } catch (error) { throw new Exception("Couldn't verify the token.", { code: 'TOKEN_INVALID', cause: error, redact: [token], }); } ``` - **Pass whole secrets.** Each value is matched as written and URL-encoded, so a token in a request URL is found too. A fragment as short as `at` would also rewrite names and every stack frame. A value transformed another way, such as base64-encoded or hashed, isn't found, so pass each form a cause may hold. - **What it costs:** each cause becomes a copy that keeps its name, code, message, stack, and plain fields, redacted, and leaves out fields holding objects, such as an HTTP client's `config`. The copy runs none of the cause's own code, such as a custom inspect hook, which could hold a value no redaction reaches. So `instanceof` the cause's original class no longer holds: read its `name` and `code` instead. An Exception cause stays an Exception, so `isException`, `hasErrorCode`, and `toJSON()` work on it. - The values are used while the exception is built, and kept nowhere. To log an error you caught, without rethrowing it, `extractRedactedMessage` gives its message, causes included, with the values redacted: ```ts import { extractRedactedMessage } from 'jg-exception'; extractRedactedMessage(error, [apiKey]); // the message with the key replaced by [redacted] ``` When what you report carries the client's data, such as the body of the request that failed, the secrets are in fields you know by name. `redactFields` copies it with those fields' values replaced by `[redacted]`, at any depth and whatever their case: ```ts import { redactFields } from 'jg-exception'; redactFields(request.body, ['password', 'token']); // { email: '…', password: '[redacted]' } ``` ## jg-exception API reference Every export of `jg-exception@1.5.0`, 23 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. ### `jg-exception` - Functions: [`createErrorFeedback`](#api-createErrorFeedback), [`extractMessage`](#api-extractMessage), [`extractRedactedMessage`](#api-extractRedactedMessage), [`getErrorCode`](#api-getErrorCode), [`hasErrorCode`](#api-hasErrorCode), [`hasErrorCodePrefix`](#api-hasErrorCodePrefix), [`isDefaultErrorMessage`](#api-isDefaultErrorMessage), [`isException`](#api-isException), [`redactFields`](#api-redactFields) - Classes: [`Exception`](#api-Exception) - Constants: [`DEFAULT_MESSAGE`](#api-DEFAULT_MESSAGE), [`EXCEPTION_SEVERITIES`](#api-EXCEPTION_SEVERITIES), [`REDACTED_VALUE`](#api-REDACTED_VALUE), [`UNKNOWN_ERROR_CODE`](#api-UNKNOWN_ERROR_CODE) - Types: [`IErrorFeedback`](#api-IErrorFeedback), [`IErrorFeedbackOptions`](#api-IErrorFeedbackOptions), [`IErrorFeedbackOverrides`](#api-IErrorFeedbackOverrides), [`IExceptionJson`](#api-IExceptionJson), [`IExceptionOptions`](#api-IExceptionOptions), [`IExceptionSeverity`](#api-IExceptionSeverity), [`IJsonValue`](#api-IJsonValue) #### Functions in `jg-exception` ##### `createErrorFeedback` *Function* Creates the function a UI turns errors into feedback with: the copy users read for the error's code, the code itself when it's safe to show, and whether the error means the user cancelled the work or isn't allowed to do it. Configure it once, and pass overrides where a screen needs copy of its own. A code is shown only when a table gives it copy or `publicCodePattern` matches it, so a new or internal code never reaches users, and the error's message, written for whoever debugs it, is never shown. ```ts createErrorFeedback( options: IErrorFeedbackOptions, ): (error: unknown, overrides?: IErrorFeedbackOverrides) => IErrorFeedback ``` **Parameters** - `options` `IErrorFeedbackOptions`: The fallback copy, and optionally the copy by code, the access-denied codes, and the pattern of codes that are safe to show. **Returns** `(error: unknown, overrides?: IErrorFeedbackOverrides) => IErrorFeedback`: A function that gives an error's feedback, with optional overrides for one call. It never throws, whatever error it's given. **Example 1** ```ts import { Exception, createErrorFeedback } from 'jg-exception'; const getErrorFeedback = createErrorFeedback({ fallbackMessage: 'Something went wrong. Try again in a moment.', messagesByCode: { SESSION_EXPIRED: 'Your session expired. Sign in again to continue.' }, accessDeniedCodes: ['SESSION_EXPIRED'], }); const feedback = getErrorFeedback(new Exception('Expired', { code: 'SESSION_EXPIRED' })); feedback.message; // => 'Your session expired. Sign in again to continue.' feedback.code; // => 'SESSION_EXPIRED' feedback.isAccessDenied; // => true // an error without copy gets the fallback, and its code stays hidden getErrorFeedback(new Exception('Disk full', { code: 'DISK_FULL' })).code; // => null ``` **Example 2** ```ts import { Exception, createErrorFeedback } from 'jg-exception'; const getErrorFeedback = createErrorFeedback({ fallbackMessage: 'Something went wrong.', // codes such as AVATAR_TOO_LARGE are safe to show next to the fallback, for support requests publicCodePattern: /^AVATAR_/, }); const error = new Exception('The avatar is larger than 5 MB.', { code: 'AVATAR_TOO_LARGE' }); getErrorFeedback(error).code; // => 'AVATAR_TOO_LARGE' // one screen gives the code copy of its own const overrides = { messagesByCode: { AVATAR_TOO_LARGE: 'Choose an image of 5 MB or less.' } }; getErrorFeedback(error, overrides).message; // => 'Choose an image of 5 MB or less.' ``` ##### `extractMessage` *Function* Extracts a message from an error of any shape: a string, an `Error` (with its causes), a ZodError, or an object holding the message in a common property such as `message`, `error`, or `reason`. Other objects are stringified. When nothing can be extracted, it returns the default message. ```ts extractMessage(error: unknown): string ``` **Parameters** - `error` `unknown`: The error to extract the message from. **Returns** `string`: The extracted message, or `DEFAULT_MESSAGE`. **Example** ```ts import { DEFAULT_MESSAGE, extractMessage } from 'jg-exception'; extractMessage(new Error('Not found')); // => 'Not found' extractMessage({ reason: 'Timed out' }); // => 'Timed out' extractMessage(undefined) === DEFAULT_MESSAGE; // => true ``` ##### `extractRedactedMessage` *Function* Extracts an error message and replaces every exact, case-sensitive occurrence of the sensitive values with `[redacted]`. ```ts extractRedactedMessage(error: unknown, sensitiveValues: string[]): string ``` **Parameters** - `error` `unknown`: The error to extract the message from. - `sensitiveValues` `string[]`: The exact values to redact, such as tokens or passwords. **Returns** `string`: The extracted message with the sensitive values redacted. **Example** ```ts import { extractRedactedMessage } from 'jg-exception'; extractRedactedMessage(new Error('Bad token abc123'), ['abc123']); // => 'Bad token [redacted]' ``` ##### `getErrorCode` *Function* Reads an error's code: the non-empty string in its `code` property, as on an Exception, a Node.js system error, or a provider's error object. ```ts getErrorCode(error: unknown): string | null ``` **Parameters** - `error` `unknown`: The error to read, of any shape. **Returns** `string | null`: The code, or `null` when the error has none. **Example** ```ts import { Exception, getErrorCode } from 'jg-exception'; getErrorCode(new Exception('Not found', { code: 'NOT_FOUND' })); // => 'NOT_FOUND' getErrorCode(new Error('Not found')); // => null ``` ##### `hasErrorCode` *Function* Checks whether an error has a code. The error may also be the code itself. ```ts hasErrorCode(error: unknown, code: string): boolean ``` **Parameters** - `error` `unknown`: The error to check, of any shape. - `code` `string`: The code to look for. **Returns** `boolean`: Whether the error has the code. **Example** ```ts import { Exception, hasErrorCode } from 'jg-exception'; hasErrorCode(new Exception('Not found', { code: 'NOT_FOUND' }), 'NOT_FOUND'); // => true hasErrorCode('NOT_FOUND', 'NOT_FOUND'); // => true ``` ##### `hasErrorCodePrefix` *Function* Checks whether an error's code, or the error itself when it's a string, starts with a prefix. ```ts hasErrorCodePrefix(error: unknown, prefix: string): boolean ``` **Parameters** - `error` `unknown`: The error to check, of any shape. - `prefix` `string`: The non-empty prefix to look for. **Returns** `boolean`: Whether the code starts with the prefix; always `false` for an empty prefix. **Example** ```ts import { Exception, hasErrorCodePrefix } from 'jg-exception'; hasErrorCodePrefix(new Exception('Denied', { code: 'AUTH_EXPIRED' }), 'AUTH_'); // => true ``` ##### `isDefaultErrorMessage` *Function* Checks whether a value is, or contains, the default message the package falls back to. ```ts isDefaultErrorMessage(value: string, fullMatch?: boolean): value is string ``` **Parameters** - `value` `string`: The value to check. - `fullMatch?` `boolean`: Whether the whole value must be the default message. Defaults to `false`. **Returns** `value is string`: Whether the value matches the default message. **Example** ```ts import { extractMessage, isDefaultErrorMessage } from 'jg-exception'; isDefaultErrorMessage(extractMessage(undefined), true); // => true ``` ##### `isException` *Function* Checks whether a value is an `Exception`, including one created by another installed copy of the package, where `instanceof` fails. ```ts isException(value: unknown): value is Exception ``` **Parameters** - `value` `unknown`: The value to check. **Returns** `value is Exception`: Whether the value is an Exception. **Example** ```ts import { Exception, isException } from 'jg-exception'; isException(new Exception('Not found', { code: 'NOT_FOUND' })); // => true isException(new Error('Not found')); // => false ``` ##### `redactFields` *Function* Copies a value, such as a request body headed for an error report, with the values of the named fields replaced by `[redacted]`, however deep they are. Keys match case-insensitively, so `password` also redacts `Password`. Plain objects and arrays are copied, and anything else, such as a Date, is kept as it is; the original is never changed. ```ts redactFields(value: unknown, keys: readonly string[]): unknown ``` **Parameters** - `value` `unknown`: The value. - `keys` `readonly string[]`: The names of the fields to redact, such as `['password', 'token']`. **Returns** `unknown`: The copy, typed `unknown` because any field may now hold `[redacted]` instead of its own type. **Example** ```ts import { redactFields } from 'jg-exception'; const body = { email: 'ada@example.com', profile: { Password: 'hunter2' } }; redactFields(body, ['password']); // => { email: 'ada@example.com', profile: { Password: '[redacted]' } } ``` #### Classes in `jg-exception` ##### `Exception` *Class* An `Error` with a stable string code and optional JSON data, the error every jg package throws. An app's throw sites can also say how serious the failure is with `severity`, and ask for it to be logged with `shouldLog`, for its reporter to read. Neither is serialized. **Members** - `code: string` (property): the stable code callers handle the error by - `data: IJsonValue | undefined` (property): context that helps whoever handles or debugs the error - `severity: "expected" | "critical"` (property): how serious the failure is, only when the throw site gave a known severity - `shouldLog: boolean` (property): whether the error is worth logging in full, only when the throw site asked for it - `new Exception(message: string, options: IExceptionOptions)` (constructor): Creates an exception. - `toJSON(): IExceptionJson` (method): Converts the exception to a plain object, which `JSON.stringify` calls. - `toString(): string` (method): Describes the exception in one line, with its code. **Example 1** ```ts import { Exception } from 'jg-exception'; const exception = new Exception('The user was not found.', { code: 'USER_NOT_FOUND', data: { id: 1 }, }); exception.code; // => 'USER_NOT_FOUND' exception.data; // => { id: 1 } ``` **Example 2** ```ts import { Exception } from 'jg-exception'; // the message says what failed at this step, and the cause keeps the error underneath const readSettings = (text: string): unknown => { try { return JSON.parse(text); } catch (error) { throw new Exception("Couldn't read the settings.", { code: 'SETTINGS_INVALID', cause: error }); } }; readSettings('{"theme":"dark"}'); // => { theme: 'dark' } ``` **Example 3** ```ts import { Exception, extractMessage } from 'jg-exception'; // the values in `redact` are replaced throughout the exception, including in its causes const exception = new Exception("Couldn't verify the token.", { code: 'TOKEN_INVALID', cause: new Error('Invalid token abc123'), redact: ['abc123'], }); extractMessage(exception); // => "Couldn't verify the token.; [CAUSE]: Invalid token [redacted]" ``` **Example 4** ```ts import { Exception } from 'jg-exception'; // an app's throw site says how its reporter treats the failure, without changing the JSON const exception = new Exception('The user was not found.', { code: 'USER_NOT_FOUND', severity: 'expected', }); exception.severity; // => 'expected' exception.shouldLog; // => undefined JSON.stringify(exception).includes('severity'); // => false ``` #### Constants in `jg-exception` ##### `DEFAULT_MESSAGE` *Constant* the message used when none can be extracted from an error ```ts const DEFAULT_MESSAGE: "The error message could not be extracted, check the logs for more information." ``` ##### `EXCEPTION_SEVERITIES` *Constant* the severities an Exception accepts, from the least to the most serious ```ts const EXCEPTION_SEVERITIES: readonly ["expected", "critical"] ``` ##### `REDACTED_VALUE` *Constant* the marker that replaces each redacted value, in messages, data, causes, and fields ```ts const REDACTED_VALUE: "[redacted]" ``` ##### `UNKNOWN_ERROR_CODE` *Constant* the code given to an error that has none, such as when it's sent in an API envelope ```ts const UNKNOWN_ERROR_CODE: "UNKNOWN_ERROR" ``` #### Types in `jg-exception` ##### `IErrorFeedback` *Type* what a UI shows for an error ```ts type IErrorFeedback = { message: string; code: string | null; isCancelled: boolean; isAccessDenied: boolean; }; ``` **Members** - `message: string` (property): the copy for the error's code, or the fallback - `code: string | null` (property): the error's code when it's safe to show, otherwise `null` - `isCancelled: boolean` (property): whether the error is an `AbortError`, which usually means the user cancelled the work - `isAccessDenied: boolean` (property): whether the error's code is one of the access-denied codes ##### `IErrorFeedbackOptions` *Type* how createErrorFeedback turns errors into what users read ```ts type IErrorFeedbackOptions = { fallbackMessage: string; messagesByCode?: Readonly> | undefined; accessDeniedCodes?: Iterable | undefined; publicCodePattern?: RegExp | undefined; }; ``` **Members** - `fallbackMessage: string` (property): what users read for an error without copy of its own - `messagesByCode: Readonly>` (property): the copy users read for each code, written for them rather than for the logs - `accessDeniedCodes: Iterable` (property): the codes that mean the user isn't allowed to do what they tried, such as a revoked session - `publicCodePattern: RegExp` (property): the codes that are safe to show users without copy of their own, such as `/^AUTH_/` ##### `IErrorFeedbackOverrides` *Type* what one call replaces, for a screen whose errors need copy of their own ```ts type IErrorFeedbackOverrides = { fallbackMessage?: string | undefined; messagesByCode?: Readonly> | undefined; }; ``` **Members** - `fallbackMessage: string` (property): what users read for an error without copy of its own, instead of the configured fallback - `messagesByCode: Readonly>` (property): the copy users read for each code, which wins over the configured copy for the same code ##### `IExceptionJson` *Type* an Exception as `toJSON` returns it, ready to log or send ```ts type IExceptionJson = { name: string; code: string; message: string; data: IJsonValue; }; ``` **Members** - `name: string` (property) - `code: string` (property) - `message: string` (property) - `data: IJsonValue` (property) ##### `IExceptionOptions` *Type* the options an Exception is created with ```ts type IExceptionOptions = { code: string; data?: IJsonValue | undefined; cause?: unknown; redact?: readonly string[] | undefined; severity?: IExceptionSeverity | undefined; shouldLog?: boolean | undefined; }; ``` **Members** - `code: string` (property): the stable code callers handle the error by, such as `USER_NOT_FOUND` - `data: IJsonValue` (property): context that helps whoever handles or debugs the error, such as the id that wasn't found - `cause: unknown` (property): the error underneath, kept as the native `cause` - `redact: readonly string[]` (property): values to replace with `[redacted]`, as written and URL-encoded, such as a token the cause may hold. They're redacted from the message, the data, and every cause, which becomes a copy that keeps its name, code, and plain fields without running the cause's own code. Nothing keeps them - `severity: IExceptionSeverity` (property): how serious the failure is, set where an app throws it. Packages never set it, and it's never serialized - `shouldLog: boolean` (property): whether the error is worth writing to the logs in full, such as one too large for the error tracker's storage, set where an app throws it. Packages never set it, and it's never serialized ##### `IExceptionSeverity` *Type* How serious a failure is, for whoever reports it: `expected` for one the app anticipates, such as a resource that doesn't exist, and `critical` for one someone must hear about at once. Leaving it out means an ordinary unexpected failure. ```ts type IExceptionSeverity = (typeof EXCEPTION_SEVERITIES)[number]; ``` ##### `IJsonValue` *Type* A value that survives a trip through JSON. An object's values may also be `undefined`, because `JSON.stringify` drops them, so an object with optional fields fits. Interfaces and classes don't fit, because they have no index signature: pass an interface-typed value as `{ ...value }`, or declare its type with `type`. ```ts type IJsonValue = string | number | boolean | null | IJsonValue[] | { [key: string]: IJsonValue | undefined; }; ``` ### `jg-exception/testing` - Functions: [`expectToRejectCode`](#api-testing-expectToRejectCode), [`expectToThrowCode`](#api-testing-expectToThrowCode) #### Functions in `jg-exception/testing` ##### `expectToRejectCode` *Function* Asserts that a promise rejects with an error containing the expected code and optional message text. Message checks use substring matching. It works with any test runner, because it throws a plain `Error` that the runner reports. ```ts expectToRejectCode( promise: PromiseLike, expectedCode: string, expectedMessage?: string, ): Promise ``` **Parameters** - `promise` `PromiseLike`: The promise expected to reject. - `expectedCode` `string`: The expected error code. - `expectedMessage?` `string`: Optional text that the extracted rejection message must contain. **Returns** `Promise`: A promise that resolves with undefined when the rejection matches. **Throws** - Expected the promise to reject with error code "{expectedCode}", but it resolved. - Expected error code "{expectedCode}", but the error did not include a code. - Expected error code "{expectedCode}", but received error code "{actualCode}". - Expected error message to include "{expectedMessage}", but received error message "{actualMessage}". **Example** ```ts import { expectToRejectCode } from 'jg-exception/testing'; await expectToRejectCode( Promise.reject(Object.assign(new Error('Access denied'), { code: 'ACCESS_DENIED' })), 'ACCESS_DENIED', 'Access denied', ); ``` ##### `expectToThrowCode` *Function* Asserts that a synchronous operation throws an error with the expected code and optional message text. Message checks use substring matching. It works with any test runner, because it throws a plain `Error` that the runner reports. ```ts expectToThrowCode( operation: () => unknown, expectedCode: string, expectedMessage?: string, ): void ``` **Parameters** - `operation` `() => unknown`: The synchronous operation expected to throw. - `expectedCode` `string`: The expected error code. - `expectedMessage?` `string`: Optional text that the extracted error message must contain. **Throws** - Expected the operation to throw error code "{expectedCode}", but it did not throw. - Expected error code "{expectedCode}", but the error did not include a code. - Expected error code "{expectedCode}", but received error code "{actualCode}". - Expected error message to include "{expectedMessage}", but received error message "{actualMessage}". **Example** ```ts import { expectToThrowCode } from 'jg-exception/testing'; expectToThrowCode( () => { throw Object.assign(new Error('Access denied'), { code: 'ACCESS_DENIED' }); }, 'ACCESS_DENIED', 'Access denied', ); ``` --- # jg-exception recipes Patterns for apps that throw coded errors, report them, show them to users, keep secrets out of them, and handle them by their code. ## Severity and logging flags for an error reporter Use this in an app that reports its errors, such as to its logs and an error tracker, when the place that throws an error knows better than the reporter how serious it is. `severity` says it: `'expected'` for a failure the app anticipates, such as a user that doesn't exist, and `'critical'` for one someone must hear about at once, such as a charge without a matching record. `shouldLog: true` asks for the error to be written to the logs in full, such as one whose details are too large for the error tracker's storage. Leaving both out means an ordinary unexpected failure: ```ts import { Exception } from 'jg-exception'; type IUser = { id: number; name: string }; // finds a user by id, such as with a database query declare function findUser(userId: number): Promise; // compares what a payment provider charged for an invoice with what the ledger recorded declare function compareLedger(invoiceId: number): Promise<{ charged: bigint; recorded: bigint }>; export const getUser = async (userId: number): Promise => { const user = await findUser(userId); if (user === null) { // the caller asked for a user that doesn't exist, which the app expects to happen throw new Exception('The user was not found.', { code: 'USER_NOT_FOUND', data: { userId }, severity: 'expected', }); } return user; }; export const checkInvoice = async (invoiceId: number): Promise => { const { charged, recorded } = await compareLedger(invoiceId); if (charged !== recorded) { // money moved without a matching record: someone must look at it now, with the full details throw new Exception("The ledger doesn't match the charge.", { code: 'LEDGER_MISMATCH', data: { invoiceId, charged: String(charged), recorded: String(recorded) }, severity: 'critical', shouldLog: true, }); } }; ``` Neither flag is serialized: `toJSON()`, which jg-api-response's `buildResponse` reads, gives only the name, code, message, and data, so clients never see them. They also survive `redact`, so a wrapped cause keeps its own. For an HTTP status, jg-nestjs's `ApiException` is an Exception that adds one. Read the flags in one place, where every error is reported, such as the app's error handler: ```ts import { extractMessage, getErrorCode, isException } from 'jg-exception'; // writes a line to the app's logs declare const logger: { error(message: string, details: object): void }; // sends an error to the error tracker, such as Sentry's captureException declare function captureError(error: unknown): void; // pages whoever is on call declare function notifyOnCall(error: unknown): void; // logs, captures, and escalates an error as its throw site asked export const reportError = (error: unknown): void => { // an error that isn't an Exception, such as a bug's TypeError, has no flags const severity = isException(error) ? error.severity : undefined; if (severity === 'expected') { return; } if (!isException(error) || error.shouldLog === true || severity === 'critical') { // the message joins the causes, which the logs need and the client never sees logger.error(extractMessage(error), { code: getErrorCode(error) }); } captureError(error); if (severity === 'critical') { notifyOnCall(error); } }; ``` An error that isn't an Exception wasn't expected, so it's logged and captured. An Exception from another package has neither flag, because packages never set them: it's an ordinary failure, captured but not logged in full. When you expect one, wrap it in an Exception with `severity: 'expected'` where you call the code that throws it, with the error as its `cause`. ## Feedback for the UI Use this in a browser app, where most errors are expected, such as a file that's too large or a session that expired, and the user needs to know what to do next. What users read comes from copy you write for each code, never from the error's message, which is written for whoever debugs it: it can name an id or an internal step, and it isn't translated. `createErrorFeedback` holds that copy, configured once: ```ts import { createErrorFeedback, Exception } from 'jg-exception'; // the copy users read for each code, and the codes that send them back to sign in export const getErrorFeedback = createErrorFeedback({ fallbackMessage: 'Something went wrong. Try again in a moment.', messagesByCode: { AVATAR_TOO_LARGE: 'Choose an image of 5 MB or less.', SESSION_EXPIRED: 'Your session expired. Sign in again to continue.', USER_NOT_FOUND: 'This account no longer exists.', }, accessDeniedCodes: ['SESSION_EXPIRED'], }); // the largest avatar the app uploads, in bytes const MAX_AVATAR_BYTES = 5 * 1024 * 1024; // checks the image a user picked before it's uploaded export const checkAvatar = (file: File): void => { if (file.size > MAX_AVATAR_BYTES) { // the user can pick a smaller image, so the error tracker doesn't need it throw new Exception('The avatar is larger than 5 MB.', { code: 'AVATAR_TOO_LARGE', data: { sizeBytes: file.size, maxSizeBytes: MAX_AVATAR_BYTES }, severity: 'expected', }); } }; ``` Handle errors where the UI catches them, such as in an event handler or an error boundary: ```ts import { type createErrorFeedback, isException } from 'jg-exception'; // the function from the block above declare const getErrorFeedback: ReturnType; // sends an error to the error tracker, such as Sentry's captureException declare function captureError(error: unknown): void; // shows a short message to the user, such as in a toast declare function showToast(message: string): void; // sends the user to the sign-in page declare function redirectToSignIn(): void; // tells the user what happened, and captures the errors the app didn't expect export const showError = (error: unknown, fallbackMessage?: string): void => { const feedback = getErrorFeedback(error, { fallbackMessage }); // a cancelled request, such as when the user left the page, needs no message if (feedback.isCancelled) { return; } if (!isException(error) || error.severity !== 'expected') { captureError(error); } if (feedback.isAccessDenied) { redirectToSignIn(); return; } showToast(feedback.message); }; ``` A screen passes a fallback of its own, such as `showError(error, "Couldn't upload the avatar.")`, and keeps the copy for every code. The codes come from anywhere: your Exceptions, your API's envelope errors, which jg-fetch's FetchException carries as its `code`, and other packages, such as jg-clipboard. A code without copy gets the fallback, so a new code never shows users a developer's message, and `feedback.code` is `null` for it unless `publicCodePattern` marks it safe to show, such as for a support request. If your API already reports its own failures, skip the errors your API client returns for them, so the tracker doesn't get each one twice. ## Keeping secrets out of a cause Use `redact` when you wrap an error that can hold a credential your code passed in, such as a token, a password, or a one-time code. A library's error can echo the request it sent in its message, its stack, or its fields, and whatever logs or tracks the new exception prints the whole chain. Exchanging an OAuth authorization code for tokens is a typical case: the code, the PKCE verifier, and the client secret all go into the request: ```ts import { Exception } from 'jg-exception'; type ITokens = { accessToken: string; refreshToken: string }; // the OAuth client's secret, from the environment declare const clientSecret: string; // exchanges an authorization code for tokens, such as with an OAuth library, whose errors can // carry the request it sent declare function requestTokens(request: { code: string; codeVerifier: string; clientSecret: string; }): Promise; export const exchangeCode = async (code: string, codeVerifier: string): Promise => { try { return await requestTokens({ code, codeVerifier, clientSecret }); } catch (error) { // each value the request carried is a credential, wherever the cause holds it throw new Exception("Couldn't exchange the authorization code for tokens.", { code: 'OAUTH_CODE_EXCHANGE_FAILED', cause: error, redact: [code, codeVerifier, clientSecret], }); } }; ``` Pass every secret the call was given, not only the one you expect in the error: you can't tell which one a library echoes. Each value is also matched URL-encoded, so a code with a `/` is found where a URL or a form body carries it as `%2F`. A handler that needs to know why the provider refused, such as for a code that expired, reads the cause's `code` or `name`: the cause is now a redacted copy that keeps them, and it isn't `instanceof` the library's error class anymore. When the error ends where you catch it, because the work goes on without that step, only a log line leaves the `catch`. `extractRedactedMessage` gives that line: the error's message with its causes, with the values redacted. Revoking a refresh token at sign-out is such a step, because the session ends either way: ```ts import { extractRedactedMessage } from 'jg-exception'; // writes a line to the app's logs declare const logger: { warn(message: string, details: object): void }; // asks the provider to revoke a token, such as with an OAuth library declare function revokeToken(token: string): Promise; // clears the session cookie and the stored tokens declare function clearSession(): Promise; export const signOut = async (refreshToken: string): Promise => { try { await revokeToken(refreshToken); } catch (error) { // signing out goes on without the revocation, so the error ends here, as a log line. Unlike // redact, this matches values only as written, so the URL-encoded form is passed too logger.warn("Couldn't revoke the refresh token.", { error: extractRedactedMessage(error, [refreshToken, encodeURIComponent(refreshToken)]), }); } await clearSession(); }; ``` Only the message is redacted, so log that string and nothing else: passing `error` itself to the logger or the error tracker would send its stack and fields as they are. To track the failure, wrap it in an Exception with `redact`, and send that. ## Redacting a request in an error report Use `redactFields` when an error report carries the request that failed, such as its body, so the error tracker shows what the client sent. The body is the client's, so you don't know its secrets' values, as `redact` needs, but you know the fields that hold them: a password, a token, or personal data such as an email address. The copy replaces their values wherever they are, in nested objects and arrays too, and matches the names whatever their case: ```ts import { redactFields } from 'jg-exception'; // the fields that hold credentials or personal data in the API's requests const SENSITIVE_FIELDS = ['email', 'googleIdToken', 'password', 'token']; // sends an error to the error tracker with what to show beside it, such as Sentry's // captureException declare function captureError(error: unknown, context: { extra: Record }): void; export const reportRequestError = ( error: unknown, request: { method: string; path: string; body: unknown }, ): void => { captureError(error, { extra: { request: `${request.method} ${request.path}`, body: redactFields(request.body, SENSITIVE_FIELDS), }, }); }; ``` Only the fields you name are redacted, so add one whenever a request gains a sensitive field. A class instance in the body, such as a `Date`, is kept as it is rather than walked, and the request's own body is never changed, so the handler that runs after the report still sees what the client sent. ## Handling an error by its code Branch on codes, never on messages: a message can be reworded in any release, and a code is stable. `hasErrorCode` and `getErrorCode` read a string `code` from any error, so the same check works on an Exception, a Node.js system error, and a database driver's error. Use `hasErrorCode` for the one failure a step expects, and wrap everything else. A settings file that doesn't exist yet is the usual case: Node.js rejects with `ENOENT`, which means the user never saved settings, not that reading failed: ```ts import { readFile } from 'node:fs/promises'; import { Exception, hasErrorCode } from 'jg-exception'; type ISettings = { theme: 'light' | 'dark'; language: string }; // a Zod schema, or any validator with parse declare const SettingsSchema: { parse(value: unknown): ISettings }; // the settings of a user who never saved any const DEFAULT_SETTINGS: ISettings = { theme: 'light', language: 'en' }; export const loadSettings = async (path: string): Promise => { let text: string; try { text = await readFile(path, 'utf8'); } catch (error) { if (hasErrorCode(error, 'ENOENT')) { return DEFAULT_SETTINGS; } // any other code, such as EACCES, is a real failure, with the system error as the cause throw new Exception("Couldn't read the settings file.", { code: 'SETTINGS_READ_FAILED', data: { path }, cause: error, }); } return SettingsSchema.parse(JSON.parse(text)); }; ``` At a module's boundary, turn a library's code into one of your own, so your callers don't depend on the library. Export your codes as an `as const` map, so callers compare against it. Here PostgreSQL's code for a unique violation, `23505`, becomes `EMAIL_TAKEN`: ```ts import { Exception, hasErrorCode } from 'jg-exception'; // the codes this module throws, which callers compare against export const ERRORS = { EMAIL_TAKEN: 'EMAIL_TAKEN', USER_CREATE_FAILED: 'USER_CREATE_FAILED', } as const; // PostgreSQL's code for a unique violation, which drivers such as pg set as the error's code const UNIQUE_VIOLATION = '23505'; // inserts the user's row, such as with pg's pool.query; email is the table's only unique column declare function insertUser(email: string): Promise<{ id: number }>; export const createUser = async (email: string): Promise<{ id: number }> => { try { return await insertUser(email); } catch (error) { if (hasErrorCode(error, UNIQUE_VIOLATION)) { throw new Exception('The email is already registered.', { code: ERRORS.EMAIL_TAKEN, cause: error, }); } throw new Exception("Couldn't create the user.", { code: ERRORS.USER_CREATE_FAILED, cause: error, }); } }; // a sign-up form shows a taken email next to its field, and rethrows anything else export const signUp = async (email: string): Promise<{ emailProblem: string } | null> => { try { await createUser(email); return null; } catch (error) { if (hasErrorCode(error, ERRORS.EMAIL_TAKEN)) { return { emailProblem: 'This email is already registered.' }; } throw error; } }; ``` Your codes are part of the module's contract: renaming one breaks every caller that compares against it. When one place chooses between several outcomes, `switch` on `getErrorCode`, which returns `null` for an error without a code, and use `hasErrorCodePrefix` for a family of codes that share a prefix. Here a queue worker decides what to do with an email job that failed: ```ts import { getErrorCode, hasErrorCodePrefix } from 'jg-exception'; // what the worker does with a job that failed type IJobOutcome = 'retry' | 'drop' | 'fail'; // chooses a failed job's outcome by the error's code, from the app, a library, or Node.js export const chooseOutcome = (error: unknown): IJobOutcome => { // every RECIPIENT_ code, such as RECIPIENT_UNSUBSCRIBED, means the address won't take the // email, so a retry can't help if (hasErrorCodePrefix(error, 'RECIPIENT_')) { return 'drop'; } switch (getErrorCode(error)) { // Node.js network errors and the provider's rate limit pass, so the next attempt may succeed case 'ECONNRESET': case 'ETIMEDOUT': case 'RATE_LIMITED': return 'retry'; // anything else needs a person, so the job fails and the error is reported default: return 'fail'; } }; ``` `getErrorCode` and the other checks read the error's own code, not its causes'. So when you wrap an error, give the wrapper the code its callers branch on, as `createUser` does above, instead of leaving that code on the cause. --- # jg-api-response > Build and recognise the success, data, and error envelope that services exchange over HTTP, with errors as a code, a message, and data. - **Version:** `1.1.1` - **Runtimes:** Universal - **Install:** `npm install jg-api-response` - **On npm:** [jg-api-response](https://www.npmjs.com/package/jg-api-response) - **Source:** [packages/jg-api-response](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-api-response) - **Recipes:** [jg-api-response recipes](https://jg-packages.jesusgraterol.dev/packages/jg-api-response/recipes/index.md) ## Usage Build the body a service responds with. A response is successful when no error is passed: ```ts import { buildResponse } from 'jg-api-response'; buildResponse({ id: 1 }); // { success: true, data: { id: 1 }, error: null } ``` A failed response carries the error as `{ code, message, data }`, so the client gets everything it needs to handle or debug it. An `Exception` from `jg-exception` keeps its code, message, and data: ```ts import { Exception } from 'jg-exception'; buildResponse( undefined, new Exception('The user was not found.', { code: 'USER_NOT_FOUND', data: { id: 1 } }), ); // { // success: false, // data: undefined, // error: { code: 'USER_NOT_FOUND', message: 'The user was not found.', data: { id: 1 } }, // } ``` Any other error keeps its string `code`, such as a Node.js `ENOENT`, or gets `UNKNOWN_ERROR`, with its message and `null` data: ```ts buildResponse(undefined, new Error('Request failed')); // { success: false, data: undefined, error: { code: 'UNKNOWN_ERROR', message: 'Request failed', data: null } } ``` The envelope can always be serialized when its data can: Exception data that JSON can't hold becomes `null`. Check a body you received, after `JSON.parse` dropped its undefined fields, or from a service that leaves out `error` or its `data`: ```ts import { isResponse } from 'jg-api-response'; const body: unknown = await response.json(); if (isResponse(body)) { body.success; // boolean body.data; // IUser | undefined body.error; // { code: string; message: string; data?: unknown } | null | undefined } ``` `isResponse` checks the error's `code` and `message`, but not its `data`, which is `unknown` until you check it. ## jg-api-response API reference Every export of `jg-api-response@1.1.1`, 6 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. - Functions: [`buildResponse`](#api-buildResponse), [`isResponse`](#api-isResponse) - Types: [`IApiError`](#api-IApiError), [`IApiResponse`](#api-IApiResponse), [`IParsedApiError`](#api-IParsedApiError), [`IParsedApiResponse`](#api-IParsedApiResponse) ### Functions #### `buildResponse` *Function* Builds the envelope for an HTTP body. The response is successful when no error is passed. Any error that is passed, even a falsy one, becomes `{ code, message, data }`: an Exception keeps all three, and any other error keeps its string `code`, or gets `UNKNOWN_ERROR`, and its message. A failed response can carry data too. ```ts buildResponse(data?: T, error?: unknown): IApiResponse ``` **Parameters** - `data?` `T`: The data to send. - `error?` `unknown`: The error the request failed with, of any shape. **Returns** `IApiResponse`: The envelope, which `JSON.stringify` can always serialize when the data can. **Example** ```ts import { buildResponse } from 'jg-api-response'; import { Exception } from 'jg-exception'; buildResponse({ id: 1 }); // => { success: true, data: { id: 1 }, error: null } buildResponse(undefined, new Exception('Not found', { code: 'NOT_FOUND', data: { id: 1 } })); // { success: false, data: undefined, error: { code: 'NOT_FOUND', message: 'Not found', data: { id: 1 } } } ``` #### `isResponse` *Function* Checks whether a value is an envelope, including one parsed from JSON or sent by another service: an object with a boolean `success`, an `error` that is `null`, missing, or an object with a string `code` and `message`, and any `data` or none. The error's `data` isn't checked. ```ts isResponse(value: unknown): value is IParsedApiResponse ``` **Parameters** - `value` `unknown`: The value to check, such as a parsed response body. **Returns** `value is IParsedApiResponse`: Whether the value is an envelope. **Example** ```ts import { isResponse } from 'jg-api-response'; type IUser = { id: number; name: string }; const body: unknown = JSON.parse('{"success":true,"data":{"id":1,"name":"Ada"},"error":null}'); isResponse(body); // => true if (isResponse(body) && body.success) { body.data; // IUser | undefined } ``` ### Types #### `IApiError` *Type* an error as an envelope carries it to the client ```ts type IApiError = { code: string; message: string; data: IJsonValue; }; ``` **Members** - `code: string` (property): the stable code the client handles the error by, or `UNKNOWN_ERROR` when the error had none - `message: string` (property): what failed - `data: IJsonValue` (property): the context the error carried, or `null` when it carried none #### `IApiResponse` *Type* The envelope a service sends in an HTTP body. A response is successful when it has no error, and it carries data either way. ```ts type IApiResponse = { success: boolean; data: T; error: IApiError | null; }; ``` **Members** - `success: boolean` (property): whether the request succeeded, which is when `error` is null - `data: T` (property): the data sent to the client, whatever the outcome - `error: IApiError | null` (property): the error the request failed with, or null when it succeeded #### `IParsedApiError` *Type* An envelope error as it arrives from any service. `data` may be missing, and it's `unknown`, because `isResponse` doesn't check it: whoever reads it checks it first. ```ts type IParsedApiError = { code: string; message: string; data?: unknown; }; ``` **Members** - `code: string` (property) - `message: string` (property) - `data: unknown` (property) #### `IParsedApiResponse` *Type* An envelope as it arrives after `JSON.parse`: `undefined` fields are gone, and services may send `error: null` on success or leave `error` out. ```ts type IParsedApiResponse = { success: boolean; data?: T; error?: IParsedApiError | null; }; ``` **Members** - `success: boolean` (property) - `data: T` (property) - `error: IParsedApiError | null` (property) --- # jg-api-response recipes Patterns for APIs that answer every error with a status and an envelope, and for code that reads another service's envelope. ## One error handler for every route Use one function to turn whatever a route throws into an HTTP status and an envelope, and call it from your framework's error hook, such as Express's error middleware or Hono's `app.onError`, so every route answers errors the same way. It's a plain function, so it fits any framework: the hook sends the body it returns, with its status. ```ts import { buildResponse, type IApiResponse } from 'jg-api-response'; import { Exception, isException, UNKNOWN_ERROR_CODE } from 'jg-exception'; import { ERRORS as UTILITIES_ERRORS } from 'jg-utilities'; // the app's exception with an HTTP status, such as the ApiException in jg-exception's recipes declare class ApiException extends Exception { readonly status: number; } // writes an error to the logs and sends it to the error tracker, such as Sentry declare function reportError(error: unknown): void; // the status for each code thrown without one, such as by a jg package; any other code is a 500 const STATUS_BY_CODE: ReadonlyMap = new Map([ ['INVALID_REQUEST', 400], [UTILITIES_ERRORS.INVALID_CURSOR, 400], ]); // what the client reads for a server error, whose own message can name a path, a host, or a query const INTERNAL_ERROR_MESSAGE = 'Something went wrong on our side. Try again in a moment.'; // a status, and the envelope sent with it type IErrorResponse = { status: number; body: IApiResponse }; // reads the status an error is answered with const readStatus = (error: unknown): number => { if (error instanceof ApiException) { return error.status; } return isException(error) ? (STATUS_BY_CODE.get(error.code) ?? 500) : 500; }; // turns anything a route throws into the status and the envelope the client gets export const handleError = (error: unknown): IErrorResponse => { const status = readStatus(error); if (status < 500) { // a client error's message and data say what to fix return { status, body: buildResponse(null, error) }; } reportError(error); // the client keeps the code to branch on, or gets UNKNOWN_ERROR for an error that isn't an // Exception, such as a bug or a database driver's error, whose own code is an internal const code = isException(error) ? error.code : UNKNOWN_ERROR_CODE; return { status, body: buildResponse(null, new Exception(INTERNAL_ERROR_MESSAGE, { code })) }; }; ``` - **The status comes from your own class, or from the code.** Don't read a `status` field from any error: jg-fetch's FetchException has one too, the status another service answered you with, and a 404 from a service you call is your server's failure, not the client's. For the same reason, wrap what a service you call throws in a code of your own where you call it, so its codes never reach the table. - **An error that isn't an Exception is a 500 with `UNKNOWN_ERROR`.** A schema's error is one, so wrap it where you parse, as the route below does, and the client gets a 400 it can act on. - **The cause never reaches the client.** `buildResponse` sends an Exception's code, message, and data, so a 4xx's cause stays in your logs. A 5xx's message and data stay there too, because the handler sends its code with a fixed message. A route is then a plain function that returns the envelope for a success and throws for anything else: ```ts import { buildResponse, type IApiResponse } from 'jg-api-response'; import { Exception, extractMessage } from 'jg-exception'; type IUser = { id: number; name: string }; type IUserChanges = { name: string }; // a Zod schema, or any validator with parse declare const UserChangesSchema: { parse(value: unknown): IUserChanges }; // saves the changes to a user, and throws an ApiException with a 404 when there's no such user declare function updateUser(userId: number, changes: IUserChanges): Promise; // the framework passes the request in and sends the envelope with a 200, and its error hook passes // anything thrown to handleError export const updateUserRoute = async ( userId: number, body: unknown, ): Promise> => { let changes: IUserChanges; try { changes = UserChangesSchema.parse(body); } catch (error) { // the schema's error has no code the handler knows, so it's wrapped in one answered with a 400, // and its first issue goes in the data, so the client can tell which field failed throw new Exception('The request body is invalid.', { code: 'INVALID_REQUEST', data: { issue: extractMessage(error) }, cause: error, }); } return buildResponse(await updateUser(userId, changes)); }; ``` ## Reading another service's envelope Use `isResponse` where an envelope reaches you without jg-fetch, such as in a body from `fetch` or another HTTP client, a WebSocket message, or a queue job's result; jg-fetch's `request` makes these checks itself, with `parseAs: 'envelope'` and a `schema`. `isResponse` checks the envelope's shape, so you can read `success` and `error`, but not its data, which your schema checks: ```ts import { isResponse } from 'jg-api-response'; import { Exception } from 'jg-exception'; type IInvoice = { id: string; totalCents: number }; // the codes this module throws export const ERRORS = { INVOICE_NOT_FOUND: 'INVOICE_NOT_FOUND', BILLING_REQUEST_FAILED: 'BILLING_REQUEST_FAILED', BILLING_RESPONSE_INVALID: 'BILLING_RESPONSE_INVALID', } as const; // the billing service's origin declare const billingUrl: string; // a Zod schema, or any validator with parse declare const InvoiceSchema: { parse(value: unknown): IInvoice }; // reads an invoice from the billing service, which answers with an envelope export const getInvoice = async (invoiceId: string): Promise => { const response = await fetch(`${billingUrl}/invoices/${encodeURIComponent(invoiceId)}`); // a proxy's error page isn't JSON, so it's treated like any other body that isn't an envelope const body: unknown = await response.json().catch(() => null); if (!isResponse(body)) { throw new Exception("The billing service's response isn't an envelope.", { code: ERRORS.BILLING_RESPONSE_INVALID, data: { status: response.status }, }); } if (body.error || !body.success) { // the service's codes are its own: the one callers handle becomes this module's, and the rest // share one code, so the service's INVALID_REQUEST never reads as your client's throw new Exception("The billing service couldn't return the invoice.", { code: body.error?.code === 'INVOICE_NOT_FOUND' ? ERRORS.INVOICE_NOT_FOUND : ERRORS.BILLING_REQUEST_FAILED, data: { invoiceId, status: response.status }, cause: body.error, }); } return InvoiceSchema.parse(body.data); }; ``` Leave out the type argument: `isResponse(body)` would type `body.data` as `IInvoice | undefined` without looking at it, so the schema checks the data instead. A body with an `error` failed, whatever its `success` says, and so did one with `success: false` and no `error`, which `isResponse` also accepts. The service's error is the cause, so `extractMessage` and your logs show its message after yours. Its `data` is `unknown`, because `isResponse` doesn't check it: check it before you copy anything from it into your own data. --- # jg-utilities > Validations, formatters, secure random generators, pagination cursors, and collection, async, and environment helpers. - **Version:** `1.4.0` - **Runtimes:** Universal - **Install:** `npm install jg-utilities` - **On npm:** [jg-utilities](https://www.npmjs.com/package/jg-utilities) - **Source:** [packages/jg-utilities](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-utilities) - **Recipes:** [jg-utilities recipes](https://jg-packages.jesusgraterol.dev/packages/jg-utilities/recipes/index.md) ## Usage Validate input before using it: ```ts import { isBigIntStringValid, isEmailValid, isIntegerValid, isStringValid } from 'jg-utilities'; isStringValid(name, 1, 100); // a non-blank string of 1 to 100 characters isIntegerValid(page, 1); // an integer of at least 1 isEmailValid(email); // a valid address without a forbidden extension isBigIntStringValid(id, 1n); // a positive integer string that fits PostgreSQL's BIGINT ``` Format values for people: ```ts import { escapeHtml, pluralize, prettifyDate, prettifyFileSize, prettifyNumber, toSlug, } from 'jg-utilities'; prettifyNumber(1234567.891); // '1,234,567.89' prettifyNumber(-5, { prefix: '$' }); // '-$5' prettifyFileSize(1536); // '1.50 kB' prettifyDate(createdAt, 'date-medium'); // 'October 17, 2026' toSlug('Héllo, World! 2026'); // 'hello-world-2026' `${count} ${pluralize(count, 'file')}`; // '1 file', '3 files' escapeHtml(name); // safe to put into HTML, such as an email's body ``` Send someone back where they were after signing in, only within the site and never back to the sign-in page: ```ts import { resolveSafeInternalPath } from 'jg-utilities'; const returnTo = resolveSafeInternalPath(searchParams.get('returnTo'), ['/sign-in']) ?? '/'; ``` Keep secrets out of logs, and read the YouTube links people paste: ```ts import { parseYouTubeVideoId, redactSecrets, redactUrlQueryParams, toYouTubeEmbedUrl, } from 'jg-utilities'; redactUrlQueryParams(request.url, ['token']); // the path alone when the query holds a token redactSecrets(logLine).text; // GitHub tokens, sk- keys, and private keys become [redacted] const videoId = parseYouTubeVideoId(pastedUrl); // the id, or null for anything but one video const embedUrl = videoId === null ? undefined : toYouTubeEmbedUrl(videoId); ``` Generate secure random values, IDs, and timestamps: ```ts import { createTimestamp, generateRandomString, generateUuid } from 'jg-utilities'; generateRandomString(16); // drawn with crypto.getRandomValues, fit for secrets generateUuid(7); // a time-ordered UUID createTimestamp(); // '2026-10-17T08:30:00.000Z' ``` Work with UTC calendar dates, such as the days a report covers, and fit text into a byte budget, such as a push notification's payload limit: ```ts import { generateUtcDateSequence, getTrailingUtcDateRange, toUtcDate, truncateToUtf8Bytes, } from 'jg-utilities'; toUtcDate(createdAt); // '2026-10-17', the UTC day of an instant const range = getTrailingUtcDateRange(30); // the last 30 UTC days, today included generateUtcDateSequence(range); // one 'YYYY-MM-DD' per day, oldest first truncateToUtf8Bytes(body, 2048); // whole code points only, at most 2048 bytes in UTF-8 ``` Run side effects away from the code that triggers them with an in-process event bus: ```ts import { createEventBus } from 'jg-utilities'; type IAppEvents = { userSignedUp: { userId: string } }; const events = createEventBus({ onError: (error) => logger.error(error) }); events.subscribe('userSignedUp', ({ userId }) => sendWelcomeEmail(userId)); events.emit('userSignedUp', { userId }); // returns at once; each handler runs later, on its own ``` Paginate with opaque cursors, and wait or retry with timing in seconds: ```ts import { decodeCursor, encodeCursor, retryAsyncFunction, waitUntil } from 'jg-utilities'; const cursor = encodeCursor({ createdAt: lastRow.createdAt, id: lastRow.id }); const continuation = decodeCursor(cursorFromClient); // throws INVALID_CURSOR for anything else await retryAsyncFunction(() => fetchData(), [3, 5]); // retries after 3, then 5 seconds await waitUntil(() => readStatus() === 'ready' || undefined, { timeoutSeconds: 30 }); ``` Give each list a cursor codec that validates its cursors and accepts them only for the filters they were made for, and keep a rate-limited API's requests within its limits: ```ts import { createCursorCodec, createRequestCoordinator } from 'jg-utilities'; const paymentCursors = createCursorCodec({ schema: PaymentCursorSchema }); // a Zod schema, say const after = paymentCursors.decode(request.query.cursor, { status }); // INVALID_CURSOR otherwise const github = createRequestCoordinator({ maxConcurrent: 4, maxQueued: 64 }); const issues = await github.run(() => fetchIssues(), { caller: installationId, maxQueuedForCaller: 8, }); github.deferUntil(retryAt); // queued requests wait out a Retry-After, without holding a slot ``` ## jg-utilities API reference Every export of `jg-utilities@1.4.0`, 125 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. - Functions: [`applyDefaults`](#api-applyDefaults), [`applySubstitutions`](#api-applySubstitutions), [`capitalizeFirst`](#api-capitalizeFirst), [`createCursorCodec`](#api-createCursorCodec), [`createEventBus`](#api-createEventBus), [`createRequestCoordinator`](#api-createRequestCoordinator), [`createTimestamp`](#api-createTimestamp), [`decodeBase64Url`](#api-decodeBase64Url), [`decodeCursor`](#api-decodeCursor), [`delay`](#api-delay), [`encodeBase64Url`](#api-encodeBase64Url), [`encodeCursor`](#api-encodeCursor), [`escapeHtml`](#api-escapeHtml), [`estimateReadingTime`](#api-estimateReadingTime), [`extractEmailUsername`](#api-extractEmailUsername), [`extractFirstMarkdownHeadingName`](#api-extractFirstMarkdownHeadingName), [`extractSubstitutionPlaceholderNames`](#api-extractSubstitutionPlaceholderNames), [`extractTokenFromAuthorizationHeader`](#api-extractTokenFromAuthorizationHeader), [`filterByQuery`](#api-filterByQuery), [`generateDateId`](#api-generateDateId), [`generateRandomFloat`](#api-generateRandomFloat), [`generateRandomInteger`](#api-generateRandomInteger), [`generateRandomString`](#api-generateRandomString), [`generateSequence`](#api-generateSequence), [`generateUtcDateSequence`](#api-generateUtcDateSequence), [`generateUuid`](#api-generateUuid), [`getInitials`](#api-getInitials), [`getNextPageParam`](#api-getNextPageParam), [`getTrailingUtcDateRange`](#api-getTrailingUtcDateRange), [`hasNavigator`](#api-hasNavigator), [`hasWindow`](#api-hasWindow), [`isArrayValid`](#api-isArrayValid), [`isAuthorizationHeaderValid`](#api-isAuthorizationHeaderValid), [`isBigIntStringValid`](#api-isBigIntStringValid), [`isEmailValid`](#api-isEmailValid), [`isEqual`](#api-isEqual), [`isFileNameValid`](#api-isFileNameValid), [`isIndexedDbAvailable`](#api-isIndexedDbAvailable), [`isIntegerValid`](#api-isIntegerValid), [`isJwtValid`](#api-isJwtValid), [`isNumberValid`](#api-isNumberValid), [`isNumeric`](#api-isNumeric), [`isObjectValid`](#api-isObjectValid), [`isOTPSecretValid`](#api-isOTPSecretValid), [`isOTPTokenValid`](#api-isOTPTokenValid), [`isPasswordValid`](#api-isPasswordValid), [`isSemverValid`](#api-isSemverValid), [`isSlugValid`](#api-isSlugValid), [`isStringValid`](#api-isStringValid), [`isTimestampValid`](#api-isTimestampValid), [`isUrlValid`](#api-isUrlValid), [`isUuidValid`](#api-isUuidValid), [`isYouTubeVideoIdValid`](#api-isYouTubeVideoIdValid), [`maskMiddle`](#api-maskMiddle), [`normalizeQuery`](#api-normalizeQuery), [`omitProps`](#api-omitProps), [`parseJson`](#api-parseJson), [`parseYouTubeVideoId`](#api-parseYouTubeVideoId), [`pickProps`](#api-pickProps), [`pluralize`](#api-pluralize), [`prefersReducedMotion`](#api-prefersReducedMotion), [`prettifyBadgeCount`](#api-prettifyBadgeCount), [`prettifyDate`](#api-prettifyDate), [`prettifyFileSize`](#api-prettifyFileSize), [`prettifyNumber`](#api-prettifyNumber), [`prettifyPercentage`](#api-prettifyPercentage), [`prettifyTime`](#api-prettifyTime), [`pruneJson`](#api-pruneJson), [`redactSecrets`](#api-redactSecrets), [`redactUrlQueryParams`](#api-redactUrlQueryParams), [`resolveSafeInternalPath`](#api-resolveSafeInternalPath), [`retryAsyncFunction`](#api-retryAsyncFunction), [`shuffleArray`](#api-shuffleArray), [`sortPrimitives`](#api-sortPrimitives), [`sortRecords`](#api-sortRecords), [`sortRecordsWithBigIntString`](#api-sortRecordsWithBigIntString), [`sortRecordsWithDateValue`](#api-sortRecordsWithDateValue), [`splitArrayIntoBatches`](#api-splitArrayIntoBatches), [`stringifyJson`](#api-stringifyJson), [`stringifyJsonDeterministically`](#api-stringifyJsonDeterministically), [`stringifyValue`](#api-stringifyValue), [`toDate`](#api-toDate), [`toMS`](#api-toMS), [`toSlug`](#api-toSlug), [`toTitleCase`](#api-toTitleCase), [`toUtcDate`](#api-toUtcDate), [`toYouTubeEmbedUrl`](#api-toYouTubeEmbedUrl), [`toYouTubeWatchUrl`](#api-toYouTubeWatchUrl), [`truncateText`](#api-truncateText), [`truncateToUtf8Bytes`](#api-truncateToUtf8Bytes), [`waitUntil`](#api-waitUntil) - Constants: [`ERRORS`](#api-ERRORS), [`MAX_EMAIL_LENGTH`](#api-MAX_EMAIL_LENGTH), [`ONE_DAY_IN_MILLISECONDS`](#api-ONE_DAY_IN_MILLISECONDS), [`ONE_HOUR_IN_MILLISECONDS`](#api-ONE_HOUR_IN_MILLISECONDS), [`ONE_MINUTE_IN_MILLISECONDS`](#api-ONE_MINUTE_IN_MILLISECONDS), [`ONE_MONTH_IN_MILLISECONDS`](#api-ONE_MONTH_IN_MILLISECONDS), [`ONE_SECOND_IN_MILLISECONDS`](#api-ONE_SECOND_IN_MILLISECONDS), [`ONE_WEEK_IN_MILLISECONDS`](#api-ONE_WEEK_IN_MILLISECONDS), [`ONE_YEAR_IN_MILLISECONDS`](#api-ONE_YEAR_IN_MILLISECONDS), [`SECRET_TOKEN_PATTERNS`](#api-SECRET_TOKEN_PATTERNS) - Types: [`ICursorCodec`](#api-ICursorCodec), [`ICursorCodecOptions`](#api-ICursorCodecOptions), [`ICursorValue`](#api-ICursorValue), [`IDateTemplate`](#api-IDateTemplate), [`IDateValue`](#api-IDateValue), [`IDelayOptions`](#api-IDelayOptions), [`IEventBus`](#api-IEventBus), [`IEventBusOptions`](#api-IEventBusOptions), [`IFilterByQueryOptions`](#api-IFilterByQueryOptions), [`IJsonValue`](#api-IJsonValue), [`INumberFormatConfig`](#api-INumberFormatConfig), [`IRedactSecretsOptions`](#api-IRedactSecretsOptions), [`IRedactSecretsResult`](#api-IRedactSecretsResult), [`IRequestCoordinator`](#api-IRequestCoordinator), [`IRequestCoordinatorOptions`](#api-IRequestCoordinatorOptions), [`IRetryAsyncFunctionOptions`](#api-IRetryAsyncFunctionOptions), [`IRunRequestOptions`](#api-IRunRequestOptions), [`ISchema`](#api-ISchema), [`ISortDirection`](#api-ISortDirection), [`ISubstitutionOptions`](#api-ISubstitutionOptions), [`ITimeString`](#api-ITimeString), [`IUtcDateRange`](#api-IUtcDateRange), [`IUuidVersion`](#api-IUuidVersion), [`IWaitUntilOptions`](#api-IWaitUntilOptions) ### Functions #### `applyDefaults` *Function* Applies non-nullish overrides to a defaults object and returns a new shallow object. ```ts applyDefaults>( defaults: T, overrides: { [Key in string | number | symbol]?: T[Key] | null }, ): T ``` **Parameters** - `defaults` `T`: The object containing the default properties and values. - `overrides` `{ [Key in string | number | symbol]?: T[Key] | null }`: The values that should replace matching defaults when they are not nullish. **Returns** `T`: A new object containing the default keys and resolved values. **Example** ```ts import { applyDefaults } from 'jg-utilities'; applyDefaults({ retries: 3, timeout: 15 }, { retries: 5, timeout: null }); // => { retries: 5, timeout: 15 } ``` #### `applySubstitutions` *Function* Applies substitutions to a string based on a provided object. The string can contain placeholders in the format of {{key}}, which will be replaced by the corresponding value from the substitutions object. If a placeholder does not have a corresponding key in the substitutions object, it will remain unchanged in the output string. ```ts applySubstitutions( input: string, substitutions?: Record, options?: ISubstitutionOptions, ): string ``` **Parameters** - `input` `string`: The input string containing placeholders in the format of {{key}}. - `substitutions?` `Record`: No description. Defaults to `{}`. - `options?` `ISubstitutionOptions`: No description. Defaults to `...`. **Returns** `string`: A string with the placeholders replaced by their corresponding values from the substitutions object. **Example** ```ts import { applySubstitutions } from 'jg-utilities'; applySubstitutions('Hi {{name}}, you have {{count}} messages.', { name: 'Ada', count: 3 }); // => 'Hi Ada, you have 3 messages.' ``` #### `capitalizeFirst` *Function* Capitalizes the first letter of a string and returns the new value. ```ts capitalizeFirst(value: string): string ``` **Parameters** - `value` `string`: The string value to capitalize. **Returns** `string`: A string with the first letter capitalized. **Example** ```ts import { capitalizeFirst } from 'jg-utilities'; capitalizeFirst('hello world'); // => 'Hello world' ``` #### `createCursorCodec` *Function* Creates a codec for a list's pagination cursors: `encode` validates a continuation, such as the last row's sort keys and the list's filters, with the schema and encodes it as an opaque base64url cursor; `decode` reverses that for a cursor a client sent, and accepts it only for the list it came from. Without `matchesScope`, every key of the scope must equal the payload's. `decode` returns undefined for a missing cursor, meaning the first page, and throws `INVALID_CURSOR` for any cursor it refuses, with `data.reason` saying whether its encoding, its shape, or its scope was wrong, and the schema's own error, if any, as `cause`: for a client, a cursor that fails its schema is the same mistake as a malformed one. ```ts createCursorCodec>( options: ICursorCodecOptions, ): ICursorCodec ``` **Parameters** - `options` `ICursorCodecOptions`: The schema, the longest cursor, and the scope rule. **Returns** `ICursorCodec`: The codec. **Throws** - `CURSOR_TOO_LONG`: The cursor is longer than the codec's maximum length. - `INVALID_CURSOR`: The cursor is not valid for this list. **Example** ```ts import { createCursorCodec } from 'jg-utilities'; type IPostCursor = { authorId: string; lastId: number }; const isPostCursor = (value: unknown): value is IPostCursor => typeof value === 'object' && value !== null && 'authorId' in value && 'lastId' in value; const postCursors = createCursorCodec({ schema: { parse: (value) => { if (!isPostCursor(value)) { throw new TypeError('Not a post cursor.'); } return value; }, }, }); const cursor = postCursors.encode({ authorId: 'ada', lastId: 42 }); postCursors.decode(cursor, { authorId: 'ada' }); // => { authorId: 'ada', lastId: 42 } postCursors.decode(undefined, { authorId: 'ada' }); // => undefined ``` #### `createEventBus` *Function* Creates an in-process event bus that decouples the code that emits an event from the handlers that react to it, such as sending a welcome email after a sign-up. `emit` returns at once, and each event is delivered later, in the order it was emitted. Each handler runs on its own, so one that throws or rejects doesn't stop the others: its error goes to `onError`. A handler unsubscribed before it's called isn't called, and subscribing the same handler twice to one event registers it once. Declare the event map with `type`, because an interface has no index signature. ```ts createEventBus>( options: IEventBusOptions, ): IEventBus ``` **Parameters** - `options` `IEventBusOptions`: The `onError` that receives each handler's error. **Returns** `IEventBus`: The bus. **Example** ```ts import { createEventBus } from 'jg-utilities'; type IAppEvents = { userSignedUp: { userId: string } }; const events = createEventBus({ onError: (error, { name }) => console.error(`A ${String(name)} handler failed`, error), }); const welcomedUserIds: string[] = []; const unsubscribe = events.subscribe('userSignedUp', ({ userId }) => { welcomedUserIds.push(userId); }); // emit returns at once, and the handler runs later events.emit('userSignedUp', { userId: '42' }); welcomedUserIds; // => [] // once the handler is no longer needed setTimeout(unsubscribe, 1_000); ``` #### `createRequestCoordinator` *Function* Creates a coordinator that runs tasks, such as requests to a rate-limited API, at most `maxConcurrent` at a time, and queues the rest, first in, first out, up to `maxQueued`. A caller can be capped too, with `caller` and `maxQueuedForCaller`, so one caller can't fill the queue. `deferUntil` starts a cooldown, such as from a `Retry-After` header: queued tasks wait until it ends, without holding a slot, and tasks run with `rejectDuringCooldown` reject at once instead. A task whose signal aborts while it waits leaves the queue. A task's own result or error passes through unchanged, and its slot is released either way. No timer is left once nothing waits. `run` rejects with `REQUEST_QUEUE_FULL` when the queue, or the caller's share of it, is full, with `REQUEST_COOLDOWN_ACTIVE` when the task may not wait out a cooldown, with `REQUEST_COORDINATOR_CLOSED` after `close`, and with `INVALID_QUEUE_LIMIT` for a `maxQueuedForCaller` that isn't an integer of at least 0. `deferUntil` throws `INVALID_DATE` for a deadline that isn't a valid date. ```ts createRequestCoordinator( options: IRequestCoordinatorOptions, ): IRequestCoordinator ``` **Parameters** - `options` `IRequestCoordinatorOptions`: The concurrency and queue limits. **Returns** `IRequestCoordinator`: The coordinator. **Throws** - `INVALID_CONCURRENCY_LIMIT`: The concurrency limit must be an integer of at least 1. - `INVALID_QUEUE_LIMIT`: The queue limit must be an integer of at least 0. **Example** ```ts import { createRequestCoordinator } from 'jg-utilities'; const github = createRequestCoordinator({ maxConcurrent: 4, maxQueued: 64 }); const fetchIssues = (): Promise => github.run(() => fetch('https://api.github.com/repos/octocat/hello-world/issues')); ``` #### `createTimestamp` *Function* Creates an ISO 8601 timestamp, in UTC, from a date, a millisecond epoch, or a date string, or from the current time when no value is given. ```ts createTimestamp(value?: string | number | Date): string ``` **Parameters** - `value?` `string | number | Date`: The moment to format. Defaults to now. **Returns** `string`: The timestamp, such as `2026-10-17T08:30:00.000Z`. **Throws** - Invalid time value **Example** ```ts import { createTimestamp } from 'jg-utilities'; createTimestamp(); // the current time, such as '2026-10-17T08:30:00.000Z' createTimestamp(0); // => '1970-01-01T00:00:00.000Z' ``` #### `decodeBase64Url` *Function* Decodes a base64url value, without padding, into the UTF-8 string it encodes. Only the canonical encoding of a string is accepted, so every string has exactly one valid encoding. ```ts decodeBase64Url(value: string): string ``` **Parameters** - `value` `string`: The base64url value to decode. **Returns** `string`: The decoded string. **Throws** - `INVALID_BASE64URL`: if the value isn't a string of base64url characters with a valid length. - `INVALID_BASE64URL`: if the decoded bytes aren't valid UTF-8. - `INVALID_BASE64URL`: if the value isn't the canonical encoding of its result. **Example** ```ts import { decodeBase64Url } from 'jg-utilities'; decodeBase64Url('eyJwYWdlIjoyfQ'); // => '{"page":2}' ``` #### `decodeCursor` *Function* Decodes a cursor made by `encodeCursor`. The cursor comes from a client, so it's validated before decoding: a non-empty base64url string no longer than `maxLength`. The caller still validates the decoded value's shape, such as with a schema. ```ts decodeCursor(cursor: string, maxLength?: number): ICursorValue ``` **Parameters** - `cursor` `string`: The cursor to decode. - `maxLength?` `number`: The longest cursor accepted, in characters. Defaults to `2048`. **Returns** `ICursorValue`: The decoded object or array. **Throws** - `INVALID_CURSOR`: The cursor is not a valid encoded value. **Example** ```ts import { decodeCursor } from 'jg-utilities'; const searchParams = new URLSearchParams('?cursor=eyJpZCI6NDJ9'); decodeCursor(searchParams.get('cursor') ?? ''); // => { id: 42 } ``` #### `delay` *Function* Creates an asynchronous delay that resolves once the provided seconds have passed. ```ts delay(seconds: number, options?: IDelayOptions): Promise ``` **Parameters** - `seconds` `number`: The number of seconds to delay before the promise resolves. - `options?` `IDelayOptions`: No description. **Returns** `Promise`: A promise that resolves after the specified number of seconds has passed. **Throws** - The signal's reason when the signal is already aborted or aborts during the wait. **Example** ```ts import { delay } from 'jg-utilities'; await delay(0.5); // resumes after half a second await delay(10, { signal: AbortSignal.timeout(100) }).catch(() => 'stopped'); // => 'stopped' ``` #### `encodeBase64Url` *Function* Encodes a string as UTF-8 base64url without padding. For ASCII input, the output equals the btoa-based and Buffer-based base64url encodings. ```ts encodeBase64Url(value: string): string ``` **Parameters** - `value` `string`: The string to encode. **Returns** `string`: The base64url encoding of the string's UTF-8 bytes. **Example** ```ts import { encodeBase64Url } from 'jg-utilities'; encodeBase64Url('{"page":2}'); // => 'eyJwYWdlIjoyfQ' ``` #### `encodeCursor` *Function* Encodes a pagination continuation, such as the sort keys of the last row, as an opaque cursor: its JSON in base64url, safe for URLs and query strings. ```ts encodeCursor(value: ICursorValue): string ``` **Parameters** - `value` `ICursorValue`: The object or array to encode. **Returns** `string`: The cursor. **Throws** - `UNSUPPORTED_DATA_TYPE`: if the value isn't an object or an array - `UNABLE_TO_SERIALIZE_JSON`: if the value contains data that cannot be serialized **Example** ```ts import { encodeCursor } from 'jg-utilities'; encodeCursor({ id: 42 }); // => 'eyJpZCI6NDJ9' ``` #### `escapeHtml` *Function* Escapes text for HTML, so it shows as written instead of becoming markup, such as a name in an email template or a Telegram message. It's safe in element content and quoted attribute values, not in a URL, a script, or a style. ```ts escapeHtml(text: string): string ``` **Parameters** - `text` `string`: The text. **Returns** `string`: The escaped text. **Example** ```ts import { escapeHtml } from 'jg-utilities'; escapeHtml('"Tom" & Jerry\'s'); // => '<b>"Tom" & Jerry's</b>' ``` #### `estimateReadingTime` *Function* Estimates how long text takes to read at 200 words per minute. ```ts estimateReadingTime(text: unknown): number ``` **Parameters** - `text` `unknown`: The text whose reading time will be estimated. **Returns** `number`: The estimated reading time in milliseconds, or 0 for non-string or empty input. **Example** ```ts import { estimateReadingTime, prettifyTime } from 'jg-utilities'; const article = 'word '.repeat(600); estimateReadingTime(article); // milliseconds, about 3 minutes for 600 words prettifyTime(estimateReadingTime(article)); // => '3m' ``` #### `extractEmailUsername` *Function* Validates the format of an email address and extracts the username from it. ```ts extractEmailUsername(email: string): string ``` **Parameters** - `email` `string`: The email address to validate and extract the username from. **Returns** `string`: The extracted username from the email address. **Throws** - `INVALID_EMAIL_ADDRESS`: If the email address is not valid or has a forbidden extension. **Example** ```ts import { extractEmailUsername } from 'jg-utilities'; extractEmailUsername('Ada.Lovelace@example.com'); // => 'ada.lovelace' ``` #### `extractFirstMarkdownHeadingName` *Function* Extracts the name of the first markdown heading in the given content. ```ts extractFirstMarkdownHeadingName(markdownContent: string): string | null ``` **Parameters** - `markdownContent` `string`: The markdown-formatted content. **Returns** `string | null`: The first markdown heading text found in the content, or null if none is found. **Example** ```ts import { extractFirstMarkdownHeadingName } from 'jg-utilities'; extractFirstMarkdownHeadingName('Intro\n\n## Getting started\n\nText'); // => 'Getting started' ``` #### `extractSubstitutionPlaceholderNames` *Function* Extracts the names of substitution placeholders in the given text. ```ts extractSubstitutionPlaceholderNames(text: string): string[] ``` **Parameters** - `text` `string`: The text containing substitution placeholders. **Returns** `string[]`: An array of substitution placeholder names. **Example** ```ts import { extractSubstitutionPlaceholderNames } from 'jg-utilities'; extractSubstitutionPlaceholderNames('Hi {{name}}, you have {{count}} messages, {{name}}.'); // => ['name', 'count'] ``` #### `extractTokenFromAuthorizationHeader` *Function* Validates the format of an authorization header and extracts the token from it. ```ts extractTokenFromAuthorizationHeader(header: string): string ``` **Parameters** - `header` `string`: The authorization header to validate and extract the token from. **Returns** `string`: The extracted token from the authorization header. **Throws** - `INVALID_AUTHORIZATION_HEADER`: If the header does not comply with the expected format. **Example** ```ts import { extractTokenFromAuthorizationHeader } from 'jg-utilities'; extractTokenFromAuthorizationHeader('Bearer eyJhbGciOi.payload.signature'); // => 'eyJhbGciOi.payload.signature' ``` #### `filterByQuery` *Function* Filters an array of primitives based on a given query and returns a shallow copy. ```ts filterByQuery( items: T[], query: string, options?: IFilterByQueryOptions, ): T[] ``` **Parameters** - `items` `T[]`: The array of items to be filtered by the query. - `query` `string`: The query string to filter the items by. - `options?` `IFilterByQueryOptions`: No description. **Returns** `T[]`: A new array containing the items that match the query based on the provided options. **Example** ```ts import { filterByQuery } from 'jg-utilities'; filterByQuery([{ name: 'Ada' }, { name: 'Alan' }, { name: 'Grace' }], 'al', { queryProp: 'name' }); // => [{ name: 'Alan' }] ``` #### `generateDateId` *Function* Generates a date ID string in the format "YYYY_MM_DD". ```ts generateDateId(value?: IDateValue): string ``` **Parameters** - `value?` `IDateValue`: The date value to generate the ID from. Defaults to the current date if not provided. **Returns** `string`: The generated date ID string. **Example** ```ts import { generateDateId } from 'jg-utilities'; generateDateId(new Date(2026, 9, 17)); // => '2026_10_17' generateDateId(); // today's ID ``` #### `generateRandomFloat` *Function* Generates a random decimal in a range, with 53 bits of randomness from `crypto.getRandomValues`. ```ts generateRandomFloat(min: number, max: number): number ``` **Parameters** - `min` `number`: The lowest value, included. - `max` `number`: The highest value, excluded. **Returns** `number`: A random number greater than or equal to `min` and less than `max`. **Example** ```ts import { generateRandomFloat } from 'jg-utilities'; generateRandomFloat(0, 1); // 0.7312... ``` #### `generateRandomInteger` *Function* Generates a random integer in an inclusive range. Each integer is equally likely: values come from `crypto.getRandomValues` with rejection sampling. ```ts generateRandomInteger(min: number, max: number): number ``` **Parameters** - `min` `number`: The lowest value, included. - `max` `number`: The highest value, included. **Returns** `number`: A random integer from `min` to `max`. **Example** ```ts import { generateRandomInteger } from 'jg-utilities'; generateRandomInteger(1, 6); // such as 4 ``` #### `generateRandomString` *Function* Generates a string of characters picked at random from a set. Each character is drawn with `crypto.getRandomValues` and rejection sampling, so every character in the set is equally likely and the result is fit for secrets such as passwords. ```ts generateRandomString(length: number, characters?: string): string ``` **Parameters** - `length` `number`: The length of the string. - `characters?` `string`: The characters to pick from. Defaults to ASCII letters and digits. Defaults to `'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'`. **Returns** `string`: The random string, or an empty string when the length or the set is empty. **Example** ```ts import { generateRandomString } from 'jg-utilities'; generateRandomString(12); // such as 'aZ3kPq9TbW0x' generateRandomString(6, '0123456789'); // such as '402913' ``` #### `generateSequence` *Function* Generates a sequence of numbers within a range based on a number of steps. ```ts generateSequence(start: number, stop: number, step?: number): number[] ``` **Parameters** - `start` `number`: The starting value of the sequence. - `stop` `number`: The ending value of the sequence. - `step?` `number`: No description. Defaults to `1`. **Returns** `number[]`: An array of numbers representing the sequence. **Example** ```ts import { generateSequence } from 'jg-utilities'; generateSequence(1, 5); // => [1, 2, 3, 4, 5] generateSequence(0, 10, 5); // => [0, 5, 10] ``` #### `generateUtcDateSequence` *Function* Lists every UTC calendar date in an inclusive range, in ascending order, such as the days of a chart's axis. It gives one entry per day, so bound the range before passing one from a client. ```ts generateUtcDateSequence(range: IUtcDateRange): string[] ``` **Parameters** - `range` `IUtcDateRange`: The first and last dates, as `YYYY-MM-DD`. **Returns** `string[]`: The dates, from `fromDate` to `toDate`. **Throws** - `INVALID_DATE_RANGE`: The range must hold two real dates as YYYY-MM-DD, the first no later than the second. **Example** ```ts import { generateUtcDateSequence } from 'jg-utilities'; generateUtcDateSequence({ fromDate: '2028-02-28', toDate: '2028-03-01' }); // => ['2028-02-28', '2028-02-29', '2028-03-01'] ``` #### `generateUuid` *Function* Generates a UUID based on a version. ```ts generateUuid(version: IUuidVersion): string ``` **Parameters** - `version` `IUuidVersion`: The version of the UUID to be generated. **Returns** `string`: A UUID string of the specified version. **Example** ```ts import { generateUuid } from 'jg-utilities'; generateUuid(4); // such as '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' generateUuid(7); // time-ordered, so later IDs sort after earlier ones ``` #### `getInitials` *Function* Extracts a string of initials from the provided value. ```ts getInitials(value: string, initialsCount?: number): string ``` **Parameters** - `value` `string`: The string value from which to extract initials. - `initialsCount?` `number`: No description. Defaults to `1`. **Returns** `string`: A string with up to the requested number of letter-only initials, or "A" if none exist. **Example** ```ts import { getInitials } from 'jg-utilities'; getInitials('Ada Lovelace'); // => 'A' getInitials('Ada Lovelace', 2); // => 'AL' ``` #### `getNextPageParam` *Function* Gets the value of a property from the last item when the array length reaches the initial page size. ```ts getNextPageParam( propName: K, items: T[], initialPageSize: number, ): T[K] | undefined ``` **Parameters** - `propName` `K`: The property name to read from the last item. - `items` `T[]`: The array of items from which to read the last property value. - `initialPageSize` `number`: The expected page size required before reading the last property value. **Returns** `T[K] | undefined`: The last item's property value, or undefined when the array length is below the initial page size. **Example** ```ts import { getNextPageParam } from 'jg-utilities'; // with a page size of 2, a full page means there may be more getNextPageParam('id', [{ id: 1 }, { id: 2 }], 2); // => 2 getNextPageParam('id', [{ id: 3 }], 2); // => undefined ``` #### `getTrailingUtcDateRange` *Function* Gives the inclusive range of the last `dayCount` UTC days, ending on the UTC date of an instant, such as the 30 days a report covers. ```ts getTrailingUtcDateRange(dayCount: number, value?: IDateValue): IUtcDateRange ``` **Parameters** - `dayCount` `number`: The number of days, a positive safe integer; 1 gives a range of one day. - `value?` `IDateValue`: No description. **Returns** `IUtcDateRange`: The first and last UTC dates, as `YYYY-MM-DD`. **Throws** - `INVALID_DAY_COUNT`: The day count must be a positive safe integer whose range starts in the year 0000 or later. - `INVALID_DATE`: The value is not a valid date in the years 0000 to 9999. **Example** ```ts import { getTrailingUtcDateRange } from 'jg-utilities'; getTrailingUtcDateRange(7, '2026-10-17T08:30:00.000Z'); // => { fromDate: '2026-10-11', toDate: '2026-10-17' } getTrailingUtcDateRange(30); // the last 30 days, today included ``` #### `hasNavigator` *Function* Checks whether a global navigator object exists. It reports presence only and doesn't identify a browser: Node.js 21 and later define a global navigator too. ```ts hasNavigator(): boolean ``` **Returns** `boolean`: True when the navigator global is defined. **Example** ```ts import { hasNavigator } from 'jg-utilities'; hasNavigator(); // true in browsers and in Node.js 21 or later ``` #### `hasWindow` *Function* Checks whether a global window object exists. It reports presence only and doesn't identify a browser. ```ts hasWindow(): boolean ``` **Returns** `boolean`: True when the window global is defined. **Example** ```ts import { hasWindow } from 'jg-utilities'; hasWindow(); // true in a browser, false in Node.js ``` #### `isArrayValid` *Function* Verifies if a value is an array. It also validates if it has elements inside (optional). ```ts isArrayValid(value: unknown, allowEmpty?: boolean): value is any[] ``` **Parameters** - `value` `unknown`: The value to be validated. - `allowEmpty?` `boolean`: No description. **Returns** `value is any[]`: A boolean indicating whether the value is a valid array and meets the element requirements (if provided). **Example** ```ts import { isArrayValid } from 'jg-utilities'; isArrayValid([1]); // => true isArrayValid([]); // => false isArrayValid([], true); // => true ``` #### `isAuthorizationHeaderValid` *Function* Verifies if a value has a valid Authorization Header format based on the RFC6750. Example: Authorization: Bearer eyJhbGciOiJIUzI1NiIXVCJ9TJV...r7E20RMHrHDcEfxjoYZgeFONFh7HgQ More info: - https://stackoverflow.com/questions/33265812/best-http-authorization-header-type-for-jwt - https://www.rfc-editor.org/rfc/rfc6750 ```ts isAuthorizationHeaderValid(value: unknown): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. **Returns** `value is string`: A boolean indicating whether the value is a valid Authorization header. **Example** ```ts import { isAuthorizationHeaderValid } from 'jg-utilities'; isAuthorizationHeaderValid('Bearer eyJhbGciOi.payload.signature'); // => true isAuthorizationHeaderValid('Basic dXNlcg=='); // => false ``` #### `isBigIntStringValid` *Function* Verifies that a value is an integer written in plain decimal digits, as PostgreSQL returns a BIGINT, and that it fits a range: a signed 64-bit integer by default. Leading zeros, a plus sign, `-0`, decimals, exponents, and padding are rejected, so one number has one spelling. ```ts isBigIntStringValid(value: unknown, min?: bigint, max?: bigint): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. - `min?` `bigint`: No description. Defaults to `INT64_MIN`. - `max?` `bigint`: No description. Defaults to `INT64_MAX`. **Returns** `value is string`: A boolean indicating whether the value is such a string, within the range. **Example** ```ts import { isBigIntStringValid } from 'jg-utilities'; isBigIntStringValid('9223372036854775807'); // => true isBigIntStringValid('9223372036854775808'); // => false isBigIntStringValid('007'); // => false isBigIntStringValid('5', 1n); // => true ``` #### `isEmailValid` *Function* Verifies if a value is a valid email address with a maximum length of 320 characters. Important: when providing forbiddenExtensions, ensure to include the dot (.) at the beginning. For example, to forbid .con, use ['.con']. ```ts isEmailValid(value: unknown, forbiddenExtensions?: string[]): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. - `forbiddenExtensions?` `string[]`: No description. Defaults to `...`. **Returns** `value is string`: A boolean indicating whether the value is a valid email address and does not have a forbidden extension. **Example** ```ts import { isEmailValid } from 'jg-utilities'; isEmailValid('ada@example.com'); // => true isEmailValid('ada@example.con'); // => false ``` #### `isEqual` *Function* Compares two values. Primitives, `null`, and `undefined` are compared with `Object.is`, so `NaN` equals `NaN` and `0` doesn't equal `-0`. Two objects or arrays are compared deeply, ignoring key order, through their deterministic JSON. An object never equals a primitive. ```ts isEqual(firstValue: unknown, secondValue: unknown): boolean ``` **Parameters** - `firstValue` `unknown`: The first value. - `secondValue` `unknown`: The second value. **Returns** `boolean`: Whether the values are equal. **Throws** - `UNABLE_TO_SERIALIZE_JSON`: if either object contains data that cannot be serialized **Example** ```ts import { isEqual } from 'jg-utilities'; isEqual({ a: 1, b: [2] }, { b: [2], a: 1 }); // => true isEqual('a', 'a'); // => true isEqual({}, null); // => false ``` #### `isFileNameValid` *Function* Verifies if a value is a file name that is valid on Windows, macOS and Linux: one path component whose UTF-8 size is between 1 and maxBytes. It rejects ".", "..", control characters, the characters < > : " / \ | ? *, a trailing space or period, and Windows device names such as "con" or "lpt1.txt", in any letter case. ```ts isFileNameValid(value: unknown, maxBytes?: number): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. - `maxBytes?` `number`: No description. Defaults to `MAX_FILE_NAME_BYTES`. **Returns** `value is string`: A boolean indicating whether the value is a portable file name. **Example** ```ts import { isFileNameValid } from 'jg-utilities'; isFileNameValid('notes.md'); // => true isFileNameValid('con.txt'); // => false isFileNameValid('a/b.txt'); // => false ``` #### `isIndexedDbAvailable` *Function* Checks whether the IndexedDB API exists in the current environment. ```ts isIndexedDbAvailable(): boolean ``` **Returns** `boolean`: True when the indexedDB global is defined. **Example** ```ts import { isIndexedDbAvailable } from 'jg-utilities'; isIndexedDbAvailable(); // true in browsers that support IndexedDB ``` #### `isIntegerValid` *Function* Verifies if a value is a valid integer and is within a range (optional). If a range is not provided, it will use the properties Number.MIN_SAFE_INTEGER & Number.MAX_SAFE_INTEGER. ```ts isIntegerValid(value: unknown, min?: number, max?: number): value is number ``` **Parameters** - `value` `unknown`: The value to be validated. - `min?` `number`: No description. - `max?` `number`: No description. **Returns** `value is number`: A boolean indicating whether the value is a valid integer and within the specified range. **Example** ```ts import { isIntegerValid } from 'jg-utilities'; isIntegerValid(5); // => true isIntegerValid(5.5); // => false isIntegerValid(5, 1, 3); // => false ``` #### `isJwtValid` *Function* Verifies if a value has a correct JWT Format: [Base64-URL Encoded Header].[Base64-URL Encoded Payload].[Signature] ```ts isJwtValid(value: unknown): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. **Returns** `value is string`: A boolean indicating whether the value is a valid JWT. **Example** ```ts import { isJwtValid } from 'jg-utilities'; isJwtValid('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl'); // => true isJwtValid('not-a-token'); // => false ``` #### `isNumberValid` *Function* Verifies if a value is a valid number and is within a range (optional). The minimum value defaults to Number.MIN_SAFE_INTEGER (-9007199254740991) while the maximum value defaults to Number.MAX_SAFE_INTEGER (9007199254740991). ```ts isNumberValid(value: unknown, min?: number, max?: number): value is number ``` **Parameters** - `value` `unknown`: The value to be validated. - `min?` `number`: No description. Defaults to `Number.MIN_SAFE_INTEGER`. - `max?` `number`: No description. Defaults to `Number.MAX_SAFE_INTEGER`. **Returns** `value is number`: A boolean indicating whether the value is a valid number and within the specified range. **Example** ```ts import { isNumberValid } from 'jg-utilities'; isNumberValid(12.5); // => true isNumberValid(Number.NaN); // => false isNumberValid(5, 10); // => false ``` #### `isNumeric` *Function* Verifies if a value is a valid numeric string. ```ts isNumeric(value: string): boolean ``` **Parameters** - `value` `string`: The value to be validated. **Returns** `boolean`: A boolean indicating whether the value is a valid numeric string. **Example** ```ts import { isNumeric } from 'jg-utilities'; isNumeric('-12.5'); // => true isNumeric('1,000'); // => false ``` #### `isObjectValid` *Function* Verifies if a value is an actual object. It also validates if it has keys (optional). ```ts isObjectValid( value: unknown, allowEmpty?: boolean, ): value is Record ``` **Parameters** - `value` `unknown`: The value to be validated. - `allowEmpty?` `boolean`: No description. **Returns** `value is Record`: A boolean indicating whether the value is a valid object and meets the key requirements (if provided). **Example** ```ts import { isObjectValid } from 'jg-utilities'; isObjectValid({ id: 1 }); // => true isObjectValid({}); // => false isObjectValid({}, true); // => true isObjectValid([]); // => false ``` #### `isOTPSecretValid` *Function* Verifies if a value has the correct OTP Secret Format. ```ts isOTPSecretValid(value: unknown): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. **Returns** `value is string`: A boolean indicating whether the value is a valid OTP secret. **Example** ```ts import { isOTPSecretValid } from 'jg-utilities'; isOTPSecretValid('JBSWY3DPEHPK3PXP'); // => true ``` #### `isOTPTokenValid` *Function* Verifies if a value has the correct OTP Token Format. ```ts isOTPTokenValid(value: unknown): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. **Returns** `value is string`: A boolean indicating whether the value is a valid OTP token. **Example** ```ts import { isOTPTokenValid } from 'jg-utilities'; isOTPTokenValid('123456'); // => true isOTPTokenValid('12345'); // => false ``` #### `isPasswordValid` *Function* Verifies if a password meets the following requirements: - Meets a length range (Defaults to 8 - 2048) - At least one uppercase letter - At least one lowercase letter - At least one number - At least one special character ```ts isPasswordValid( value: unknown, minLength?: number, maxLength?: number, ): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. - `minLength?` `number`: No description. Defaults to `8`. - `maxLength?` `number`: No description. Defaults to `2048`. **Returns** `value is string`: A boolean indicating whether the value is a valid password. **Example** ```ts import { isPasswordValid } from 'jg-utilities'; isPasswordValid('Sup3r$ecret'); // => true isPasswordValid('password'); // => false ``` #### `isSemverValid` *Function* Verifies if a value complies with semantic versioning. ```ts isSemverValid(value: unknown): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. **Returns** `value is string`: A boolean indicating whether the value is a valid semantic version. **Example** ```ts import { isSemverValid } from 'jg-utilities'; isSemverValid('1.2.0'); // => true isSemverValid('1.2'); // => false ``` #### `isSlugValid` *Function* Verifies that a slug contains lowercase letters or digits separated by single hyphens and meets a length range. ```ts isSlugValid( value: unknown, minLength?: number, maxLength?: number, ): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. - `minLength?` `number`: No description. Defaults to `2`. - `maxLength?` `number`: No description. Defaults to `16`. **Returns** `value is string`: A boolean indicating whether the value is a valid slug. **Example** ```ts import { isSlugValid } from 'jg-utilities'; isSlugValid('jg-utilities'); // => true isSlugValid('JG Utils'); // => false ``` #### `isStringValid` *Function* Verifies if a value is a valid string and its length is within a range (optional). ```ts isStringValid( value: unknown, minLength?: number, maxLength?: number, disallowWhitespaceOnly?: boolean, ): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. - `minLength?` `number`: No description. Defaults to `1`. - `maxLength?` `number`: No description. - `disallowWhitespaceOnly?` `boolean`: No description. Defaults to `true`. **Returns** `value is string`: A boolean indicating whether the value is a valid string and meets the length requirements (if provided). **Example** ```ts import { isStringValid } from 'jg-utilities'; isStringValid('hello'); // => true isStringValid(' '); // => false isStringValid('hello', 1, 3); // => false ``` #### `isTimestampValid` *Function* Verifies if a value is a valid unix timestamp in milliseconds. The smallest value is set for the beginning of the Unix epoch (January 1st, 1970 - 14400000) while the largest value is based on the numeric limit established by JavaScript (9007199254740991). ```ts isTimestampValid(value: unknown): value is number ``` **Parameters** - `value` `unknown`: The value to be validated. **Returns** `value is number`: A boolean indicating whether the value is a valid unix timestamp in milliseconds. **Example** ```ts import { isTimestampValid } from 'jg-utilities'; isTimestampValid(Date.UTC(2026, 9, 17)); // => true isTimestampValid(-1); // => false ``` #### `isUrlValid` *Function* Verifies if a value is a valid URL. ```ts isUrlValid(value: unknown): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. **Returns** `value is string`: A boolean indicating whether the value is a valid URL. **Example** ```ts import { isUrlValid } from 'jg-utilities'; isUrlValid('https://jg-packages.jesusgraterol.dev'); // => true isUrlValid('not a url'); // => false ``` #### `isUuidValid` *Function* Verifies if a value is a valid UUID and that it matches a specific version. ```ts isUuidValid(value: unknown, version: IUuidVersion): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. - `version` `IUuidVersion`: The UUID version to be validated against. **Returns** `value is string`: A boolean indicating whether the value is a valid UUID of the specified version. **Example** ```ts import { isUuidValid } from 'jg-utilities'; isUuidValid('0199f5e2-8f5a-7b3c-9d2e-4f1a6b8c0d7e', 7); // => true isUuidValid('0199f5e2-8f5a-7b3c-9d2e-4f1a6b8c0d7e', 4); // => false ``` #### `isYouTubeVideoIdValid` *Function* Verifies if a value is a YouTube video id: 11 characters of letters, digits, `-`, and `_`. ```ts isYouTubeVideoIdValid(value: unknown): value is string ``` **Parameters** - `value` `unknown`: The value to be validated. **Returns** `value is string`: A boolean indicating whether the value is a YouTube video id. **Example** ```ts import { isYouTubeVideoIdValid } from 'jg-utilities'; isYouTubeVideoIdValid('M7lc1UVf-VE'); // => true isYouTubeVideoIdValid('M7lc1UVf-V'); // => false ``` #### `maskMiddle` *Function* Masks the middle of a string, keeping a specified number of visible characters at the start and end. ```ts maskMiddle(text: string, visibleChars: number, mask?: string): string ``` **Parameters** - `text` `string`: The string value to mask. - `visibleChars` `number`: The number of characters to keep visible at the start and end of the string. - `mask?` `string`: No description. Defaults to `'...'`. **Returns** `string`: A string with the middle masked, keeping the specified number of visible characters at the start. **Example** ```ts import { maskMiddle } from 'jg-utilities'; maskMiddle('0x1234567890abcdef', 4); // => '0x12...cdef' ``` #### `normalizeQuery` *Function* Normalizes a query string by removing control characters, zero-width characters, and extra spaces, then converts it to lowercase. Optionally, it can truncate the string to a specified maximum length. ```ts normalizeQuery(query: string, maxLength?: number): string ``` **Parameters** - `query` `string`: The query string to normalize. - `maxLength?` `number`: No description. **Returns** `string`: The normalized query string. **Example** ```ts import { normalizeQuery } from 'jg-utilities'; normalizeQuery(' Héllo WORLD '); // => 'héllo world' ``` #### `omitProps` *Function* Omits a list of properties from an object and returns a new object (shallow) with only those keys that weren't omitted ```ts omitProps( input: T, propKeys: K[], ): Omit ``` **Parameters** - `input` `T`: The object from which to omit properties. - `propKeys` `K[]`: The keys of the properties to omit. **Returns** `Omit`: A new object containing only the properties that were not omitted. **Throws** - `INVALID_OR_EMPTY_OBJECT`: if the input is not a valid object or it is empty - `INVALID_OR_EMPTY_ARRAY`: if the keys to be omitted are not a valid array or it is empty **Example** ```ts import { omitProps } from 'jg-utilities'; omitProps({ id: 1, name: 'Ada', email: 'ada@example.com' }, ['email']); // => { id: 1, name: 'Ada' } ``` #### `parseJson` *Function* Deserializes a JSON string with the JSON.parse method. ```ts parseJson(value: string): T ``` **Parameters** - `value` `string`: The JSON string to be deserialized. **Returns** `T`: The deserialized object or array. **Throws** - `UNSUPPORTED_DATA_TYPE`: if the provided value is not a non-empty string - `UNABLE_TO_DESERIALIZE_JSON`: if the result of JSON.parse is not a valid object or array - `UNABLE_TO_DESERIALIZE_JSON`: if an error is thrown during parsing **Example** ```ts import { parseJson } from 'jg-utilities'; const user = parseJson<{ id: number }>('{"id":1}'); // { id: 1 } ``` #### `parseYouTubeVideoId` *Function* Reads the id of the single YouTube video a URL points at, such as one a user pasted. It accepts https URLs without credentials or a port, on these hosts and paths, and ignores the query, apart from `v`, and the hash: - `youtu.be/` - `youtube.com/watch?v=`, `/shorts/`, `/embed/`, and `/live/` - `m.youtube.com/watch?v=` - `youtube-nocookie.com/embed/` The `www.` form of each host works too. Anything else, such as a playlist, a channel, or a value that isn't a URL, returns null rather than throwing. ```ts parseYouTubeVideoId(url: unknown): string | null ``` **Parameters** - `url` `unknown`: The value to read, trimmed when it's a string. **Returns** `string | null`: The 11-character video id, or null. **Example** ```ts import { parseYouTubeVideoId } from 'jg-utilities'; parseYouTubeVideoId('https://youtu.be/M7lc1UVf-VE'); // => 'M7lc1UVf-VE' parseYouTubeVideoId('https://www.youtube.com/watch?v=M7lc1UVf-VE&t=30'); // => 'M7lc1UVf-VE' parseYouTubeVideoId('https://www.youtube.com/playlist?list=PL123'); // => null ``` #### `pickProps` *Function* Picks a list of properties from an object and returns a new object (shallow) with the provided keys. ```ts pickProps( input: T, propKeys: K[], ): Pick ``` **Parameters** - `input` `T`: The object from which to pick properties. - `propKeys` `K[]`: The keys of the properties to pick. **Returns** `Pick`: A new object containing only the picked properties. **Throws** - `INVALID_OR_EMPTY_OBJECT`: if the input is not a valid object or it is empty - `INVALID_OR_EMPTY_ARRAY`: if the keys to be picked are not a valid array or it is empty **Example** ```ts import { pickProps } from 'jg-utilities'; pickProps({ id: 1, name: 'Ada', email: 'ada@example.com' }, ['id', 'name']); // => { id: 1, name: 'Ada' } ``` #### `pluralize` *Function* Picks the singular or plural form of a word for a count, such as "1 file" and "3 files". ```ts pluralize(count: number, singular: string, plural?: string): string ``` **Parameters** - `count` `number`: The count. - `singular` `string`: The word for exactly one. - `plural?` `string`: The word for any other count. Defaults to the singular with an "s". Defaults to `...`. **Returns** `string`: The word alone, so other words can sit between it and the count. **Example** ```ts import { pluralize } from 'jg-utilities'; pluralize(1, 'file'); // => 'file' pluralize(0, 'file'); // => 'files' pluralize(2, 'entry', 'entries'); // => 'entries' ``` #### `prefersReducedMotion` *Function* Checks whether the user asked the system to reduce motion. The view defaults to the global window when one exists; pass another one, such as an element's ownerDocument.defaultView, to check a different window. ```ts prefersReducedMotion(view?: IMediaQueryView | null): boolean ``` **Parameters** - `view?` `IMediaQueryView | null`: No description. Defaults to `...`. **Returns** `boolean`: True only when the view reports a reduced-motion preference. **Example** ```ts import { prefersReducedMotion } from 'jg-utilities'; prefersReducedMotion(); // true when the user asked the system to reduce motion ``` #### `prettifyBadgeCount` *Function* Formats the number that will be inserted in a badge so it doesn't take too much space. If the current count is 0, it returns undefined as the badge shouldn't be displayed. ```ts prettifyBadgeCount(value: number, maxValue?: number): string | undefined ``` **Parameters** - `value` `number`: The current count to be displayed in the badge. - `maxValue?` `number`: The maximum value to display before showing a "+" sign. Defaults to 9. Defaults to `9`. **Returns** `string | undefined`: A string representing the formatted badge count or undefined if the count is 0. **Example** ```ts import { prettifyBadgeCount } from 'jg-utilities'; prettifyBadgeCount(3); // => '3' prettifyBadgeCount(42); // => '9+' prettifyBadgeCount(0); // => undefined ``` #### `prettifyDate` *Function* Formats a date instance based on a template. The output is always en-US, whatever the runtime's locale, and hours have no leading zero. - date-short: 12/05/2024 (Default) - date-medium: December 5, 2024 - date-long: Thursday, December 5, 2024 - time-short: 9:05 PM - time-medium: 9:05:20 PM - datetime-short: 12/5/2024, 9:05 PM - datetime-medium: December 5, 2024 at 9:05 PM - datetime-long: Thursday, December 5, 2024 at 9:05:20 PM Without a time zone, the runtime's local zone is used, so a server and a browser can format the same instant differently. Pass 'UTC' for calendar dates and for data defined in UTC periods, and an explicit time zone for server-rendered output. Templates with a time end with the zone's short name when a time zone is given, for example "December 5, 2024 at 11:30 PM UTC". Prefer date-medium, datetime-medium and time-medium in UI text over the numeric templates. ```ts prettifyDate( value: IDateValue, template?: IDateTemplate, timeZone?: string, ): string ``` **Parameters** - `value` `IDateValue`: The date value to be formatted. It can be a Date object, a timestamp, or a date string. - `template?` `IDateTemplate`: The template to use for formatting the date. Defaults to date-short. Defaults to `'date-short'`. - `timeZone?` `string`: No description. **Returns** `string`: A string representing the formatted date. **Throws** - A RangeError if the time zone is unknown. **Example** ```ts import { prettifyDate } from 'jg-utilities'; prettifyDate('2026-10-17T08:30:00.000Z', 'date-medium', 'UTC'); // => 'October 17, 2026' prettifyDate('2026-10-17T08:30:00.000Z', 'datetime-short', 'UTC'); // => '10/17/2026, 8:30 AM UTC' ``` #### `prettifyFileSize` *Function* Formats a bytes value into a human readable format. Bigints, such as BIGINT database values, are accepted too; values beyond Number.MAX_SAFE_INTEGER are formatted without losing precision. ```ts prettifyFileSize(value: number | bigint, decimalPlaces?: number): string ``` **Parameters** - `value` `number | bigint`: The bytes value to be formatted, as a number or a bigint. - `decimalPlaces?` `number`: The number of decimal places to include. Defaults to 2. Defaults to `2`. **Returns** `string`: A string representing the formatted file size. **Example** ```ts import { prettifyFileSize } from 'jg-utilities'; prettifyFileSize(1536); // => '1.50 kB' prettifyFileSize(3_250_000_000, 1); // => '3.0 GB' ``` #### `prettifyNumber` *Function* Formats a numeric value based on the user's default language. A negative value's sign goes before the prefix, as in `-$5`. ```ts prettifyNumber( value: number, configuration?: Partial, ): string ``` **Parameters** - `value` `number`: The number to be formatted. - `configuration?` `Partial`: No description. **Returns** `string`: A string representing the formatted number. **Example** ```ts import { prettifyNumber } from 'jg-utilities'; prettifyNumber(1234567.891); // => '1,234,567.89' prettifyNumber(19.5, { minimumFractionDigits: 2, prefix: '$' }); // => '$19.50' prettifyNumber(-5, { prefix: '$' }); // => '-$5' ``` #### `prettifyPercentage` *Function* Formats a numeric value as a percentage based on the user's default language. ```ts prettifyPercentage( value: number, configuration?: Partial, ): string ``` **Parameters** - `value` `number`: The number to be formatted as a percentage - whole value (e.g., 50 for 50%) - `configuration?` `Partial`: No description. **Returns** `string`: A string representing the formatted percentage. **Example** ```ts import { prettifyPercentage } from 'jg-utilities'; prettifyPercentage(12.345); // => '12.35%' prettifyPercentage(-3); // => '-3%' ``` #### `prettifyTime` *Function* Formats a duration in milliseconds into a human-readable string. ```ts prettifyTime(milliseconds: number): string ``` **Parameters** - `milliseconds` `number`: The duration in milliseconds. **Returns** `string`: A string representing the formatted duration. **Example** ```ts import { prettifyTime } from 'jg-utilities'; prettifyTime(3_725_000); // => '1h 2m' prettifyTime(45_000); // => '45s' ``` #### `pruneJson` *Function* Removes null, undefined, empty objects, and empty arrays from the given data recursively. ```ts pruneJson(value: T): T | null ``` **Parameters** - `value` `T`: The value to be pruned. It should be an object, array, or primitive value. **Returns** `T | null`: The pruned value, or null if the value is empty or invalid. **Example** ```ts import { pruneJson } from 'jg-utilities'; pruneJson({ a: 1, b: null, c: {}, d: [], e: { f: [] } }); // => { a: 1 } ``` #### `redactSecrets` *Function* Replaces the secrets in free text, such as a log line or a file headed for an AI model: complete PEM private key blocks first, then every match of the token patterns, which default to `SECRET_TOKEN_PATTERNS`. It runs in linear time over the key blocks, matches each pattern globally whatever its flags, and never throws for any text, so a logger can call it safely. With `preserveLineBreaks`, a private key becomes the replacement followed by its line breaks, or preceded by them when it ends the text, so line numbers in the text stay the same. ```ts redactSecrets( text: string, options?: IRedactSecretsOptions, ): IRedactSecretsResult ``` **Parameters** - `text` `string`: The text to redact. - `options?` `IRedactSecretsOptions`: The patterns, the replacement, and whether key blocks keep their line breaks. Defaults to `{}`. **Returns** `IRedactSecretsResult`: The redacted text, and how many secrets were replaced. **Example** ```ts import { redactSecrets } from 'jg-utilities'; redactSecrets('auth: ghp_abcdefghijklmnopqrstuvwxyz'); // => { text: 'auth: [redacted]', count: 1 } redactSecrets('port: 3000'); // => { text: 'port: 3000', count: 0 } ``` #### `redactUrlQueryParams` *Function* Removes a URL's query and fragment when the query holds any of the named parameters, such as a token, so the URL can be logged. A name matches exactly, after decoding, or as the start of an array parameter, such as `token[]`. A name that can't be decoded could hide one of them, so the whole URL becomes `[redacted]`, as does a URL that is only a query. ```ts redactUrlQueryParams(url: string, names: readonly string[]): string ``` **Parameters** - `url` `string`: The request target or absolute URL. - `names` `readonly string[]`: The names of the parameters that make the query unsafe to log. **Returns** `string`: The URL when it holds none of them, otherwise the part before its query. **Example** ```ts import { redactUrlQueryParams } from 'jg-utilities'; redactUrlQueryParams('/reset?token=abc#form', ['token']); // => '/reset' redactUrlQueryParams('/reset?tok%65n=abc', ['token']); // => '/reset' redactUrlQueryParams('/search?q=shoes', ['token']); // => '/search?q=shoes' ``` #### `resolveSafeInternalPath` *Function* Checks an untrusted path, such as a `returnTo` parameter after sign-in, and returns it only when it stays on the same site and outside the excluded paths, so a link can't send someone to another site or back into a sign-in loop. It must start with one `/`; a protocol-relative URL, an absolute URL, a backslash, or a path that can't be decoded is rejected. An excluded path matches itself and everything under it, by whole segments, case-insensitively, and after decoding, as a router matches routes. ```ts resolveSafeInternalPath( candidate: unknown, excludedPaths?: readonly string[], ): string | null ``` **Parameters** - `candidate` `unknown`: The untrusted value. - `excludedPaths?` `readonly string[]`: Paths it must not lead to, such as `['/sign-in']`. Defaults to `[]`. **Returns** `string | null`: The path, normalized, with its query and fragment, or null when it isn't safe. **Example** ```ts import { resolveSafeInternalPath } from 'jg-utilities'; resolveSafeInternalPath('/projects/a?tab=files', ['/sign-in']); // => '/projects/a?tab=files' resolveSafeInternalPath('//evil.example', ['/sign-in']); // => null resolveSafeInternalPath('/sign-in/step', ['/sign-in']); // => null resolveSafeInternalPath('/sign-in-help', ['/sign-in']); // => '/sign-in-help' ``` #### `retryAsyncFunction` *Function* Executes an asynchronous function persistently, retrying on error with incremental delays defined in retryScheduleDuration (seconds). When the signal aborts, the returned promise rejects immediately, even while an attempt is pending. The attempt's own work isn't cancelled; pass the signal into it as well when it supports one. ```ts retryAsyncFunction( fn: () => Promise, retryScheduleDuration?: number[], options?: IRetryAsyncFunctionOptions, ): Promise ``` **Parameters** - `fn` `() => Promise`: The asynchronous function to be executed persistently. - `retryScheduleDuration?` `number[]`: No description. Defaults to `...`. - `options?` `IRetryAsyncFunctionOptions`: No description. **Returns** `Promise`: A promise that resolves with the result of the asynchronous function or rejects with the last encountered error. **Throws** - The last error thrown by the function, or the first one shouldRetry declines to retry. - The signal's reason when the signal aborts. **Example** ```ts import { retryAsyncFunction } from 'jg-utilities'; let attempts = 0; const readRate = async (): Promise => { attempts += 1; if (attempts < 2) { throw new Error('The service is busy.'); } return 1.08; }; // up to 3 attempts, 0.1 and 0.5 seconds apart await retryAsyncFunction(readRate, [0.1, 0.5]); // => 1.08 attempts; // => 2 ``` #### `shuffleArray` *Function* Creates a shallow copy of the input array and shuffles it with the Fisher-Yates algorithm, drawing each position with `crypto.getRandomValues`, so every order is equally likely. ```ts shuffleArray(input: T[]): T[] ``` **Parameters** - `input` `T[]`: The array to be shuffled. **Returns** `T[]`: A new array with the values of the input array in a random order. **Throws** - `INVALID_OR_EMPTY_ARRAY`: if the input is not array or it is empty **Example** ```ts import { shuffleArray } from 'jg-utilities'; shuffleArray([1, 2, 3, 4]); // a new array, such as [3, 1, 4, 2] ``` #### `sortPrimitives` *Function* Sorts a list of primitive values based on their type and a sort direction. ```ts sortPrimitives( direction: ISortDirection, ): (firstValue: T, secondValue: T) => number ``` **Parameters** - `direction` `ISortDirection`: The direction to sort the values. **Returns** `(firstValue: T, secondValue: T) => number`: A number indicating the sort order based on the primitive type. **Throws** - `MIXED_OR_UNSUPPORTED_DATA_TYPES`: if the values are mixed or are different to string | number | bigint **Example** ```ts import { sortPrimitives } from 'jg-utilities'; [3, 1, 2].sort(sortPrimitives('asc')); // => [1, 2, 3] ['b', 'c', 'a'].sort(sortPrimitives('desc')); // => ['c', 'b', 'a'] ``` #### `sortRecords` *Function* Sorts a list of record values by key based on their type and a sort direction. ```ts sortRecords( key: string, direction: ISortDirection, ): (firstRecord: T, secondRecord: T) => number ``` **Parameters** - `key` `string`: The key of the record to sort by. - `direction` `ISortDirection`: The direction to sort the values. **Returns** `(firstRecord: T, secondRecord: T) => number`: A number indicating the sort order based on the primitive type. **Throws** - `MIXED_OR_UNSUPPORTED_DATA_TYPES`: if the values are mixed or are different to string | number | bigint **Example** ```ts import { sortRecords } from 'jg-utilities'; [{ age: 40 }, { age: 25 }].sort(sortRecords('age', 'asc')); // => [{ age: 25 }, { age: 40 }] ``` #### `sortRecordsWithBigIntString` *Function* Sorts a list of record values by key, treating stringified bigints as actual bigints, based on a sort direction. ```ts sortRecordsWithBigIntString>( key: keyof T, direction: ISortDirection, ): (firstRecord: T, secondRecord: T) => number ``` **Parameters** - `key` `keyof T`: The key of the record to sort by. - `direction` `ISortDirection`: The direction to sort the values. **Returns** `(firstRecord: T, secondRecord: T) => number`: A number indicating the sort order based on the primitive type. **Throws** - `MIXED_OR_UNSUPPORTED_DATA_TYPES`: if the values are mixed or are different to string | number | bigint **Example** ```ts import { sortRecordsWithBigIntString } from 'jg-utilities'; [{ amount: '900000000000000000001' }, { amount: '5' }].sort(sortRecordsWithBigIntString('amount', 'asc')); // => [{ amount: '5' }, { amount: '900000000000000000001' }] ``` #### `sortRecordsWithDateValue` *Function* Sorts a list of record values by key, treating date values as actual Date objects, based on a sort direction. ```ts sortRecordsWithDateValue>( key: keyof T, direction: ISortDirection, ): (firstRecord: T, secondRecord: T) => number ``` **Parameters** - `key` `keyof T`: The key of the record to sort by. - `direction` `ISortDirection`: The direction to sort the values. **Returns** `(firstRecord: T, secondRecord: T) => number`: A number indicating the sort order based on the date values. **Throws** - `MIXED_OR_UNSUPPORTED_DATA_TYPES`: if either record has a missing, null, or invalid date value **Example** ```ts import { sortRecordsWithDateValue } from 'jg-utilities'; [{ at: '2026-10-17' }, { at: '2025-01-01' }].sort(sortRecordsWithDateValue('at', 'asc')); // => [{ at: '2025-01-01' }, { at: '2026-10-17' }] ``` #### `splitArrayIntoBatches` *Function* Splits an array into smaller arrays (batches) of a given size. ```ts splitArrayIntoBatches(items: T[], batchSize: number): T[][] ``` **Parameters** - `items` `T[]`: The array to be split into batches. - `batchSize` `number`: The size of each batch. **Returns** `T[][]`: An array of arrays, where each inner array is a batch of the specified size. **Throws** - `INVALID_BATCH_SIZE`: if the batch size is not a valid integer greater than 0 **Example** ```ts import { splitArrayIntoBatches } from 'jg-utilities'; splitArrayIntoBatches([1, 2, 3, 4, 5], 2); // => [[1, 2], [3, 4], [5]] ``` #### `stringifyJson` *Function* Serializes a JSON object with the JSON.stringify method. ```ts stringifyJson(value: T): string ``` **Parameters** - `value` `T`: The value to be serialized. It should be an object or an array. **Returns** `string`: A string representing the serialized JSON object. **Throws** - `UNSUPPORTED_DATA_TYPE`: if the provided value is not an object or an array - `UNABLE_TO_SERIALIZE_JSON`: if the result of JSON.stringify is not a valid string - `UNABLE_TO_SERIALIZE_JSON`: if an error is thrown during stringification **Example** ```ts import { stringifyJson } from 'jg-utilities'; stringifyJson({ id: 1, tags: ['a'] }); // => '{"id":1,"tags":["a"]}' ``` #### `stringifyJsonDeterministically` *Function* Stringifies a JSON object in a deterministic way, ensuring that the keys are sorted and the output is consistent. ```ts stringifyJsonDeterministically(value: T): string ``` **Parameters** - `value` `T`: The value to be serialized. It should be an object or an array. **Returns** `string`: A string representing the serialized JSON object with sorted keys. **Throws** - `UNSUPPORTED_DATA_TYPE`: if the provided value is not an object or an array - `UNABLE_TO_SERIALIZE_JSON`: if the result of JSON.stringify is not a valid string - `UNABLE_TO_SERIALIZE_JSON`: if an error is thrown during stringification **Example** ```ts import { stringifyJsonDeterministically } from 'jg-utilities'; stringifyJsonDeterministically({ b: 1, a: { d: 2, c: 3 } }); // => '{"a":{"c":3,"d":2},"b":1}' ``` #### `stringifyValue` *Function* Converts any value into a string. If the value is an object or an array, it will be stringified with JSON.stringify. ```ts stringifyValue(value: unknown, jsonIndent?: number): string ``` **Parameters** - `value` `unknown`: The value to convert into a string. - `jsonIndent?` `number`: No description. Defaults to `0`. **Returns** `string`: A string representation of the value. **Example** ```ts import { stringifyValue } from 'jg-utilities'; stringifyValue({ id: 1 }); // => '{"id":1}' stringifyValue(42); // => '42' stringifyValue(null); // => 'null' ``` #### `toDate` *Function* Creates an instance of Date based on a value. ```ts toDate(value: IDateValue): Date ``` **Parameters** - `value` `IDateValue`: The value to create a Date instance from. It can be a Date object, a timestamp, or a date string. **Returns** `Date`: A Date instance. **Example** ```ts import { toDate } from 'jg-utilities'; toDate('2026-10-17T08:30:00.000Z'); // a Date for that instant toDate(1_792_217_505_000); // a Date from a millisecond epoch ``` #### `toMS` *Function* Converts a time string into milliseconds. The time string should be in the format of "{value} {unit}", where the value is a number and the unit can be milliseconds, seconds, minutes, hours, days, weeks, months, or years. For example: "2 days", "5 minutes", "2 hours". ```ts toMS( str: `${number} years` | `${number} year` | `${number} months` | `${number} month` | `${number} weeks` | `${number} week` | `${number} days` | `${number} day` | `${number} hours` | `${number} hour` | `${number} minutes` | `${number} minute` | `${number} seconds` | `${number} second` | `${number} milliseconds` | `${number} millisecond`, ): number ``` **Parameters** - `str` `` `${number} years` | `${number} year` | `${number} months` | `${number} month` | `${number} weeks` | `${number} week` | `${number} days` | `${number} day` | `${number} hours` | `${number} hour` | `${number} minutes` | `${number} minute` | `${number} seconds` | `${number} second` | `${number} milliseconds` | `${number} millisecond` ``: The time string to convert into milliseconds. **Returns** `number`: The equivalent time in milliseconds. **Throws** - `INVALID_TIME_STRING`: if the provided time string is not a valid string. - `INVALID_TIME_STRING`: if the chunks do not match the expected format. - `INVALID_TIME_STRING`: if the value is not a valid positive integer. - `INVALID_TIME_STRING`: if the unit is not one of the accepted values. **Example** ```ts import { toMS } from 'jg-utilities'; toMS('2 days'); // => 172800000 toMS('30 minutes'); // => 1800000 ``` #### `toSlug` *Function* Converts a string value into a slug, meeting the following requirements: - lowercase - removes accents/diacritics - replaces spaces and separators with "-" - removes invalid URL characters - collapses repeated "-" - trims leading/trailing "-" ```ts toSlug(value: string): string ``` **Parameters** - `value` `string`: The string value to convert. **Returns** `string`: A string converted to a slug. **Throws** - `UNABLE_TO_SLUGIFY_STRING`: if the resulting slug is empty after processing the input string **Example** ```ts import { toSlug } from 'jg-utilities'; toSlug('Héllo, World! 2026'); // => 'hello-world-2026' ``` #### `toTitleCase` *Function* Converts a string value into Title Case. ```ts toTitleCase(value: string): string ``` **Parameters** - `value` `string`: The string value to convert. **Returns** `string`: A string converted to Title Case. **Example** ```ts import { toTitleCase } from 'jg-utilities'; toTitleCase('the QUICK brown fox'); // => 'The Quick Brown Fox' ``` #### `toUtcDate` *Function* Gives the UTC calendar date of an instant, as `YYYY-MM-DD`, such as the day a UTC report or analytics period counts it in. The instant must fall in the years 0000 to 9999. ```ts toUtcDate(value?: IDateValue): string ``` **Parameters** - `value?` `IDateValue`: No description. **Returns** `string`: The UTC date, such as `2026-10-17`. **Throws** - `INVALID_DATE`: The value is not a valid date in the years 0000 to 9999. **Example** ```ts import { toUtcDate } from 'jg-utilities'; toUtcDate('2026-10-17T23:30:00.000-05:00'); // => '2026-10-18' toUtcDate(0); // => '1970-01-01' toUtcDate(); // today's UTC date ``` #### `toYouTubeEmbedUrl` *Function* Builds the URL that embeds a YouTube video in an iframe, on YouTube's privacy-enhanced domain, which sets no cookies until the video plays. ```ts toYouTubeEmbedUrl(videoId: string): string ``` **Parameters** - `videoId` `string`: The video id, such as one parseYouTubeVideoId read. **Returns** `string`: The URL, `https://www.youtube-nocookie.com/embed/`. **Throws** - `INVALID_YOUTUBE_VIDEO_ID`: The YouTube video id must be 11 letters, digits, '-', or '_'. **Example** ```ts import { toYouTubeEmbedUrl } from 'jg-utilities'; toYouTubeEmbedUrl('M7lc1UVf-VE'); // => 'https://www.youtube-nocookie.com/embed/M7lc1UVf-VE' ``` #### `toYouTubeWatchUrl` *Function* Builds the canonical watch page URL of a YouTube video, such as for a link. ```ts toYouTubeWatchUrl(videoId: string): string ``` **Parameters** - `videoId` `string`: The video id, such as one parseYouTubeVideoId read. **Returns** `string`: The URL, `https://www.youtube.com/watch?v=`. **Throws** - `INVALID_YOUTUBE_VIDEO_ID`: The YouTube video id must be 11 letters, digits, '-', or '_'. **Example** ```ts import { toYouTubeWatchUrl } from 'jg-utilities'; toYouTubeWatchUrl('M7lc1UVf-VE'); // => 'https://www.youtube.com/watch?v=M7lc1UVf-VE' ``` #### `truncateText` *Function* Truncates a string to a specified length and appends an ellipsis if it exceeds that length. ```ts truncateText(text: string, maxLength: number): string ``` **Parameters** - `text` `string`: The string value to truncate. - `maxLength` `number`: The maximum length of the string before truncation. **Returns** `string`: A string truncated to the specified length with an ellipsis if necessary. **Example** ```ts import { truncateText } from 'jg-utilities'; truncateText('The quick brown fox jumps', 12); // => 'The quick...' ``` #### `truncateToUtf8Bytes` *Function* Shortens a string to the longest prefix of whole code points that fits a number of UTF-8 bytes, such as a push notification's body under the provider's payload limit. A character made of several code points, such as an emoji with a skin tone, can lose its last ones, but a code point is never split. ```ts truncateToUtf8Bytes(text: string, maxBytes: number): string ``` **Parameters** - `text` `string`: The string. - `maxBytes` `number`: The most bytes the result may take in UTF-8, a non-negative safe integer. **Returns** `string`: The string itself when it fits, otherwise its longest prefix that does. **Throws** - `INVALID_BYTE_LIMIT`: The byte limit must be a non-negative safe integer. **Example** ```ts import { truncateToUtf8Bytes } from 'jg-utilities'; truncateToUtf8Bytes('Hello, world', 5); // => 'Hello' truncateToUtf8Bytes('café au lait', 4); // => 'caf' truncateToUtf8Bytes('café au lait', 5); // => 'café' ``` #### `waitUntil` *Function* Probes a condition until it returns a value other than null, undefined or false, and resolves with that value. It probes immediately, then after every interval, and one final time at the deadline. The timeout bounds the whole call, so it rejects even while a probe is still pending. A running probe isn't cancelled when the call stops waiting; pass your own signal into it when it supports one. ```ts waitUntil( probe: () => T | Promise, options: IWaitUntilOptions, ): Promise> ``` **Parameters** - `probe` `() => T | Promise`: The function that checks the condition. - `options` `IWaitUntilOptions`: The timeout, the interval between probes and the signal that stops the wait. **Returns** `Promise>`: A promise that resolves with the first ready value. **Throws** - `WAIT_UNTIL_TIMED_OUT`: if the condition isn't met before the timeout. - The probe's error when a probe throws or rejects. - The signal's reason when the signal aborts. **Example** ```ts import { waitUntil } from 'jg-utilities'; const startedAt = Date.now(); const readStatus = (): string => (Date.now() - startedAt > 200 ? 'ready' : 'starting'); // the probe runs every 0.1 seconds until it returns something other than undefined await waitUntil(() => (readStatus() === 'ready' ? readStatus() : undefined), { timeoutSeconds: 5, intervalSeconds: 0.1, }); // => 'ready' ``` ### Constants #### `ERRORS` *Constant* the codes for all the errors thrown by the library ```ts const ERRORS: { MIXED_OR_UNSUPPORTED_DATA_TYPES: "MIXED_OR_UNSUPPORTED_DATA_TYPES"; UNSUPPORTED_DATA_TYPE: "UNSUPPORTED_DATA_TYPE"; INVALID_OR_EMPTY_ARRAY: "INVALID_OR_EMPTY_ARRAY"; INVALID_OR_EMPTY_OBJECT: "INVALID_OR_EMPTY_OBJECT"; UNABLE_TO_SERIALIZE_JSON: "UNABLE_TO_SERIALIZE_JSON"; UNABLE_TO_DESERIALIZE_JSON: "UNABLE_TO_DESERIALIZE_JSON"; INVALID_BATCH_SIZE: "INVALID_BATCH_SIZE"; UNABLE_TO_SLUGIFY_STRING: "UNABLE_TO_SLUGIFY_STRING"; INVALID_TIME_STRING: "INVALID_TIME_STRING"; INVALID_AUTHORIZATION_HEADER: "INVALID_AUTHORIZATION_HEADER"; INVALID_EMAIL_ADDRESS: "INVALID_EMAIL_ADDRESS"; INVALID_BASE64URL: "INVALID_BASE64URL"; WAIT_UNTIL_TIMED_OUT: "WAIT_UNTIL_TIMED_OUT"; INVALID_CURSOR: "INVALID_CURSOR"; INVALID_DATE: "INVALID_DATE"; INVALID_DAY_COUNT: "INVALID_DAY_COUNT"; INVALID_DATE_RANGE: "INVALID_DATE_RANGE"; INVALID_BYTE_LIMIT: "INVALID_BYTE_LIMIT"; INVALID_YOUTUBE_VIDEO_ID: "INVALID_YOUTUBE_VIDEO_ID"; INVALID_CONCURRENCY_LIMIT: "INVALID_CONCURRENCY_LIMIT"; INVALID_QUEUE_LIMIT: "INVALID_QUEUE_LIMIT"; REQUEST_QUEUE_FULL: "REQUEST_QUEUE_FULL"; REQUEST_COOLDOWN_ACTIVE: "REQUEST_COOLDOWN_ACTIVE"; REQUEST_COORDINATOR_CLOSED: "REQUEST_COORDINATOR_CLOSED"; CURSOR_TOO_LONG: "CURSOR_TOO_LONG" } ``` #### `MAX_EMAIL_LENGTH` *Constant* the maximum length for an email address according to the standard (RFC 5321) ```ts const MAX_EMAIL_LENGTH: 320 ``` #### `ONE_DAY_IN_MILLISECONDS` *Constant* one day in milliseconds ```ts const ONE_DAY_IN_MILLISECONDS: number ``` #### `ONE_HOUR_IN_MILLISECONDS` *Constant* one hour in milliseconds ```ts const ONE_HOUR_IN_MILLISECONDS: number ``` #### `ONE_MINUTE_IN_MILLISECONDS` *Constant* one minute in milliseconds ```ts const ONE_MINUTE_IN_MILLISECONDS: number ``` #### `ONE_MONTH_IN_MILLISECONDS` *Constant* one average month, a twelfth of an average year, in milliseconds ```ts const ONE_MONTH_IN_MILLISECONDS: number ``` #### `ONE_SECOND_IN_MILLISECONDS` *Constant* one second in milliseconds ```ts const ONE_SECOND_IN_MILLISECONDS: 1000 ``` #### `ONE_WEEK_IN_MILLISECONDS` *Constant* one week in milliseconds ```ts const ONE_WEEK_IN_MILLISECONDS: number ``` #### `ONE_YEAR_IN_MILLISECONDS` *Constant* one average year, of 365.25 days, in milliseconds ```ts const ONE_YEAR_IN_MILLISECONDS: number ``` #### `SECRET_TOKEN_PATTERNS` *Constant* The single-line secrets redactSecrets finds by default: GitHub's personal, OAuth, user, server, and refresh tokens, its fine-grained personal access tokens, and `sk-` API keys, such as OpenAI's, each with at least 20 characters after its prefix. ```ts const SECRET_TOKEN_PATTERNS: readonly RegExp[] ``` ### Types #### `ICursorCodec` *Type* encodes a list's continuation as a cursor, and decodes one only for the list it came from ```ts type ICursorCodec = { encode: (payload: TPayload) => string; decode: (cursor: string | null | undefined, scope: TScope) => TPayload | undefined; }; ``` **Members** - `encode: (payload: TPayload) => string` (property) - `decode: (cursor: string | null | undefined, scope: TScope) => TPayload | undefined` (property) #### `ICursorCodecOptions` *Type* the options of createCursorCodec ```ts type ICursorCodecOptions = { schema: ISchema; maxLength?: number; matchesScope?: (payload: TPayload, scope: TScope) => boolean; }; ``` **Members** - `schema: ISchema` (property): validates the payload when it's encoded and when a cursor is decoded - `maxLength: number` (property): the longest cursor, in characters, that's encoded or decoded. Defaults to 2048 - `matchesScope: (payload: TPayload, scope: TScope) => boolean` (property): Whether a decoded payload belongs to the list being read. Without it, every key of the scope must equal the payload's, with null and undefined counting as the same value. #### `ICursorValue` *Type* the JSON a cursor can hold: an object or an array, as pagination continuations are ```ts type ICursorValue = { [key: string]: IJsonValue; } | IJsonValue[]; ``` #### `IDateTemplate` *Type* Date Template A date can be prettified by choosing a template that meets the user's requirements. - date-short: 12/05/2024 - date-medium: December 5, 2024 - date-long: Thursday, December 5, 2024 - time-short: 9:05 PM - time-medium: 9:05:20 PM - datetime-short: 12/5/2024, 9:05 PM - datetime-medium: December 5, 2024 at 9:05 PM - datetime-long: Thursday, December 5, 2024 at 9:05:20 PM ```ts type IDateTemplate = 'date-short' | 'date-medium' | 'date-long' | 'time-short' | 'time-medium' | 'datetime-short' | 'datetime-medium' | 'datetime-long'; ``` #### `IDateValue` *Type* the value that can be used to create a Date instance ```ts type IDateValue = Date | number | string; ``` #### `IDelayOptions` *Type* the options that can be passed to the delay function ```ts type IDelayOptions = { signal?: AbortSignal | undefined; }; ``` **Members** - `signal: AbortSignal` (property): aborts the wait and rejects with the signal's reason #### `IEventBus` *Type* an in-process event bus, whose events and their payloads are the keys and values of TEvents ```ts type IEventBus> = { emit: (name: TName, event: TEvents[TName]) => void; subscribe: (name: TName, handler: (event: TEvents[TName]) => unknown) => () => void; close: () => void; }; ``` **Members** - `emit: (name: TName, event: TEvents[TName]) => void` (property): delivers the event to its handlers later, and returns at once - `subscribe: (name: TName, handler: (event: TEvents[TName]) => unknown) => () => void` (property): registers a handler for an event, and returns the function that unsubscribes it - `close: () => void` (property): drops the pending deliveries and every handler, and ignores later emits and subscriptions #### `IEventBusOptions` *Type* what an event bus calls with each error a handler throws or rejects with ```ts type IEventBusOptions> = { onError: (error: unknown, context: { name: keyof TEvents; event: TEvents[keyof TEvents]; }) => void; }; ``` **Members** - `onError: (error: unknown, context: { name: keyof TEvents; event: TEvents[keyof TEvents] }) => void` (property): receives the error and the event the handler was handling; anything it throws is swallowed #### `IFilterByQueryOptions` *Type* the options that can be used to filter items by a query ```ts type IFilterByQueryOptions = { queryProp?: keyof T; limit?: number; }; ``` **Members** - `queryProp: keyof T` (property): the property to filter by, if not provided, the whole item will be used - `limit: number` (property): the maximum number of items to return #### `IJsonValue` *Type* A value that survives a trip through JSON. An object's values may also be `undefined`, because `JSON.stringify` drops them, so an object with optional fields fits. Interfaces and classes don't fit, because they have no index signature: pass an interface-typed value as `{ ...value }`, or declare its type with `type`. ```ts type IJsonValue = string | number | boolean | null | IJsonValue[] | { [key: string]: IJsonValue | undefined; }; ``` #### `INumberFormatConfig` *Type* Number Format Configuration The configuration that will be used to prettify a number. ```ts type INumberFormatConfig = { minimumFractionDigits: number; maximumFractionDigits: number; prefix: string; suffix: string; }; ``` **Members** - `minimumFractionDigits: number` (property) - `maximumFractionDigits: number` (property) - `prefix: string` (property) - `suffix: string` (property) #### `IRedactSecretsOptions` *Type* the options accepted by redactSecrets ```ts type IRedactSecretsOptions = { patterns?: readonly RegExp[]; replacement?: string; preserveLineBreaks?: boolean; }; ``` **Members** - `patterns: readonly RegExp[]` (property): the secrets to find, after the private keys. Defaults to SECRET_TOKEN_PATTERNS - `replacement: string` (property): what each secret becomes. Defaults to '[redacted]' - `preserveLineBreaks: boolean` (property): whether a private key keeps its line breaks, so the text's line numbers stay the same #### `IRedactSecretsResult` *Type* the text redactSecrets returns, and how many secrets it replaced ```ts type IRedactSecretsResult = { text: string; count: number; }; ``` **Members** - `text: string` (property) - `count: number` (property) #### `IRequestCoordinator` *Type* runs tasks within a concurrency limit and a bounded queue, and pauses them during a cooldown ```ts type IRequestCoordinator = { run: (task: () => Promise, options?: IRunRequestOptions) => Promise; deferUntil: (deadline: Date | number) => void; isIdle: () => boolean; close: () => void; }; ``` **Members** - `run: (task: () => Promise, options?: IRunRequestOptions) => Promise` (property): runs the task once a slot is free and no cooldown is active, and settles as it does - `deferUntil: (deadline: Date | number) => void` (property): pauses queued tasks until the deadline, a Date or epoch milliseconds, keeping a later one - `isIdle: () => boolean` (property): whether no task runs or waits and no cooldown is active, such as before discarding it - `close: () => void` (property): rejects the waiting tasks and every later run, and lets the running ones finish #### `IRequestCoordinatorOptions` *Type* the limits of createRequestCoordinator ```ts type IRequestCoordinatorOptions = { maxConcurrent: number; maxQueued: number; }; ``` **Members** - `maxConcurrent: number` (property): how many tasks run at once, at least 1 - `maxQueued: number` (property): how many tasks can wait for a slot, across every caller, at least 0 #### `IRetryAsyncFunctionOptions` *Type* the options that can be passed to the retryAsyncFunction function ```ts type IRetryAsyncFunctionOptions = { signal?: AbortSignal; shouldRetry?: (error: unknown) => boolean; }; ``` **Members** - `signal: AbortSignal` (property): stops retrying and rejects with the signal's reason, even while an attempt is pending - `shouldRetry: (error: unknown) => boolean` (property): decides whether a failed attempt is retried; returning false rethrows the error immediately #### `IRunRequestOptions` *Type* the options of one task a request coordinator runs ```ts type IRunRequestOptions = { signal?: AbortSignal; caller?: unknown; maxQueuedForCaller?: number; rejectDuringCooldown?: boolean; }; ``` **Members** - `signal: AbortSignal` (property): removes the task from the queue and rejects with the signal's reason when it aborts first - `caller: unknown` (property): who the task runs for, compared with ===, so maxQueuedForCaller can limit each caller - `maxQueuedForCaller: number` (property): how many of this caller's tasks can wait at once, at least 0 - `rejectDuringCooldown: boolean` (property): rejects with REQUEST_COOLDOWN_ACTIVE during a cooldown, instead of waiting it out #### `ISchema` *Type* a validator for data from outside the program, such as a Zod schema: parse returns the data, or throws when it doesn't fit ```ts type ISchema = { parse(value: unknown): T; }; ``` **Members** - `parse(value: unknown): T` (method) #### `ISortDirection` *Type* the sort direction that can be applied to a list ```ts type ISortDirection = 'asc' | 'desc'; ``` #### `ISubstitutionOptions` *Type* the options that can be passed to the applySubstitutions function ```ts type ISubstitutionOptions = { jsonIndent: number; }; ``` **Members** - `jsonIndent: number` (property) #### `ITimeString` *Type* a duration such as '2 days' or '30 minutes', which toMS converts to milliseconds ```ts type ITimeString = `${number} ${IUnit}`; ``` #### `IUtcDateRange` *Type* an inclusive range of UTC calendar dates, each as `YYYY-MM-DD` ```ts type IUtcDateRange = { fromDate: string; toDate: string; }; ``` **Members** - `fromDate: string` (property) - `toDate: string` (property) #### `IUuidVersion` *Type* UUID Version The UUID versions supported by this library. ```ts type IUuidVersion = 4 | 7; ``` #### `IWaitUntilOptions` *Type* the options that can be passed to the waitUntil function ```ts type IWaitUntilOptions = { timeoutSeconds: number; intervalSeconds?: number; signal?: AbortSignal; }; ``` **Members** - `timeoutSeconds: number` (property): the maximum number of seconds the whole call can take - `intervalSeconds: number` (property): the number of seconds to wait between probes. Defaults to 1 - `signal: AbortSignal` (property): stops waiting and rejects with the signal's reason, even while a probe is pending --- # jg-utilities recipes Patterns for apps that page through lists, cache results by request, check what clients send, and share a rate-limited API, with jg-utilities' cursor codecs, JSON helpers, validations, and request coordinator. ## Paginating with cursors For a list that grows while people page through it, such as a feed or an order history, page by position instead of by offset. Each page ends with a cursor that holds the sort keys of its last row, and the next request reads the rows after it. Rows added in between don't shift the pages, and the database seeks to the position instead of counting past an offset. ```ts import { createCursorCodec, isStringValid } from 'jg-utilities'; // an order in the list, which is sorted newest first type IOrder = { id: string; createdAt: string; total: number }; // the list's filter, and where a page ended: the sort keys of its last row type IOrderCursor = { status: string | null; createdAt: string; id: string }; // a page, and the cursor for the next one, or null after the last type IOrderPage = { items: IOrder[]; nextCursor: string | null }; // reads the orders with a status after a position, or from the start for null, such as with // WHERE status = $1 AND (created_at, id) < ($2, $3) ORDER BY created_at DESC, id DESC LIMIT $4 declare function findOrders( status: string | null, after: IOrderCursor | null, limit: number, ): Promise; const PAGE_SIZE = 20; // a client can encode any JSON itself, so the codec checks each cursor with this schema, which // could be a Zod schema instead, and accepts it only for the status it was made for const orderCursors = createCursorCodec({ schema: { parse: (value) => { const { status, createdAt, id } = value as Partial; if ( (status !== null && !isStringValid(status)) || !isStringValid(createdAt) || !isStringValid(id) ) { throw new TypeError('The value is not an order cursor.'); } return { status, createdAt, id }; }, }, }); // a plain handler: it takes the request's query string and returns the page const listOrders = async (searchParams: URLSearchParams): Promise => { const status = searchParams.get('status'); const after = orderCursors.decode(searchParams.get('cursor'), { status }) ?? null; // one row more than a page holds says whether another page follows const rows = await findOrders(status, after, PAGE_SIZE + 1); const items = rows.slice(0, PAGE_SIZE); const last = items.at(-1); return { items, nextCursor: rows.length > PAGE_SIZE && last !== undefined ? orderCursors.encode({ status, createdAt: last.createdAt, id: last.id }) : null, }; }; ``` Without a cursor, `decode` returns `undefined`, the first page. A cursor it can't accept throws `INVALID_CURSOR`, whether its encoding, its shape, or its status is wrong, which `data.reason` says, so your error handler answers it with a `400`. The codec also refuses a cursor longer than 2048 characters, unless you pass another `maxLength`, and won't encode one that long either. A cursor is opaque, but not secret or signed: it's base64url JSON, which a client can decode and change. The status check stops a cursor made for one filter from being replayed with another, but a client could still write one by hand, so this is only harmless while a cursor picks a position in rows the user can already read. Never put anything in a cursor that the client mustn't see. When an API pages by the last row's id instead, such as `?after=42`, `getNextPageParam` reads the next id from a page: the last row's when the page is full, or `undefined` after a short page, which is the last. ```ts import { getNextPageParam } from 'jg-utilities'; // a chat message type IMessage = { id: number; text: string }; // reads up to limit messages after an id, oldest first, or from the start without one declare function fetchMessages(after: number | undefined, limit: number): Promise; const PAGE_SIZE = 50; // reads every page, until a short one says there are no more const messages: IMessage[] = []; let after: number | undefined; do { const page = await fetchMessages(after, PAGE_SIZE); messages.push(...page); after = getNextPageParam('id', page, PAGE_SIZE); } while (after !== undefined); ``` When the last page happens to be full, this costs one more request, which comes back empty. A server that sends a `nextCursor`, as the handler above does, saves it. ## Stable cache keys from JSON To cache a result by the request that produced it, such as a search, build the key from the request's parameters with `stringifyJsonDeterministically`, which sorts object keys at every depth. `{ q, sort }` and `{ sort, q }` then share one entry, where `JSON.stringify` would give two. ```ts import { createHash } from 'node:crypto'; import { normalizeQuery, parseJson, pruneJson, stringifyJson, stringifyJsonDeterministically, } from 'jg-utilities'; // the filters of a product search, as the request sends them type ISearchFilters = { q?: string; tags?: string[]; maxPrice?: number | null; sort?: 'price' | 'newest'; }; // a product a search finds type IProduct = { id: string; name: string; price: number }; // a key-value store with expiry, such as Redis declare const cache: { get(key: string): Promise; set(key: string, value: string, ttlSeconds: number): Promise; }; // runs the search on the database declare function searchProducts(filters: ISearchFilters): Promise; // the same search gives the same key, however the client ordered or spaced it const toSearchCacheKey = (filters: ISearchFilters): string => { // pruneJson drops null, undefined, and empty arrays and objects, so { tags: [] } matches {} const normalized = pruneJson({ ...filters, // text that differs only in case or spacing finds the same products, and blank text is none q: normalizeQuery(filters.q ?? '') || undefined, // the tags' order doesn't change the result, so it doesn't change the key tags: filters.tags?.toSorted(), }); const json = stringifyJsonDeterministically(normalized ?? {}); // a hash keeps the key short whatever the filters hold; the prefix says what's cached, and the // version of its shape return `products:search:v1:${createHash('sha256').update(json).digest('base64url')}`; }; const searchProductsCached = async (filters: ISearchFilters): Promise => { const key = toSearchCacheKey(filters); const cached = await cache.get(key); if (cached !== null) { return parseJson(cached); } const products = await searchProducts(filters); await cache.set(key, stringifyJson(products), 60); return products; }; ``` Arrays keep their order, because it often matters, so sort the ones where it doesn't, as the tags are here. Change the version in the prefix when the cached value's shape changes, so old entries are never read as new ones. The same key works in memory without a hash, such as to share one request between the parts of a page that ask for the same search at once: ```ts import { stringifyJsonDeterministically } from 'jg-utilities'; // the filters of a product search type ISearchFilters = { q?: string; tags?: string[]; sort?: 'price' | 'newest' }; // a product a search finds type IProduct = { id: string; name: string; price: number }; // sends the search to the API declare function fetchProducts(filters: ISearchFilters): Promise; // the searches in flight, by key const inFlight = new Map>(); const searchProductsOnce = (filters: ISearchFilters): Promise => { const key = stringifyJsonDeterministically(filters); const pending = inFlight.get(key); if (pending !== undefined) { return pending; } // the entry leaves once the request settles, so a later search asks again const search = fetchProducts(filters).finally(() => inFlight.delete(key)); inFlight.set(key, search); return search; }; ``` ## Validating input at the edge Check what a client sends where it enters your app, then pass typed values inward. jg-utilities has no schema builder: its validations are type guards, which fit a handful of flat fields. For nested or larger input, use a schema, such as a Zod one. ```ts import { Exception } from 'jg-exception'; import { isEmailValid, isObjectValid, isPasswordValid, isStringValid } from 'jg-utilities'; // the sign-up form, once it's checked type ISignUpInput = { email: string; password: string; name: string }; // one code for every rejected field, with the field's name in data, so the client can mark it const invalidInput = (field: string): Exception => new Exception(`The ${field} is invalid.`, { code: 'INVALID_INPUT', data: { field } }); // checks the parsed JSON body one field at a time const readSignUpInput = (body: unknown): ISignUpInput => { if (!isObjectValid(body)) { throw invalidInput('body'); } const { email, password, name } = body; if (!isEmailValid(email)) { throw invalidInput('email'); } if (!isPasswordValid(password)) { throw invalidInput('password'); } if (!isStringValid(name, 1, 100)) { throw invalidInput('name'); } return { email: email.toLowerCase(), password, name: name.trim() }; }; ``` Each guard narrows its field, so the returned object needs no casts, and building a new object drops whatever else the client sent, such as an `isAdmin` flag. The error names the field in `data`, never its value, which here could be a password. Query strings and headers arrive as strings or `null`. Check each string before you convert it, and map each code to a status in one place: ```ts import { buildResponse } from 'jg-api-response'; import { Exception, hasErrorCode } from 'jg-exception'; import { extractTokenFromAuthorizationHeader, isAuthorizationHeaderValid, isIntegerValid, isNumeric, normalizeQuery, } from 'jg-utilities'; // an order a search finds type IOrder = { id: string; total: number }; // reads the user a token belongs to, such as by verifying a JWT declare function verifyToken(token: string): Promise<{ userId: string }>; // searches a user's orders declare function searchOrders(userId: string, query: string, limit: number): Promise; // reads ?limit=, from 1 to 100, or 20 without one const readLimit = (value: string | null): number => { if (value === null) { return 20; } // isNumeric rejects '', ' 12', and '0x10', which Number reads as 0, 12, and 16 const limit = Number(value); if (!isNumeric(value) || !isIntegerValid(limit, 1, 100)) { throw new Exception('The limit is invalid.', { code: 'INVALID_INPUT', data: { field: 'limit' }, }); } return limit; }; // a plain handler: the framework passes the query string and the Authorization header, and // sends back the status and the body const listOrders = async (searchParams: URLSearchParams, authorization: string | null) => { try { if (!isAuthorizationHeaderValid(authorization)) { throw new Exception('The request has no valid bearer token.', { code: 'UNAUTHENTICATED' }); } const { userId } = await verifyToken(extractTokenFromAuthorizationHeader(authorization)); const limit = readLimit(searchParams.get('limit')); // searches that differ only in case or spacing run as one, cut to 100 characters const query = normalizeQuery(searchParams.get('q') ?? '', 100); return { status: 200, body: buildResponse(await searchOrders(userId, query, limit)) }; } catch (error) { if (hasErrorCode(error, 'UNAUTHENTICATED')) { return { status: 401, body: buildResponse(null, error) }; } if (hasErrorCode(error, 'INVALID_INPUT')) { return { status: 400, body: buildResponse(null, error) }; } // anything else is the app's own failure, for the error handler every route shares throw error; } }; ``` Checking the header with `isAuthorizationHeaderValid` before `extractTokenFromAuthorizationHeader` turns a missing or malformed header into the handler's own `UNAUTHENTICATED`, answered with a 401, so it never reaches the shared error handler as jg-utilities' `INVALID_AUTHORIZATION_HEADER`, which that handler doesn't know. ## Waiting out a rate limit across requests Use this when many requests share one rate-limited API, such as GitHub's for an app installation. A coordinator runs at most a few at a time, queues the rest, and, once a response says to wait with `Retry-After`, holds every queued request until then, without retrying them one by one. jg-fetch's retries handle one request; the coordinator keeps a whole app's requests in line. ```ts import { hasErrorCode } from 'jg-exception'; import { createRequestCoordinator, ERRORS } from 'jg-utilities'; // GitHub's requests for every installation, four at a time, with up to 256 waiting const github = createRequestCoordinator({ maxConcurrent: 4, maxQueued: 256 }); // the time a Retry-After header names, as seconds or as an HTTP date, in epoch milliseconds const readRetryAt = (response: Response): number | undefined => { const header = response.headers.get('retry-after'); if (header === null) { return undefined; } const seconds = Number(header); const retryAt = Number.isFinite(seconds) ? Date.now() + seconds * 1000 : Date.parse(header); return Number.isNaN(retryAt) ? undefined : retryAt; }; // one installation's request, which waits behind at most 32 of its own export const requestGitHub = (installationId: number, url: string, signal: AbortSignal) => github.run( async () => { const response = await fetch(url, { signal }); const retryAt = response.status === 403 || response.status === 429 ? readRetryAt(response) : undefined; if (retryAt !== undefined) { github.deferUntil(retryAt); } return response; }, { signal, caller: installationId, maxQueuedForCaller: 32 }, ); // a webhook that answers at once instead of waiting: GitHub redelivers it after a 503 export const handleWebhook = async (installationId: number, url: string, signal: AbortSignal) => { try { await github.run(() => fetch(url, { signal }), { signal, caller: installationId, rejectDuringCooldown: true, }); return { status: 202 }; } catch (error) { if ( hasErrorCode(error, ERRORS.REQUEST_COOLDOWN_ACTIVE) || hasErrorCode(error, ERRORS.REQUEST_QUEUE_FULL) ) { return { status: 503 }; } throw error; } }; ``` A request that waits for a slot holds nothing: the cooldown blocks no slot, and an aborted signal takes its request out of the queue. `maxQueuedForCaller` keeps one busy installation from filling the queue for the rest. Discard the coordinator only when `isIdle()` is true, or call `close()`, which rejects what's still waiting. --- # jg-bignumber > Exact decimal math on bignumber.js: rounding modes, formatting, and financial calculations such as percentage changes. - **Version:** `1.4.0` - **Runtimes:** Universal - **Install:** `npm install jg-bignumber` - **On npm:** [jg-bignumber](https://www.npmjs.com/package/jg-bignumber) - **Source:** [packages/jg-bignumber](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-bignumber) - **Recipes:** [jg-bignumber recipes](https://jg-packages.jesusgraterol.dev/packages/jg-bignumber/recipes/index.md) ## Usage Every calculation is exact, then rounded to 2 decimal places with `ROUND_HALF_UP` and returned as a number, unless the configuration asks otherwise: ```ts import { calculateSum, processValue, prettifyValue } from 'jg-bignumber'; calculateSum([0.1, '0.2', 0.3]); // 0.6, not 0.6000000000000001 processValue(512.155); // 512.16 processValue(19.991, { roundingMode: 'ROUND_CEIL' }); // 20 processValue('0.000001', { decimalPlaces: 6, type: 'string' }); // '0.000001' prettifyValue(1234.5, { format: { prefix: '$' } }); // '$1,234.5' prettifyValue(1234.5, { format: { prefix: '$', minimumDecimalPlaces: 2 } }); // '$1,234.50' prettifyValue(-5, { format: { prefix: '$' } }); // '-$5' ``` Percentages and money: ```ts import { adjustByPercentage, calculateExchange, calculatePercentageChange, calculateWeightedEntry, } from 'jg-bignumber'; calculatePercentageChange(100, 125); // 25 adjustByPercentage(100, -10); // 90 calculateExchange(100, 65000, { decimalPlaces: 8 }); // 0.00153846 calculateWeightedEntry([ [100, 2], [130, 1], ]); // 110, the average price weighted by amount ``` Store money as an integer count of its smallest unit, such as microcents in a PostgreSQL `BIGINT`, and convert it exactly, never rounding: ```ts import { applyBasisPoints, fromScaledInteger, toScaledInteger } from 'jg-bignumber'; toScaledInteger('1.23', 8); // 123000000n toScaledInteger('0.123456789', 8); // throws INVALID_VALUE: the 9th decimal would be lost toScaledInteger(1e-8, 8); // throws INVALID_VALUE: JavaScript writes it as '1e-8' fromScaledInteger(123000000n, 8); // '1.23000000' applyBasisPoints(100000000n, 500); // 5000000n: a 5% commission, rounded down to a whole unit ``` `BigNumber` is the package's own bignumber.js constructor, so creating values with it never changes the global bignumber.js configuration. ## jg-bignumber API reference Every export of `jg-bignumber@1.4.0`, 33 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. - Functions: [`adjustByPercentage`](#api-adjustByPercentage), [`applyBasisPoints`](#api-applyBasisPoints), [`calculateExchange`](#api-calculateExchange), [`calculateExchangeFee`](#api-calculateExchangeFee), [`calculateMax`](#api-calculateMax), [`calculateMean`](#api-calculateMean), [`calculateMedian`](#api-calculateMedian), [`calculateMin`](#api-calculateMin), [`calculatePercentageChange`](#api-calculatePercentageChange), [`calculatePercentageRepresentation`](#api-calculatePercentageRepresentation), [`calculateSum`](#api-calculateSum), [`calculateWeightedEntry`](#api-calculateWeightedEntry), [`fromScaledInteger`](#api-fromScaledInteger), [`getBigNumber`](#api-getBigNumber), [`isBigNumber`](#api-isBigNumber), [`isFloat`](#api-isFloat), [`isInteger`](#api-isInteger), [`isNumber`](#api-isNumber), [`prettifyValue`](#api-prettifyValue), [`processValue`](#api-processValue), [`toScaledInteger`](#api-toScaledInteger) - Constants: [`BigNumber`](#api-BigNumber), [`ERRORS`](#api-ERRORS) - Types: [`IBigNumber`](#api-IBigNumber), [`IBigNumberFormat`](#api-IBigNumberFormat), [`IBigNumberRoundingMode`](#api-IBigNumberRoundingMode), [`IBigNumberRoundingModeName`](#api-IBigNumberRoundingModeName), [`IBigNumberToType`](#api-IBigNumberToType), [`IBigNumberValue`](#api-IBigNumberValue), [`IConfig`](#api-IConfig), [`IOutput`](#api-IOutput), [`IPrettifyFormat`](#api-IPrettifyFormat), [`IType`](#api-IType) ### Functions #### `adjustByPercentage` *Function* Changes a value by a percentage: a positive percentage increases it, and a negative one decreases it, to 0 at most. A value of 0 stays 0. ```ts adjustByPercentage>( value: Value, percentage: Value, config?: T, ): IOutput ``` **Parameters** - `value` `Value`: The value to change. - `percentage` `Value`: The percentage to change it by. - `config?` `T`: How the result is processed. **Returns** `IOutput`: The changed value. **Throws** - `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported - `NEGATIVE_VALUE_NOT_ALLOWED`: if the value is negative **Example** ```ts import { adjustByPercentage } from 'jg-bignumber'; adjustByPercentage(100, 25); // => 125 adjustByPercentage(100, -10); // => 90 ``` #### `applyBasisPoints` *Function* Applies a rate in basis points to an integer count of an amount's smallest unit, such as a 5% commission on a payment in microcents, rounding down to a whole unit, so the result never exceeds what the rate gives. 100 basis points are 1%, and 10,000 are 100%. ```ts applyBasisPoints(value: bigint, basisPoints: number): bigint ``` **Parameters** - `value` `bigint`: The amount in the smallest unit, 0 or more. - `basisPoints` `number`: The rate, a non-negative safe integer; it may exceed 10,000 for more than 100%. **Returns** `bigint`: `value × basisPoints / 10,000`, rounded down. **Throws** - `INVALID_VALUE`: The value must be a bigint. - `NEGATIVE_VALUE_NOT_ALLOWED`: The value must be greater than or equal to 0. - `INVALID_VALUE`: The basis points must be a non-negative safe integer. **Example** ```ts import { applyBasisPoints } from 'jg-bignumber'; applyBasisPoints(100_000_000n, 500); // => 5000000n applyBasisPoints(123_000_000n, 1500); // => 18450000n applyBasisPoints(10_001n, 1); // => 1n ``` #### `calculateExchange` *Function* Calculates the amount of an asset an exchange gives for a value at a rate, such as how much BTC 100 USDT buys at 65,000 USDT per BTC. ```ts calculateExchange>( value: Value, rate: Value, config?: T, ): IOutput ``` **Parameters** - `value` `Value`: The amount being exchanged. - `rate` `Value`: The price of one unit of the asset received. - `config?` `T`: How the result is processed. **Returns** `IOutput`: The amount received. **Throws** - `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported - `NEGATIVE_VALUE_NOT_ALLOWED`: if the value or rate are not positive **Example** ```ts import { calculateExchange } from 'jg-bignumber'; calculateExchange(100, 65000, { decimalPlaces: 8 }); // => 0.00153846 ``` #### `calculateExchangeFee` *Function* Calculates the fee charged for an exchange, as a percentage of the value exchanged. ```ts calculateExchangeFee>( value: Value, feePercentage: Value, config?: T, ): IOutput ``` **Parameters** - `value` `Value`: The amount being exchanged. - `feePercentage` `Value`: The fee, as a percentage. - `config?` `T`: How the result is processed. **Returns** `IOutput`: The fee. **Throws** - `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported - `NEGATIVE_VALUE_NOT_ALLOWED`: if the value or feePercentage are not positive **Example** ```ts import { calculateExchangeFee } from 'jg-bignumber'; calculateExchangeFee(1000, 0.1); // => 1 ``` #### `calculateMax` *Function* Finds the largest value in a list. An empty list returns 0. ```ts calculateMax>( values: Value[], config?: T, ): IOutput ``` **Parameters** - `values` `Value[]`: The values. - `config?` `T`: How the result is processed. **Returns** `IOutput`: The largest value. **Throws** - `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported - `INVALID_VALUES_ARRAY`: if the provided values arg is not a valid array **Example** ```ts import { calculateMax } from 'jg-bignumber'; calculateMax([3, '1.5', 2]); // => 3 ``` #### `calculateMean` *Function* Calculates the mean of a list of values. An empty list returns 0. ```ts calculateMean>( values: Value[], config?: T, ): IOutput ``` **Parameters** - `values` `Value[]`: The values. - `config?` `T`: How the result is processed. **Returns** `IOutput`: The mean. **Throws** - `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported - `INVALID_VALUES_ARRAY`: if the provided values arg is not a valid array **Example** ```ts import { calculateMean } from 'jg-bignumber'; calculateMean([1, 2, 4]); // => 2.33 ``` #### `calculateMedian` *Function* Calculates the median of a list of values: the middle value, or the mean of the two middle values when the list has an even length. An empty list returns 0. ```ts calculateMedian>( values: Value[], config?: T, ): IOutput ``` **Parameters** - `values` `Value[]`: The values. - `config?` `T`: How the result is processed. **Returns** `IOutput`: The median. **Throws** - `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported - `INVALID_VALUES_ARRAY`: if the provided values arg is not a valid array **Example** ```ts import { calculateMedian } from 'jg-bignumber'; calculateMedian([5, 1, 3]); // => 3 calculateMedian([4, 1, 3, 2]); // => 2.5 ``` #### `calculateMin` *Function* Finds the smallest value in a list. An empty list returns 0. ```ts calculateMin>( values: Value[], config?: T, ): IOutput ``` **Parameters** - `values` `Value[]`: The values. - `config?` `T`: How the result is processed. **Returns** `IOutput`: The smallest value. **Throws** - `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported - `INVALID_VALUES_ARRAY`: if the provided values arg is not a valid array **Example** ```ts import { calculateMin } from 'jg-bignumber'; calculateMin([3, '1.5', 2]); // => 1.5 ``` #### `calculatePercentageChange` *Function* Calculates the percentage a value changed by: positive when it increased, negative when it decreased, and 0 when it didn't change. The largest decrease is -100%, returned whenever the new value is 0 or less. ```ts calculatePercentageChange>( oldValue: Value, newValue: Value, config?: T, ): IOutput ``` **Parameters** - `oldValue` `Value`: The value before the change. - `newValue` `Value`: The value after the change. - `config?` `T`: How the result is processed. **Returns** `IOutput`: The percentage change. **Throws** - `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported - `NEGATIVE_VALUE_NOT_ALLOWED`: if the oldValue is not positive **Example** ```ts import { calculatePercentageChange } from 'jg-bignumber'; calculatePercentageChange(100, 125); // => 25 calculatePercentageChange(100, 80); // => -20 calculatePercentageChange(100, 0); // => -100 ``` #### `calculatePercentageRepresentation` *Function* Calculates what percentage of a total a value represents. ```ts calculatePercentageRepresentation>( value: Value, total: Value, config?: T, ): IOutput ``` **Parameters** - `value` `Value`: The part. - `total` `Value`: The total. - `config?` `T`: How the result is processed. **Returns** `IOutput`: The percentage. **Throws** - `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported - `NEGATIVE_VALUE_NOT_ALLOWED`: if the value or total are not positive **Example** ```ts import { calculatePercentageRepresentation } from 'jg-bignumber'; calculatePercentageRepresentation(25, 200); // => 12.5 ``` #### `calculateSum` *Function* Adds up a list of values. An empty list sums to 0. ```ts calculateSum>( values: Value[], config?: T, ): IOutput ``` **Parameters** - `values` `Value[]`: The values. - `config?` `T`: How the result is processed. **Returns** `IOutput`: The sum. **Throws** - `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported - `INVALID_VALUES_ARRAY`: if the provided values arg is not a valid array **Example** ```ts import { calculateSum } from 'jg-bignumber'; calculateSum([0.1, '0.2', 0.3]); // => 0.6 calculateSum([0.1, 0.2], { type: 'string' }); // => '0.3' ``` #### `calculateWeightedEntry` *Function* Calculates the average entry price of a position bought in several trades, weighting each price by the amount bought. An empty list, or one whose amounts add up to 0, returns 0. ```ts calculateWeightedEntry>( trades: [Value, Value][], config?: T, ): IOutput ``` **Parameters** - `trades` `[Value, Value][]`: The trades, each as a `[price, amount]` tuple. - `config?` `T`: How the result is processed. **Returns** `IOutput`: The weighted average price. **Throws** - `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported - `INVALID_VALUES_ARRAY`: if the provided values arg is not a valid array **Example** ```ts import { calculateWeightedEntry } from 'jg-bignumber'; calculateWeightedEntry([[100, 2], [130, 1]]); // => 110 calculateWeightedEntry([]); // => 0 ``` #### `fromScaledInteger` *Function* Converts an integer count of an amount's smallest unit, such as microcents, back to the amount as an exact decimal string, with all `decimals` digits and no symbol, separators, or rounding. Pass it to `prettifyValue` to show it to people. ```ts fromScaledInteger(value: string | bigint, decimals: number): string ``` **Parameters** - `value` `string | bigint`: The amount in the smallest unit, as a bigint or an integer string, such as a PostgreSQL BIGINT. A string may have surrounding spaces and a sign. - `decimals` `number`: How many decimals the smallest unit has, such as 8 for microcents. **Returns** `string`: The amount. **Throws** - `INVALID_VALUE`: if the value isn't a bigint or an integer string - `INVALID_DECIMAL_PLACES`: if decimals isn't an integer from 0 to 100 **Example** ```ts import { fromScaledInteger } from 'jg-bignumber'; fromScaledInteger(123000000n, 8); // => '1.23000000' fromScaledInteger('-1', 8); // => '-0.00000001' fromScaledInteger(1250n, 0); // => '1250' ``` #### `getBigNumber` *Function* Creates a BigNumber from a number, a numeric string, or a BigNumber, which is returned as is. Only finite values are accepted: `NaN` and `±Infinity` are rejected. ```ts getBigNumber(value: Value): BigNumber ``` **Parameters** - `value` `Value`: The value. **Returns** `BigNumber`: The BigNumber. **Throws** - `INVALID_VALUE`: if the value isn't a finite number or BigNumber throws an error **Example** ```ts import { getBigNumber } from 'jg-bignumber'; getBigNumber('0.1').plus('0.2').toString(); // => '0.3' ``` #### `isBigNumber` *Function* Checks whether a value is a BigNumber instance, from this package or any other copy of bignumber.js. ```ts isBigNumber(value: unknown): value is BigNumber ``` **Parameters** - `value` `unknown`: The value. **Returns** `value is BigNumber`: Whether it's a BigNumber. **Example** ```ts import { getBigNumber, isBigNumber } from 'jg-bignumber'; isBigNumber(getBigNumber(1)); // => true isBigNumber(1); // => false ``` #### `isFloat` *Function* Checks whether a value is a finite number with a fractional part, in any of the supported types. ```ts isFloat(value: unknown): boolean ``` **Parameters** - `value` `unknown`: The value. **Returns** `boolean`: Whether it's a float. **Example** ```ts import { isFloat } from 'jg-bignumber'; isFloat(12.5); // => true isFloat('12'); // => false ``` #### `isInteger` *Function* Checks whether a value is a finite integer, in any of the supported types. ```ts isInteger(value: unknown): boolean ``` **Parameters** - `value` `unknown`: The value. **Returns** `boolean`: Whether it's an integer. **Example** ```ts import { isInteger } from 'jg-bignumber'; isInteger('12'); // => true isInteger(12.5); // => false ``` #### `isNumber` *Function* Checks whether a value is a finite number, numeric string, or BigNumber. ```ts isNumber(value: unknown): boolean ``` **Parameters** - `value` `unknown`: The value. **Returns** `boolean`: Whether it's a finite number. **Example** ```ts import { isNumber } from 'jg-bignumber'; isNumber('12.5'); // => true isNumber(Infinity); // => false isNumber('abc'); // => false ``` #### `prettifyValue` *Function* Processes a value and formats it for people, with group separators, a prefix, and a suffix. Trailing zeros are dropped, unless `format.minimumDecimalPlaces` keeps them. The sign goes before the prefix, as in `-$5`. ```ts prettifyValue( value: Value, config?: { processing?: Partial; format?: IPrettifyFormat }, ): string ``` **Parameters** - `value` `Value`: The value. - `config?` `{ processing?: Partial; format?: IPrettifyFormat }`: How the value is processed and formatted. Defaults to `{}`. **Returns** `string`: The formatted value. **Throws** - `INVALID_VALUE`: if the value isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported - `INVALID_BIGNUMBER_FORMAT`: if any of the format properties are invalid, or minimumDecimalPlaces - exceeds decimalPlaces **Example** ```ts import { prettifyValue } from 'jg-bignumber'; prettifyValue(1234567.891); // => '1,234,567.89' prettifyValue(1234.5, { format: { prefix: '$' } }); // => '$1,234.5' prettifyValue(1234.5, { format: { prefix: '$', minimumDecimalPlaces: 2 } }); // => '$1,234.50' prettifyValue(-5, { format: { prefix: '$' } }); // => '-$5' ``` #### `processValue` *Function* Rounds a value and returns it as the type the configuration asks for: a number by default, or a string or a BigNumber. ```ts processValue>( value: Value, configuration?: T, ): IOutput ``` **Parameters** - `value` `Value`: The value. - `configuration?` `T`: The decimal places, rounding mode, and output type. **Returns** `IOutput`: The processed value. **Throws** - `INVALID_VALUE`: if the value isn't a finite number or BigNumber throws an error - `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason - `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported - `INVALID_TYPE`: if the processing type is not supported **Example** ```ts import { processValue } from 'jg-bignumber'; processValue(512.155); // => 512.16 processValue(512.155, { decimalPlaces: 1, roundingMode: 'ROUND_FLOOR' }); // => 512.1 processValue('0.000001', { decimalPlaces: 6, type: 'string' }); // => '0.000001' ``` #### `toScaledInteger` *Function* Converts an amount, such as dollars, to an integer count of its smallest unit, such as microcents with 8 decimals, so it can be stored and added up exactly, as in a PostgreSQL BIGINT. It takes a number or a plain decimal string, with surrounding spaces and a leading `+` allowed, and never rounds: scientific notation, including a number JavaScript writes that way, such as `0.00000001`, hex, malformed values, and digits beyond `decimals` other than trailing zeros are rejected. Only the text is read, so `0.1 + 0.2`, which is `0.30000000000000004`, fails rather than becoming a slightly wrong amount. ```ts toScaledInteger(value: string | number, decimals: number): bigint ``` **Parameters** - `value` `string | number`: The amount. - `decimals` `number`: How many decimals the smallest unit has, such as 8 for microcents. **Returns** `bigint`: The amount in the smallest unit. **Throws** - `INVALID_VALUE`: if the value isn't a plain decimal, or has more decimals than `decimals` - `INVALID_DECIMAL_PLACES`: if decimals isn't an integer from 0 to 100 **Example** ```ts import { toScaledInteger } from 'jg-bignumber'; toScaledInteger('1.23', 8); // => 123000000n toScaledInteger(' +0.00000001 ', 8); // => 1n toScaledInteger(-12.5, 2); // => -1250n ``` ### Constants #### `BigNumber` *Constant* The BigNumber constructor every function uses: a clone of bignumber.js's, so the package never changes the global bignumber.js configuration other code relies on. It never switches to exponential notation, and with STRICT off an invalid value becomes NaN instead of throwing, which the package then reports as INVALID_VALUE. ```ts const BigNumber: typeof BigNumber ``` #### `ERRORS` *Constant* the codes of every error the package throws ```ts const ERRORS: { INVALID_VALUE: "INVALID_VALUE"; INVALID_TYPE: "INVALID_TYPE"; INVALID_ROUNDING_MODE: "INVALID_ROUNDING_MODE"; INVALID_DECIMAL_PLACES: "INVALID_DECIMAL_PLACES"; INVALID_BIGNUMBER_FORMAT: "INVALID_BIGNUMBER_FORMAT"; INVALID_VALUES_ARRAY: "INVALID_VALUES_ARRAY"; NEGATIVE_VALUE_NOT_ALLOWED: "NEGATIVE_VALUE_NOT_ALLOWED" } ``` ### Types #### `IBigNumber` *Type* A BigNumber instance, created with `getBigNumber(value)` or the exported `BigNumber` constructor. Every bignumber.js method is available on it. ```ts type IBigNumber = BigNumber; ``` #### `IBigNumberFormat` *Type* How prettifyValue formats a value, with every setting of bignumber.js's toFormat, defaults in parentheses: prefix (''), negativeSign ('-'), positiveSign (''), decimalSeparator ('.'), groupSeparator (','), groupSize (3), secondaryGroupSize (0), fractionGroupSeparator (' '), fractionGroupSize (0), and suffix (''). ```ts type IBigNumberFormat = Required; ``` #### `IBigNumberRoundingMode` *Type* the numeric rounding mode bignumber.js uses for each IBigNumberRoundingModeName ```ts type IBigNumberRoundingMode = BigNumber.RoundingMode; ``` #### `IBigNumberRoundingModeName` *Type* How a value is rounded when it has more decimals than allowed: - ROUND_UP: away from zero - ROUND_DOWN: towards zero - ROUND_CEIL: towards Infinity - ROUND_FLOOR: towards -Infinity - ROUND_HALF_UP (default): to the nearest neighbour, away from zero when equidistant - ROUND_HALF_DOWN: to the nearest neighbour, towards zero when equidistant - ROUND_HALF_EVEN: to the nearest neighbour, towards the even one when equidistant - ROUND_HALF_CEIL: to the nearest neighbour, towards Infinity when equidistant - ROUND_HALF_FLOOR: to the nearest neighbour, towards -Infinity when equidistant ```ts type IBigNumberRoundingModeName = 'ROUND_UP' | 'ROUND_DOWN' | 'ROUND_CEIL' | 'ROUND_FLOOR' | 'ROUND_HALF_UP' | 'ROUND_HALF_DOWN' | 'ROUND_HALF_EVEN' | 'ROUND_HALF_CEIL' | 'ROUND_HALF_FLOOR'; ``` #### `IBigNumberToType` *Type* the TypeScript type of a value converted to an IType ```ts type IBigNumberToType = T extends 'string' ? string : T extends 'number' ? number : T extends 'bignumber' ? IBigNumber : never; ``` #### `IBigNumberValue` *Type* the values a BigNumber can be created from: numbers, numeric strings, and BigNumbers ```ts type IBigNumberValue = BigNumber.Value; ``` #### `IConfig` *Type* how a value is processed ```ts type IConfig = { decimalPlaces: number; roundingMode: IBigNumberRoundingModeName; type: IType; }; ``` **Members** - `decimalPlaces: number` (property): the maximum number of decimals in the output. Defaults to 2 - `roundingMode: IBigNumberRoundingModeName` (property): how the value is rounded when it has more decimals. Defaults to 'ROUND_HALF_UP' - `type: IType` (property): the output's type. Defaults to 'number' #### `IOutput` *Type* the return type of a function that processes a value, picked from its configuration's type ```ts type IOutput = T extends { type: 'string'; } ? string : T extends { type: 'number'; } ? number : T extends { type: 'bignumber'; } ? IBigNumber : number; ``` #### `IPrettifyFormat` *Type* The format prettifyValue takes: any of bignumber.js's toFormat settings, and the fewest decimals it shows, padded with trailing zeros, such as 2 for '$1,234.50'. minimumDecimalPlaces defaults to 0, and can't exceed the processing's decimalPlaces. ```ts type IPrettifyFormat = Partial & { minimumDecimalPlaces?: number; }; ``` #### `IType` *Type* the type a processed value is returned as ```ts type IType = 'string' | 'number' | 'bignumber'; ``` --- # jg-bignumber recipes Patterns for apps that add up money and report how a metric changed, with exact decimal math and rounding where you choose. ## Summing money without rounding errors Use this for a cart, an invoice, or any total of prices. JavaScript numbers are binary fractions, so `0.1 + 0.2` is `0.30000000000000004`, and each addition can add an error. Keep amounts as decimal strings from the API or the database, do the arithmetic with jg-bignumber, and round where the invoice rounds: ```ts import { calculateSum, getBigNumber, prettifyValue, processValue, type IBigNumber, } from 'jg-bignumber'; // a line of an invoice, with its price as a decimal string, as an API or a PostgreSQL numeric // column sends it, so it never passes through a float type IInvoiceLine = { unitPrice: string; quantity: number }; type IInvoiceTotals = { subtotal: IBigNumber; tax: IBigNumber; total: IBigNumber }; // the reader's invoice, with its tax rate as a percentage, such as '8.25' declare const invoice: { lines: IInvoiceLine[]; taxRate: string }; // the cell that shows the invoice's total declare const totalCell: HTMLElement; const calculateInvoiceTotals = (lines: IInvoiceLine[], taxRate: string): IInvoiceTotals => { // each line's total stays exact, so nothing is rounded before the sum const lineTotals = lines.map(({ unitPrice, quantity }) => getBigNumber(unitPrice).times(quantity), ); // the subtotal and the tax are each rounded to cents, as the invoice prints them, so the // printed amounts add up to the printed total const subtotal = calculateSum(lineTotals, { type: 'bignumber' }); const tax = processValue(subtotal.times(taxRate).dividedBy(100), { type: 'bignumber' }); return { subtotal, tax, total: calculateSum([subtotal, tax], { type: 'bignumber' }) }; }; const { total } = calculateInvoiceTotals(invoice.lines, invoice.taxRate); // toFixed keeps the cents, such as '1234.50', where a number drops the trailing zero, and // minimumDecimalPlaces does the same for the formatted total, such as '$1,234.50' const totalForApi = total.toFixed(2); totalCell.textContent = prettifyValue(total, { format: { prefix: '$', minimumDecimalPlaces: 2 } }); ``` Every jg-bignumber calculation, and `processValue`, rounds its result, so call one where the invoice rounds, and do the steps in between with BigNumber methods, such as `times` and `dividedBy`, which don't. `{ type: 'bignumber' }` returns a BigNumber instead of a number, so the next step starts from the decimal value. Results round to 2 decimal places with `ROUND_HALF_UP`: pass `roundingMode: 'ROUND_HALF_EVEN'` where an accounting rule asks for banker's rounding, or `decimalPlaces: 0` for a currency without cents. A string that isn't a number, such as `''` from an empty form field, throws `INVALID_VALUE`, so check input with `isNumber` before it reaches the math. ## Storing money as integers Use this when amounts are stored or added up as integers, such as microcents in a PostgreSQL `BIGINT`, which sums exactly and sorts fast. Convert at the edges: an amount that reaches the API becomes an integer before it's stored, and an integer read back becomes a decimal string before it's shown. The conversions never round, so an amount with more decimals than the unit holds is rejected instead of losing a fraction of a cent: ```ts import { fromScaledInteger, prettifyValue, toScaledInteger } from 'jg-bignumber'; // microcents: a dollar is 100,000,000 of them const MICROCENTS = 8; // adds a credit to an account's balance, such as with a SQL query on a BIGINT column, which // node-postgres returns as a string declare function addToBalance(accountId: string, microcents: bigint): Promise; export const creditAccount = async (accountId: string, dollars: string): Promise => { // throws INVALID_VALUE for '1e-8', '0x10', or '0.123456789', so the request fails instead of // storing a different amount const balance = await addToBalance(accountId, toScaledInteger(dollars, MICROCENTS)); // the exact amount, such as '12.50000000', formatted as '$12.50' for people return prettifyValue(fromScaledInteger(balance, MICROCENTS), { format: { prefix: '$', minimumDecimalPlaces: 2 }, }); }; ``` Pass amounts as strings, as a form or an API sends them. A number works while JavaScript writes it as a plain decimal, but `0.00000001` is written `1e-8`, and `0.1 + 0.2` is `0.30000000000000004`, so both are rejected. `fromScaledInteger` keeps every decimal, so its result can go back through `toScaledInteger` unchanged, and `prettifyValue` rounds it only for display. ## A percentage change for a dashboard Use this for a metric tile that compares a period with the one before, such as this month's revenue against last month's. `calculatePercentageChange` returns a signed percentage, and `prettifyValue` formats it with a sign and a suffix: ```ts import { calculatePercentageChange, getBigNumber, prettifyValue, type IBigNumberValue, } from 'jg-bignumber'; // how a metric moved, which picks the tile's arrow and color type ITrend = 'up' | 'down' | 'flat'; type IChange = { label: string; trend: ITrend }; // the reader's revenue for the last two months, and the tile's change label declare const revenue: { previous: string; current: string }; declare const changeLabel: HTMLElement; // describes how a metric changed, or returns null when the previous value can't be the base const describeChange = (previous: IBigNumberValue, current: IBigNumberValue): IChange | null => { // a change from 0, or from a negative value, has no meaningful percentage, and // calculatePercentageChange throws NEGATIVE_VALUE_NOT_ALLOWED for one if (!getBigNumber(previous).isGreaterThan(0)) { return null; } // rounded to one decimal before the trend is picked, so the arrow always agrees with the label const change = calculatePercentageChange(previous, current, { decimalPlaces: 1 }); let trend: ITrend = 'flat'; if (change > 0) { trend = 'up'; } else if (change < 0) { trend = 'down'; } // only an increase gets a plus sign, so an unchanged metric reads 0%, not +0% const label = prettifyValue(change, { format: { positiveSign: trend === 'up' ? '+' : '', suffix: '%' }, }); return { label, trend }; }; const change = describeChange(revenue.previous, revenue.current); // revenue that starts from 0 has no percentage, so the tile says it's new changeLabel.textContent = change?.label ?? 'New'; changeLabel.dataset['trend'] = change?.trend ?? 'flat'; ``` A change too small to show, such as 0.04%, rounds to 0 and reads as flat, never as an arrow next to 0%. The largest decrease is -100%, which `calculatePercentageChange` returns whenever the current value is 0 or less. `prettifyValue` groups thousands, as in `+1,234.6%`, and drops trailing zeros, so 12% and 12.5% can sit side by side. --- # jg-fetch > A fetch wrapper that never throws: timeouts, retries that honour Retry-After, response size caps, and API envelopes. - **Version:** `0.3.0` - **Runtimes:** Universal - **Install:** `npm install jg-fetch` - **On npm:** [jg-fetch](https://www.npmjs.com/package/jg-fetch) - **Source:** [packages/jg-fetch](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-fetch) - **Recipes:** [jg-fetch recipes](https://jg-packages.jesusgraterol.dev/packages/jg-fetch/recipes/index.md) ## Usage `request` resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, and never throws: ```ts import { request } from 'jg-fetch'; const { data, error, meta } = await request('https://api.example.com/users/1'); if (error) { error.kind; // what failed, such as 'TIMEOUT', 'NETWORK_ERROR', or 'HTTP_ERROR' error.status; // 404, or null when no response arrived } else { data; // IUser } meta.attempts; // 1 ``` Read a jg-api-response envelope, validate its data, and send a fresh token on every attempt: ```ts import { request } from 'jg-fetch'; import { z } from 'zod'; const User = z.object({ id: z.number(), name: z.string() }); const { data, error } = await request('/users/me', { baseUrl: 'https://api.example.com', parseAs: 'envelope', // resolves to the envelope's data schema: User, // data is { id: number; name: string } headers: async () => ({ authorization: `Bearer ${await getIdToken()}` }), }); if (error?.kind === 'API_ERROR') { error.code; // the envelope error's code, such as 'USER_NOT_FOUND' error.message; // the envelope error's message error.data; // the envelope error's data, or null without any error.body; // the whole envelope } ``` When a credential can expire, such as an ID token, refresh it and repeat the attempt at once, without using up a retry: ```ts await request('/users/me', { baseUrl, headers: async () => ({ authorization: `Bearer ${await getIdToken()}` }), // returning true repeats the attempt; once per call unless maxCredentialRefreshes says more refreshCredentials: async ({ status, signal }) => { if (status !== 401) { return false; } await refreshIdToken(signal); return true; }, }); ``` Send JSON, query parameters, or any other body: ```ts await request('/orders', { baseUrl, method: 'POST', body: { items: [1, 2] }, // sent as JSON, with Content-Type: application/json query: { notify: true, tags: ['a', 'b'] }, // ?notify=true&tags=a&tags=b }); await request('/avatar', { baseUrl, method: 'PUT', body: formData }); // the browser sets the multipart type ``` A string, `Blob`, `ArrayBuffer` or view of one, `FormData`, `URLSearchParams`, or `ReadableStream` is sent as it is. Any other body, such as a `Map`, a `Date`, or a number, fails with `INVALID_BODY` before the request is sent, because `fetch` would turn it into text such as `[object Map]`. Read other kinds of responses with `parseAs`: `'json'` (the default), `'envelope'`, `'text'`, `'bytes'`, `'blob'`, `'response'` for the unread `Response`, or `'none'`. The data's type follows it. ### Timeouts and retries | Option | Default | What it does | | ------------------------ | ---------------------------------------- | ---------------------------------------------------------------------------------- | | `timeoutSeconds` | `15` | Limits the whole call, retries and waits included. | | `attemptTimeoutSeconds` | none | Limits each attempt; an attempt that runs out retries. | | `retryDelaysSeconds` | `[3, 5]` | The waits before each retry. `[]` turns retries off. | | `retryMethods` | `GET`, `HEAD`, `OPTIONS` | The methods that may retry. Add `POST` only for requests that are safe to repeat. | | `retryStatusCodes` | `408`, `429`, `500`, `502`, `503`, `504` | The statuses that may retry. `400`–`405`, `409`, `410`, `413`, and `422` never do. | | `nonRetryableErrorCodes` | none | API error codes that never retry. | | `maxRetryAfterSeconds` | `30` | The longest `Retry-After` a retry waits for. | | `maxResponseBytes` | 10 MiB | The largest body the buffered modes read. | Network errors, attempt timeouts, and retryable statuses retry. A `429` retries only with `Retry-After`, and every retry waits the longer of its delay and `Retry-After`. When `Retry-After` asks for more than `maxRetryAfterSeconds`, or more than the time left, the call stops and the error carries `retryAfterMs`. A stream body never retries. Pass `signal` to cancel: its abort reports `ABORTED`, or `TIMEOUT` when its reason is a `TimeoutError`, such as `AbortSignal.timeout(5000)`'s. ### Errors The error is a `FetchException`, an `Exception` from `jg-exception`: | Field | Holds | | ----------------------------------------- | -------------------------------------------------------------------------------------------- | | `kind` | What failed, one of `ERRORS` | | `code`, `message` | The envelope error's code and message for an `API_ERROR`, otherwise the kind and its message | | `status`, `headers`, `body` | The response's status, headers, and body, parsed as JSON when it is JSON | | `data` | The envelope error's data for an `API_ERROR`, `null` when it has none; otherwise undefined | | `retryAfterMs` | The wait `Retry-After` asked for | | `method`, `url`, `attempts`, `durationMs` | The request, without its query string or fragment | | `cause` | The original error | Messages never include the URL or the body, and `url` and `meta.url` drop the query string and fragment, where tokens such as `?key=` sit. ### Reading headers across origins A browser lets the page read only a few response headers from another origin. To read `Retry-After`, a `cf-ray`, or any custom header, the API must list them in `Access-Control-Expose-Headers`. Otherwise `headers.get()` returns `null`, and a `429` never retries, because its `Retry-After` can't be read. ## jg-fetch API reference Every export of `jg-fetch@0.3.0`, 26 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. - Functions: [`parseRetryAfterMs`](#api-parseRetryAfterMs), [`request`](#api-request) - Classes: [`FetchException`](#api-FetchException) - Constants: [`DEFAULT_MAX_RESPONSE_BYTES`](#api-DEFAULT_MAX_RESPONSE_BYTES), [`DEFAULT_MAX_RETRY_AFTER_SECONDS`](#api-DEFAULT_MAX_RETRY_AFTER_SECONDS), [`DEFAULT_RETRY_DELAYS_SECONDS`](#api-DEFAULT_RETRY_DELAYS_SECONDS), [`DEFAULT_RETRY_METHODS`](#api-DEFAULT_RETRY_METHODS), [`DEFAULT_RETRY_STATUS_CODES`](#api-DEFAULT_RETRY_STATUS_CODES), [`DEFAULT_TIMEOUT_SECONDS`](#api-DEFAULT_TIMEOUT_SECONDS), [`ERRORS`](#api-ERRORS), [`MAX_TIMEOUT_SECONDS`](#api-MAX_TIMEOUT_SECONDS), [`NON_RETRYABLE_STATUS_CODES`](#api-NON_RETRYABLE_STATUS_CODES) - Types: [`IAttemptContext`](#api-IAttemptContext), [`ICredentialFailure`](#api-ICredentialFailure), [`IFetchErrorKind`](#api-IFetchErrorKind), [`IFetchExceptionInit`](#api-IFetchExceptionInit), [`IHeadersInit`](#api-IHeadersInit), [`IHttpMethod`](#api-IHttpMethod), [`IParseAs`](#api-IParseAs), [`IQueryParams`](#api-IQueryParams), [`IQueryScalar`](#api-IQueryScalar), [`IRequestOptions`](#api-IRequestOptions), [`IRequestResult`](#api-IRequestResult), [`IResponseMeta`](#api-IResponseMeta), [`IRetryContext`](#api-IRetryContext), [`ISchema`](#api-ISchema) ### Functions #### `parseRetryAfterMs` *Function* Parses a Retry-After header into the milliseconds to wait. It accepts a delay in seconds, such as `120`, or an HTTP date, such as `Wed, 21 Oct 2015 07:28:00 GMT`, whose wait is counted from nowMs. A date in the past waits 0, and a wait longer than MAX_TIMEOUT_SECONDS is clamped to it. For a header with several values, such as a WebSocket handshake's, the first one counts. ```ts parseRetryAfterMs( value: string | readonly string[] | null | undefined, nowMs?: number, ): number | null ``` **Parameters** - `value` `string | readonly string[] | null | undefined`: The header's value, or its values. - `nowMs?` `number`: The current time, in milliseconds since the epoch. Defaults to Date.now(). Defaults to `...`. **Returns** `number | null`: The milliseconds to wait, or null when the value is missing or invalid. **Example** ```ts import { parseRetryAfterMs } from 'jg-fetch'; parseRetryAfterMs('120'); // 120000 parseRetryAfterMs('Wed, 21 Oct 2015 07:28:30 GMT', Date.parse('Wed, 21 Oct 2015 07:28:00 GMT')); // 30000 parseRetryAfterMs('soon'); // null ``` #### `request` *Function* Sends an HTTP request and resolves to its data or its error, never throwing. It retries network errors, attempt timeouts, and retryable statuses for the methods that may retry, waiting retryDelaysSeconds between attempts, or longer when the server sends Retry-After, all within timeoutSeconds. Each attempt sends a fresh Request. With parseAs 'envelope', it reads a jg-api-response envelope and resolves to its data, or to API_ERROR with the API's own code. With refreshCredentials, an attempt that fails with a response, such as a 401, can refresh the credentials and repeat at once, up to maxCredentialRefreshes times, without using up a retry. ```ts request( url: string | URL, options: IRequestOptions & { parseAs: "text" }, ): Promise> ``` **Parameters** - `url` `string | URL`: The URL, absolute or relative to baseUrl. - `options` `IRequestOptions & { parseAs: "text" }`: The method, query, headers, body, how to read the response, and the timeouts and retries. **Returns** `Promise>`: A promise that resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, where error is a FetchException. ```ts request( url: string | URL, options: IRequestOptions> & { parseAs: "bytes" }, ): Promise>> ``` **Parameters** - `url` `string | URL`: The URL, absolute or relative to baseUrl. - `options` `IRequestOptions> & { parseAs: "bytes" }`: The method, query, headers, body, how to read the response, and the timeouts and retries. **Returns** `Promise>>`: A promise that resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, where error is a FetchException. ```ts request( url: string | URL, options: IRequestOptions & { parseAs: "blob" }, ): Promise> ``` **Parameters** - `url` `string | URL`: The URL, absolute or relative to baseUrl. - `options` `IRequestOptions & { parseAs: "blob" }`: The method, query, headers, body, how to read the response, and the timeouts and retries. **Returns** `Promise>`: A promise that resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, where error is a FetchException. ```ts request( url: string | URL, options: IRequestOptions & { parseAs: "response" }, ): Promise> ``` **Parameters** - `url` `string | URL`: The URL, absolute or relative to baseUrl. - `options` `IRequestOptions & { parseAs: "response" }`: The method, query, headers, body, how to read the response, and the timeouts and retries. **Returns** `Promise>`: A promise that resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, where error is a FetchException. ```ts request( url: string | URL, options: IRequestOptions & { parseAs: "none" }, ): Promise> ``` **Parameters** - `url` `string | URL`: The URL, absolute or relative to baseUrl. - `options` `IRequestOptions & { parseAs: "none" }`: The method, query, headers, body, how to read the response, and the timeouts and retries. **Returns** `Promise>`: A promise that resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, where error is a FetchException. ```ts request( url: string | URL, options?: IRequestOptions, ): Promise> ``` **Parameters** - `url` `string | URL`: The URL, absolute or relative to baseUrl. - `options?` `IRequestOptions`: The method, query, headers, body, how to read the response, and the timeouts and retries. **Returns** `Promise>`: A promise that resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, where error is a FetchException. **Example 1** ```ts import { request } from 'jg-fetch'; const { data, error } = await request('/users/1', { baseUrl: 'https://api.example.com', parseAs: 'envelope', headers: async () => ({ authorization: `Bearer ${await getToken()}` }), }); if (error) { error.kind; // such as 'API_ERROR' or 'TIMEOUT' } ``` **Example 2** ```ts import { request } from 'jg-fetch'; const { data: html } = await request('https://example.com', { parseAs: 'text' }); // string | null ``` ### Classes #### `FetchException` *Class* The error request returns when a call fails, never throws. kind says what failed, one of the ERRORS codes. For an API error, code, message, and data are the envelope error's; otherwise code is the kind. Its message never includes the URL or the body. **Members** - `code: string` (property) - `data: IJsonValue | undefined` (property) - `severity: "expected" | "critical"` (property) - `shouldLog: boolean` (property) - `toJSON(): IExceptionJson` (method): Converts the exception to a plain object, which `JSON.stringify` calls. - `toString(): string` (method): Describes the exception in one line, with its code. - `kind: "INVALID_URL" | "INVALID_OPTIONS" | "INVALID_HEADERS" | "INVALID_BODY" | "NETWORK_ERROR" | "TIMEOUT" | "ABORTED" | "HTTP_ERROR" | "API_ERROR" | "PARSE_ERROR" | "ENVELOPE_INVALID" | "RESPONSE_INVALID" | "RESPONSE_TOO_LARGE" | "CREDENTIALS_REFRESH_FAILED"` (property): what failed, one of the ERRORS codes - `status: number | null` (property): the HTTP status, or null when no response arrived - `headers: Headers | null` (property): the response's headers, or null when no response arrived - `body: unknown` (property): the response body: parsed JSON when it is JSON, otherwise its text - `retryAfterMs: number | null` (property): the wait the server asked for with Retry-After, in milliseconds, or null without one - `method: IHttpMethod` (property): the request's method - `url: string` (property): the request's URL, without its query string or fragment - `attempts: number` (property): the number of attempts made - `durationMs: number` (property): the milliseconds the whole call took - `new FetchException(kind: "INVALID_URL" | "INVALID_OPTIONS" | "INVALID_HEADERS" | "INVALID_BODY" | "NETWORK_ERROR" | "TIMEOUT" | "ABORTED" | "HTTP_ERROR" | "API_ERROR" | "PARSE_ERROR" | "ENVELOPE_INVALID" | "RESPONSE_INVALID" | "RESPONSE_TOO_LARGE" | "CREDENTIALS_REFRESH_FAILED", init: IFetchExceptionInit)` (constructor) **Example** ```ts import { request } from 'jg-fetch'; const { data, error } = await request('/users/1', { baseUrl, parseAs: 'envelope' }); if (error) { error.kind; // 'API_ERROR' error.code; // 'USER_NOT_FOUND', the envelope error's code error.data; // { id: 1 }, the envelope error's data error.status; // 404 } ``` ### Constants #### `DEFAULT_MAX_RESPONSE_BYTES` *Constant* the largest response body the buffered parseAs modes read, in bytes: 10 MiB ```ts const DEFAULT_MAX_RESPONSE_BYTES: 10485760 ``` #### `DEFAULT_MAX_RETRY_AFTER_SECONDS` *Constant* the longest Retry-After wait a retry accepts, in seconds, unless maxRetryAfterSeconds says otherwise ```ts const DEFAULT_MAX_RETRY_AFTER_SECONDS: 30 ``` #### `DEFAULT_RETRY_DELAYS_SECONDS` *Constant* the waits before each retry, in seconds: two retries, after 3 and then 5 seconds ```ts const DEFAULT_RETRY_DELAYS_SECONDS: readonly number[] ``` #### `DEFAULT_RETRY_METHODS` *Constant* the methods that retry by default, which are safe to repeat ```ts const DEFAULT_RETRY_METHODS: readonly IHttpMethod[] ``` #### `DEFAULT_RETRY_STATUS_CODES` *Constant* the statuses that retry by default, because a later attempt can succeed ```ts const DEFAULT_RETRY_STATUS_CODES: readonly number[] ``` #### `DEFAULT_TIMEOUT_SECONDS` *Constant* the seconds a whole call may take, retries and waits included, unless timeoutSeconds says otherwise ```ts const DEFAULT_TIMEOUT_SECONDS: 15 ``` #### `ERRORS` *Constant* the codes for every error request reports, which FetchException's kind holds ```ts const ERRORS: { INVALID_URL: "INVALID_URL"; INVALID_OPTIONS: "INVALID_OPTIONS"; INVALID_HEADERS: "INVALID_HEADERS"; INVALID_BODY: "INVALID_BODY"; NETWORK_ERROR: "NETWORK_ERROR"; TIMEOUT: "TIMEOUT"; ABORTED: "ABORTED"; HTTP_ERROR: "HTTP_ERROR"; API_ERROR: "API_ERROR"; PARSE_ERROR: "PARSE_ERROR"; ENVELOPE_INVALID: "ENVELOPE_INVALID"; RESPONSE_INVALID: "RESPONSE_INVALID"; RESPONSE_TOO_LARGE: "RESPONSE_TOO_LARGE"; CREDENTIALS_REFRESH_FAILED: "CREDENTIALS_REFRESH_FAILED" } ``` #### `MAX_TIMEOUT_SECONDS` *Constant* the longest timeout a timer can hold, in seconds, which is 2^31 - 1 milliseconds ```ts const MAX_TIMEOUT_SECONDS: 2147483 ``` #### `NON_RETRYABLE_STATUS_CODES` *Constant* the statuses that never retry, even when retryStatusCodes lists them ```ts const NON_RETRYABLE_STATUS_CODES: readonly number[] ``` ### Types #### `IAttemptContext` *Type* what a headers function receives on each attempt ```ts type IAttemptContext = { attempt: number; signal: AbortSignal; }; ``` **Members** - `attempt: number` (property): the attempt's number, starting at 1 - `signal: AbortSignal` (property): aborts when the attempt is cancelled, for work such as refreshing a token #### `ICredentialFailure` *Type* what refreshCredentials receives when an attempt fails with a response ```ts type ICredentialFailure = { status: number; code: string; refreshes: number; signal: AbortSignal; }; ``` **Members** - `status: number` (property): the response's HTTP status, such as 401 - `code: string` (property): the error's code: the envelope error's own code, or HTTP_ERROR - `refreshes: number` (property): the refreshes this call already made - `signal: AbortSignal` (property): aborts when the call is cancelled, by its timeout or your signal, for the refresh's own work #### `IFetchErrorKind` *Type* the kind of a failed request, one of the ERRORS codes ```ts type IFetchErrorKind = keyof typeof ERRORS; ``` #### `IFetchExceptionInit` *Type* what a FetchException carries besides its kind ```ts type IFetchExceptionInit = { message?: string; code?: string; data?: IJsonValue; status?: number | null; headers?: Headers | null; body?: unknown; retryAfterMs?: number | null; method: IHttpMethod; url: string; attempts: number; durationMs: number; cause?: unknown; }; ``` **Members** - `message: string` (property): the message. Defaults to the kind's message - `code: string` (property): the code. Defaults to the kind; an API error's own code takes its place - `data: IJsonValue` (property): the envelope error's data, for an API error - `status: number | null` (property): the HTTP status, or null when no response arrived - `headers: Headers | null` (property): the response's headers, or null when no response arrived - `body: unknown` (property): the response body: parsed JSON when it is JSON, otherwise its text - `retryAfterMs: number | null` (property): the wait the server asked for with Retry-After, in milliseconds - `method: IHttpMethod` (property): the request's method - `url: string` (property): the request's URL, without its query string or fragment - `attempts: number` (property): the number of attempts made - `durationMs: number` (property): the milliseconds the whole call took - `cause: unknown` (property): the original error, such as a TypeError from fetch or a schema's error #### `IHeadersInit` *Type* the headers the Headers constructor accepts: Headers, [name, value] pairs, or a record ```ts type IHeadersInit = ConstructorParameters[0]; ``` #### `IHttpMethod` *Type* the HTTP methods request sends ```ts type IHttpMethod = 'GET' | 'HEAD' | 'OPTIONS' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; ``` #### `IParseAs` *Type* how request reads a successful response: - envelope: a jg-api-response envelope, resolving to its data - json: parsed JSON - text: a string - bytes: a Uint8Array - blob: a Blob - response: the Response itself, unread - none: nothing, the body is discarded ```ts type IParseAs = 'envelope' | 'json' | 'text' | 'bytes' | 'blob' | 'response' | 'none'; ``` #### `IQueryParams` *Type* the query parameters appended to the URL. An array repeats the key once per item ```ts type IQueryParams = Record; ``` #### `IQueryScalar` *Type* a query parameter's value. null, undefined, and empty strings are left out ```ts type IQueryScalar = string | number | boolean | bigint | null | undefined; ``` #### `IRequestOptions` *Type* the options request accepts ```ts type IRequestOptions = { method?: IHttpMethod; baseUrl?: string | URL; query?: IQueryParams; headers?: IHeadersInit | ((context: IAttemptContext) => IHeadersInit | Promise); body?: RequestInit['body'] | Record | readonly unknown[]; parseAs?: IParseAs; schema?: ISchema; signal?: AbortSignal; timeoutSeconds?: number; attemptTimeoutSeconds?: number; retryDelaysSeconds?: readonly number[]; retryMethods?: readonly IHttpMethod[]; retryStatusCodes?: readonly number[]; nonRetryableErrorCodes?: readonly string[]; maxRetryAfterSeconds?: number; maxResponseBytes?: number; init?: Omit; onRetry?: (context: IRetryContext) => void; refreshCredentials?: (failure: ICredentialFailure) => boolean | Promise; maxCredentialRefreshes?: number; }; ``` **Members** - `method: IHttpMethod` (property): the HTTP method. Defaults to 'GET' - `baseUrl: string | URL` (property): the URL a relative url resolves against. A URL that resolves to another origin fails with INVALID_URL - `query: IQueryParams` (property): the query parameters appended to the URL - `headers: IHeadersInit | ((context: IAttemptContext) => IHeadersInit | Promise)` (property): the headers, or a function that builds them for each attempt, such as to add a fresh token - `body: RequestInit["body"] | Record | readonly unknown[]` (property): the body. Plain objects and arrays are sent as JSON, with Content-Type: application/json unless the headers set one. A string, Blob, ArrayBuffer or view of one, FormData, URLSearchParams, or ReadableStream is sent as it is. Any other value, such as a Map, a Date, or a number, is an INVALID_BODY error, sent nowhere - `parseAs: IParseAs` (property): how a successful response is read. Defaults to 'json' - `schema: ISchema` (property): validates the parsed data, or the envelope's data, such as a Zod schema - `signal: AbortSignal` (property): aborts the call. An abort whose reason is a TimeoutError, such as AbortSignal.timeout's, reports TIMEOUT, and any other ABORTED - `timeoutSeconds: number` (property): the seconds the whole call may take, retries and waits included. Defaults to DEFAULT_TIMEOUT_SECONDS - `attemptTimeoutSeconds: number` (property): the seconds each attempt may take. An attempt that runs out retries. No limit by default - `retryDelaysSeconds: readonly number[]` (property): the waits before each retry, in seconds; one retry per item. Defaults to DEFAULT_RETRY_DELAYS_SECONDS, and [] turns retries off - `retryMethods: readonly IHttpMethod[]` (property): the methods that may retry. Defaults to DEFAULT_RETRY_METHODS - `retryStatusCodes: readonly number[]` (property): the statuses that may retry. Defaults to DEFAULT_RETRY_STATUS_CODES, and NON_RETRYABLE_STATUS_CODES never retry - `nonRetryableErrorCodes: readonly string[]` (property): the API error codes, from an envelope's error, that never retry - `maxRetryAfterSeconds: number` (property): the longest Retry-After wait a retry accepts, in seconds. Defaults to DEFAULT_MAX_RETRY_AFTER_SECONDS - `maxResponseBytes: number` (property): the largest body the buffered parseAs modes read, counted as it streams. Defaults to DEFAULT_MAX_RESPONSE_BYTES - `init: Omit` (property): the other fetch options, such as credentials, cache, or redirect - `onRetry: (context: IRetryContext) => void` (property): called before each retry. Whatever it throws is ignored, so request still never throws - `refreshCredentials: (failure: ICredentialFailure) => boolean | Promise` (property): called when an attempt fails with a response, such as a 401, to refresh the credentials the headers function reads. Returning true repeats the attempt at once, for any method; false leaves the failure to the retry rules. Return true only for a credential failure, because the server didn't process the request then. A function that throws ends the call with CREDENTIALS_REFRESH_FAILED. Never called for a streamed body, which can be sent once - `maxCredentialRefreshes: number` (property): the most refreshes a call makes, such as 2 for two kinds of credential. Defaults to 1 #### `IRequestResult` *Type* what request resolves to: the data and no error, or no data and the error ```ts type IRequestResult = { data: T; error: null; meta: IResponseMeta; } | { data: null; error: FetchException; meta: IResponseMeta; }; ``` #### `IResponseMeta` *Type* what request knows about the last response, successful or not ```ts type IResponseMeta = { status: number | null; headers: Headers | null; url: string; attempts: number; durationMs: number; }; ``` **Members** - `status: number | null` (property): the HTTP status, or null when no response arrived - `headers: Headers | null` (property): the response's headers, or null when no response arrived - `url: string` (property): the URL, without its query string or fragment, which can hold tokens - `attempts: number` (property): the number of attempts made - `durationMs: number` (property): the milliseconds the whole call took, retries and waits included #### `IRetryContext` *Type* what onRetry receives before each retry ```ts type IRetryContext = { attempt: number; error: FetchException; delayMs: number; }; ``` **Members** - `attempt: number` (property): the attempt that failed, starting at 1 - `error: FetchException` (property): why it failed - `delayMs: number` (property): the milliseconds request waits before the next attempt #### `ISchema` *Type* validates parsed data and returns it typed, which a Zod schema does ```ts type ISchema = { parse(value: unknown): T; }; ``` **Members** - `parse(value: unknown): T` (method) --- # jg-fetch recipes Patterns for apps that call HTTP APIs with jg-fetch: a typed client for an envelope API, auth headers that stay fresh, and retries that never repeat an unsafe request. ## A typed client for an envelope API When your app calls an API that answers with jg-api-response envelopes, keep the base URL, the envelope parsing, and the auth header in one function, and give each endpoint its own schema. Every call then resolves to data its schema checked, or to a `FetchException` whose `code` is the API's own. ```ts import { request, type IRequestOptions, type IRequestResult, type ISchema } from 'jg-fetch'; // a Zod schema, or any validator with parse declare const UserSchema: { parse(value: unknown): IUser }; // what an endpoint may set; the client sets the rest type ICallOptions = Omit, 'baseUrl' | 'parseAs' | 'schema' | 'headers'>; // every call reads an envelope, checks its data with the endpoint's schema, and sends a token // read before each attempt const callApi = ( path: string, schema: ISchema, options: ICallOptions = {}, ): Promise> => request(path, { ...options, baseUrl, parseAs: 'envelope', schema, headers: async () => ({ authorization: `Bearer ${await getToken()}` }), }); export const getUser = (userId: number): Promise> => callApi(`/users/${userId}`, UserSchema); export const renameUser = (userId: number, name: string): Promise> => callApi(`/users/${userId}`, UserSchema, { method: 'PATCH', body: { name } }); ``` The schema runs on the envelope's `data`. When the API's answer doesn't match it, the call fails with `RESPONSE_INVALID` and the schema's error as `cause`, so a changed API shows up as an error here instead of as data of the wrong shape further on. Handle failures where you know what each one means. A `FetchException`'s `kind` says what failed, one of `ERRORS`. For an `API_ERROR`, its `code` is the one in the envelope, such as `USER_NOT_FOUND`, which `hasErrorCode` checks; for every other kind, the code is the kind itself. ```ts import { hasErrorCode } from 'jg-exception'; import { ERRORS, type IRequestResult } from 'jg-fetch'; // the client's getUser, from the block above declare function getUser(userId: number): Promise>; // sends an error to your error tracker declare function trackError(error: unknown): void; // what the profile page shows type IProfileState = | { status: 'ready'; user: IUser } | { status: 'not-found' } | { status: 'unreachable' } | { status: 'failed' }; const loadProfile = async (userId: number): Promise => { const { data, error } = await getUser(userId); if (error === null) { return { status: 'ready', user: data }; } // the code the API sent in the envelope if (hasErrorCode(error, 'USER_NOT_FOUND')) { return { status: 'not-found' }; } // the API couldn't be reached or didn't answer in time, even after the retries, so trying // again later may work if (error.kind === ERRORS.NETWORK_ERROR || error.kind === ERRORS.TIMEOUT) { return { status: 'unreachable' }; } // anything else is a bug on one side, such as RESPONSE_INVALID when the API's data stopped // matching the schema trackError(error); return { status: 'failed' }; }; ``` When the caller has to throw instead, such as on a server, pass the `FetchException` as the `cause` of your own error: `new Exception("Couldn't load the profile.", { code: 'PROFILE_UNAVAILABLE', cause: error })`. Your message says what failed at your step, and the cause keeps the request's kind, status, and attempts. A `FetchException`'s message never includes the URL or the body. ## A fresh auth header on every request Pass `headers` as a function when a header holds a token that expires. `request` calls it before every attempt, retries included, so each attempt sends the token as it is when that attempt starts. ```ts import { request } from 'jg-fetch'; // the token in an object is read once, before the call: a retry seconds later sends the same // token, even if it expired in between await request('/reports', { baseUrl, headers: { authorization: `Bearer ${await getToken()}` }, }); // a function runs before every attempt await request('/reports', { baseUrl, headers: async () => ({ authorization: `Bearer ${await getToken()}` }), }); ``` The function receives an `IAttemptContext`: the attempt's number, from 1, and a `signal` that aborts when the attempt is cancelled, by its own timeout, the call's, or your `signal`. `request` stops waiting for the function when that signal aborts, so a token call that hangs ends the attempt like a slow response: as an attempt timeout, which can retry, when `attemptTimeoutSeconds` runs out, or as the call's `TIMEOUT` when `timeoutSeconds` does. A function that throws or rejects ends the call with `INVALID_HEADERS` and its error as `cause`, without a retry, which usually means the user has to sign in again. A token can also stop working before it expires, such as when it's revoked. `request` never retries a `401` on its own, because the same token would fail again. Pass `refreshCredentials`, which `request` calls when an attempt fails with a response: refresh the token and return `true`, and the attempt is repeated at once, with `headers` called again. Return `false` for any other failure, which the retry rules then handle: ```ts import { request, type ICredentialFailure } from 'jg-fetch'; // gets a new access token from the auth server, which getToken returns from then on; the signal // aborts when the call is cancelled declare function refreshToken(signal: AbortSignal): Promise; // refreshes the token only for a credential failure: the server didn't process the request then, // so repeating it is safe, even for a POST const refreshOnUnauthorized = async ({ status, signal }: ICredentialFailure): Promise => { if (status !== 401) { return false; } await refreshToken(signal); return true; }; const { data, error } = await request('/reports', { baseUrl, method: 'POST', body: { range: 'last-30-days' }, headers: async () => ({ authorization: `Bearer ${await getToken()}` }), refreshCredentials: refreshOnUnauthorized, }); ``` A call refreshes once by default, so a second `401` means the session is over: send the user to sign in. Set `maxCredentialRefreshes` when more than one credential can expire, such as an App Check token and an ID token, and tell them apart by `code`, the envelope error's code with `parseAs: 'envelope'`. A refresh that throws ends the call with `CREDENTIALS_REFRESH_FAILED`, its error as `cause`, and the response's status. A refresh doesn't use up a retry, and the call's `timeoutSeconds` covers it, so a refresh that hangs ends as `TIMEOUT`. A streamed body is never repeated, so its failure goes straight to the retry rules. When several calls can get a `401` at once, have `refreshToken` share one refresh between them, and don't let one call's signal cancel a refresh the others are waiting on. ## Retrying only what's safe to retry A retry sends the request again, so it's safe only when a second copy can't do something twice. `request` retries `GET`, `HEAD`, and `OPTIONS` by default, after network errors, attempt timeouts, and the statuses in `DEFAULT_RETRY_STATUS_CODES`. Tune what a read retries on: ```ts import { request } from 'jg-fetch'; // a GET retries network errors, attempts that time out, and 408, 429 with Retry-After, 500, // 502, 503, and 504, waiting 3 and then 5 seconds, within timeoutSeconds const users = await request('/users', { baseUrl, parseAs: 'envelope', // a hung attempt ends after 4 seconds and retries, instead of using up the call's 15 attemptTimeoutSeconds: 4, // the API answers 503 with this code during maintenance, which lasts longer than any retry waits nonRetryableErrorCodes: ['MAINTENANCE'], // a call that succeeds on a retry still leaves a trace of the failures before it onRetry: ({ attempt, error, delayMs }) => { console.warn(`Attempt ${attempt} failed with ${error.code}; retrying in ${delayMs} ms.`); }, }); ``` Some failures never retry, whatever the options say: the statuses in `NON_RETRYABLE_STATUS_CODES` (400 to 405, 409, 410, 413, and 422), an abort, the whole call's timeout, and a stream body, which can only be sent once. When a `Retry-After` asks for longer than `maxRetryAfterSeconds`, or than the time left, the call stops, and the error's `retryAfterMs` says how long the server asked you to wait. A `POST` doesn't retry by default. When a network error cuts the connection, there's no telling whether the server created the order, and a retry could create a second one. Make it safe with an idempotency key, created once per order and sent on every attempt, then opt the request in: ```ts import { request } from 'jg-fetch'; // what the checkout form sends declare const order: { items: { productId: number; quantity: number }[] }; // one key per order, created before the first attempt, so every retry sends the same one const idempotencyKey = crypto.randomUUID(); const { data, error } = await request<{ orderId: number }>('/orders', { baseUrl, method: 'POST', body: order, parseAs: 'envelope', headers: async () => ({ authorization: `Bearer ${await getToken()}`, 'idempotency-key': idempotencyKey, }), // safe only because the API stores the key and answers a repeat with the first result retryMethods: ['POST'], }); ``` The key protects you only if the API stores it and answers a repeated key with the first result instead of acting again; without that, leave `POST` out of `retryMethods`. Create the key when the checkout starts rather than on each submit, and a second click on the button is safe too. `PUT`, `PATCH`, and `DELETE` are also off the default list, because only your API knows whether repeating them is harmless. --- # jg-storage > Typed key-value stores over localStorage, sessionStorage, IndexedDB, and memory, for pages and workers alike. - **Version:** `1.1.1` - **Runtimes:** Browser - **Install:** `npm install jg-storage` - **On npm:** [jg-storage](https://www.npmjs.com/package/jg-storage) - **Source:** [packages/jg-storage](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-storage) - **Recipes:** [jg-storage recipes](https://jg-packages.jesusgraterol.dev/packages/jg-storage/recipes/index.md) ## Usage Keep settings in localStorage, with a root record and records by ID: ```ts import { createLocalStore } from 'jg-storage'; const settings = createLocalStore<{ theme: 'light' | 'dark' }>('settings'); settings.set(undefined, { theme: 'dark' }); // stored under the key 'jg-storage:settings' settings.get(); // { theme: 'dark' } settings.set('draft', { theme: 'light' }); // stored under the key 'jg-storage:settings:draft' settings.del('draft'); ``` Store ids hold only letters, digits, underscores, and hyphens; any other id throws `INVALID_STORE_ID`. Record ids become strings, so `1` and `'1'` address the same record. Keep records in IndexedDB, from a page or a service worker. They live in the `records` object store of the `jg-storage` database: ```ts import { createIndexedDbStore } from 'jg-storage'; const devices = createIndexedDbStore<{ token: string }>('devices'); await devices.set('phone', { token: 'abc' }); await devices.get('phone'); // { token: 'abc' } ``` Validate what you read, and fall back to memory where storage is blocked: ```ts import { createSessionStore, ERRORS } from 'jg-storage'; import { hasErrorCode } from 'jg-exception'; import { z } from 'zod'; const Step = z.number().int().min(1); const wizard = createSessionStore('wizard-step', { fallback: 'memory', // without it, set and del throw STORE_UNAVAILABLE schema: Step, // validates every stored value get reads; its errors propagate unchanged }); wizard.set(undefined, 2); wizard.get(); // 2 try { createSessionStore('other').set(undefined, 1); } catch (error) { if (hasErrorCode(error, ERRORS.STORE_UNAVAILABLE)) { // sessionStorage is unavailable in this context } } ``` Every store probes its mechanism on its first call, never when it's created, so creating stores at module scope is safe during server-side rendering. ## jg-storage API reference Every export of `jg-storage@1.1.1`, 11 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. - Functions: [`createIndexedDbStore`](#api-createIndexedDbStore), [`createLocalStore`](#api-createLocalStore), [`createMemoryStore`](#api-createMemoryStore), [`createSessionStore`](#api-createSessionStore) - Constants: [`ERRORS`](#api-ERRORS) - Types: [`IAsyncStore`](#api-IAsyncStore), [`IRecordId`](#api-IRecordId), [`ISchema`](#api-ISchema), [`IStoreMechanism`](#api-IStoreMechanism), [`IStoreOptions`](#api-IStoreOptions), [`ISyncStore`](#api-ISyncStore) ### Functions #### `createIndexedDbStore` *Function* Creates a store over IndexedDB, which works in pages and workers alike. Records live in the `records` object store of the `jg-storage` database, under `[id]` for the root record and `[id, recordId]` for the others. Values are stored with the structured clone algorithm, so dates, maps, and binary data keep their types. IndexedDB is read and probed on the first call, never when the store is created. ```ts createIndexedDbStore(id: string, options?: IStoreOptions): IAsyncStore ``` **Parameters** - `id` `string`: The store's identifier: letters, digits, underscores, and hyphens. - `options?` `IStoreOptions`: fallback: 'memory' keeps records in memory when IndexedDB is unavailable, and schema validates every stored value get reads. Defaults to `{}`. **Returns** `IAsyncStore`: The store. **Throws** - `INVALID_STORE_ID`: The store id must hold only letters, digits, underscores, and hyphens. - `STORE_UNAVAILABLE`: set or del ran while IndexedDB is unavailable, without a fallback. - Errors from IndexedDB itself, such as a value it can't clone, propagate. **Example** ```ts import { createIndexedDbStore } from 'jg-storage'; const devices = createIndexedDbStore<{ token: string }>('devices'); await devices.set('phone', { token: 'abc' }); // stored under the key ['devices', 'phone'] await devices.get('phone'); // { token: 'abc' } await devices.del('phone'); ``` #### `createLocalStore` *Function* Creates a store over localStorage, whose records outlive the page. Values are stored as JSON, under `jg-storage:` for the root record and `jg-storage::` for the others. localStorage is read and probed on the first call, never when the store is created. ```ts createLocalStore(id: string, options?: IStoreOptions): ISyncStore ``` **Parameters** - `id` `string`: The store's identifier: letters, digits, underscores, and hyphens. - `options?` `IStoreOptions`: fallback: 'memory' keeps records in memory when localStorage is unavailable, and schema validates every stored value get reads. Defaults to `{}`. **Returns** `ISyncStore`: The store. **Throws** - `INVALID_STORE_ID`: The store id must hold only letters, digits, underscores, and hyphens. - `FAILED_TO_STRINGIFY_JSON`: set received a value JSON can't represent. - `FAILED_TO_PARSE_JSON`: get read a value that isn't JSON. - `STORE_UNAVAILABLE`: set or del ran while localStorage is unavailable, without a fallback. - Errors from localStorage itself, such as a full quota, propagate. **Example** ```ts import { createLocalStore } from 'jg-storage'; const settings = createLocalStore<{ theme: 'light' | 'dark' }>('settings'); settings.set(undefined, { theme: 'dark' }); // stored under the key 'jg-storage:settings' settings.get(); // { theme: 'dark' } settings.set('draft', { theme: 'light' }); // stored under the key 'jg-storage:settings:draft' ``` #### `createMemoryStore` *Function* Creates a store that keeps its records in memory for as long as the store exists. Values are kept as they are, without serializing them, and the store is always available. ```ts createMemoryStore(id: string, options?: IStoreOptions): ISyncStore ``` **Parameters** - `id` `string`: The store's identifier: letters, digits, underscores, and hyphens. - `options?` `IStoreOptions`: schema validates every stored value get reads. fallback has no effect, because memory is always available. Defaults to `{}`. **Returns** `ISyncStore`: The store. **Throws** - `INVALID_STORE_ID`: The store id must hold only letters, digits, underscores, and hyphens. **Example** ```ts import { createMemoryStore } from 'jg-storage'; const drafts = createMemoryStore<{ text: string }>('drafts'); drafts.set('post-1', { text: 'Hello' }); drafts.get('post-1'); // { text: 'Hello' } drafts.del('post-1'); drafts.get('post-1'); // undefined ``` #### `createSessionStore` *Function* Creates a store over sessionStorage, whose records last as long as the browser tab. Values are stored as JSON, under `jg-storage:` for the root record and `jg-storage::` for the others. sessionStorage is read and probed on the first call, never when the store is created. ```ts createSessionStore(id: string, options?: IStoreOptions): ISyncStore ``` **Parameters** - `id` `string`: The store's identifier: letters, digits, underscores, and hyphens. - `options?` `IStoreOptions`: fallback: 'memory' keeps records in memory when sessionStorage is unavailable, and schema validates every stored value get reads. Defaults to `{}`. **Returns** `ISyncStore`: The store. **Throws** - `INVALID_STORE_ID`: The store id must hold only letters, digits, underscores, and hyphens. - `FAILED_TO_STRINGIFY_JSON`: set received a value JSON can't represent. - `FAILED_TO_PARSE_JSON`: get read a value that isn't JSON. - `STORE_UNAVAILABLE`: set or del ran while sessionStorage is unavailable, without a fallback. - Errors from sessionStorage itself, such as a full quota, propagate. **Example** ```ts import { createSessionStore } from 'jg-storage'; const wizard = createSessionStore('wizard-step', { fallback: 'memory' }); wizard.set(undefined, 2); wizard.get(); // 2 wizard.del(); ``` ### Constants #### `ERRORS` *Constant* the codes for every error thrown by the package ```ts const ERRORS: { FAILED_TO_STRINGIFY_JSON: "FAILED_TO_STRINGIFY_JSON"; FAILED_TO_PARSE_JSON: "FAILED_TO_PARSE_JSON"; STORE_UNAVAILABLE: "STORE_UNAVAILABLE"; INVALID_STORE_ID: "INVALID_STORE_ID" } ``` ### Types #### `IAsyncStore` *Type* a store whose methods return promises: IndexedDB ```ts type IAsyncStore = { readonly id: string; readonly mechanism: IStoreMechanism; isCompatible(): Promise; get(recordId?: IRecordId): Promise; set(recordId: IRecordId, value: T): Promise; del(recordId?: IRecordId): Promise; }; ``` **Members** - `id: string` (property): the identifier every record key starts with - `mechanism: IStoreMechanism` (property): the mechanism the store was created for. isCompatible tells whether it's available - `isCompatible(): Promise` (method): whether the mechanism accepts writes, probed on the first call and remembered - `get(recordId?: IRecordId): Promise` (method): reads a record, or the root record when recordId is undefined - `set(recordId: IRecordId, value: T): Promise` (method): writes a record, or the root record when recordId is undefined - `del(recordId?: IRecordId): Promise` (method): deletes a record, or the root record when recordId is undefined #### `IRecordId` *Type* identifies a record in a store. undefined addresses the store's root record. A number becomes a string, so 1 and '1' address the same record in every mechanism ```ts type IRecordId = undefined | string | number; ``` #### `ISchema` *Type* validates data and returns it typed, which a Zod schema does ```ts type ISchema = { parse(value: unknown): T; }; ``` **Members** - `parse(value: unknown): T` (method) #### `IStoreMechanism` *Type* the mechanism a store keeps its records in ```ts type IStoreMechanism = 'tempMemory' | 'localStorage' | 'sessionStorage' | 'indexedDB'; ``` #### `IStoreOptions` *Type* the options every store factory accepts ```ts type IStoreOptions = { fallback?: 'memory'; schema?: ISchema; }; ``` **Members** - `fallback: "memory"` (property): keeps records in a memory map owned by the store when its mechanism is unavailable. Without it, get returns undefined and set and del throw STORE_UNAVAILABLE - `schema: ISchema` (property): validates every stored value get reads, such as a Zod schema. Its errors propagate unchanged #### `ISyncStore` *Type* a store whose methods return their results directly: localStorage, sessionStorage, and memory ```ts type ISyncStore = { readonly id: string; readonly mechanism: IStoreMechanism; isCompatible(): boolean; get(recordId?: IRecordId): T | undefined; set(recordId: IRecordId, value: T): void; del(recordId?: IRecordId): void; }; ``` **Members** - `id: string` (property): the identifier every record key starts with - `mechanism: IStoreMechanism` (property): the mechanism the store was created for. isCompatible tells whether it's available - `isCompatible(): boolean` (method): whether the mechanism accepts writes, probed on the first call and remembered - `get(recordId?: IRecordId): T | undefined` (method): reads a record, or the root record when recordId is undefined - `set(recordId: IRecordId, value: T): void` (method): writes a record, or the root record when recordId is undefined - `del(recordId?: IRecordId): void` (method): deletes a record, or the root record when recordId is undefined --- # jg-storage recipes Patterns for apps that keep settings and drafts in the browser, and keep working where the browser blocks storage. ## Typed settings with a schema Use this for preferences an app reads at startup, such as a theme and a font size. A schema gives the store its type and checks every record `get` reads, because a stored record can come from an older version of the app, or be edited by hand in the browser's devtools: ```ts import { createLocalStore } from 'jg-storage'; type ISettings = { theme: 'light' | 'dark' | 'system'; fontSize: number }; // a Zod schema, or any validator with parse. Give fields added later a default, so settings saved // by an older version still parse declare const SettingsSchema: { parse(value: unknown): ISettings }; const DEFAULT_SETTINGS: ISettings = { theme: 'system', fontSize: 16 }; // the record type comes from the schema. localStorage is probed on the first call, not here, so the // store can be created at module scope, even during server rendering const settingsStore = createLocalStore('settings', { schema: SettingsSchema }); // reads the saved settings, or the defaults when nothing is saved or the saved record is invalid export const readSettings = (): ISettings => { try { return settingsStore.get() ?? DEFAULT_SETTINGS; } catch { // the schema rejected the record, or it isn't JSON. Either way it's unusable, and the next save // replaces it return DEFAULT_SETTINGS; } }; // saves a change and keeps the other settings as they were export const updateSettings = (changes: Partial): ISettings => { const settings = { ...readSettings(), ...changes }; settingsStore.set(undefined, settings); return settings; }; // a theme switch saves only the theme, and the font size keeps its saved value updateSettings({ theme: 'dark' }); ``` The schema checks what `get` reads, not what `set` writes: the type checker covers what your code saves. When the schema rejects a record, its own error, such as a `ZodError`, propagates unchanged, and a record that isn't JSON throws `FAILED_TO_PARSE_JSON`, so `readSettings` catches both and starts from the defaults. Where the browser blocks localStorage, `get` returns `undefined` and `set` throws `STORE_UNAVAILABLE`; the next recipe keeps the records in memory there instead. ## A store that falls back to memory Use this where the app should keep working when the browser blocks storage, such as with site data blocked in its privacy settings, or inside a sandboxed iframe. With `fallback: 'memory'`, the store keeps its records in memory there, and `isCompatible` tells you which one it uses, so you can say the work won't be kept: ```ts import { createLocalStore } from 'jg-storage'; type IDraft = { title: string; body: string }; // the notice that says drafts last only until the page closes declare const draftNotice: HTMLElement; // one store for the whole app: the records in memory belong to this store object, so another // store with the same id wouldn't see them const drafts = createLocalStore('drafts', { fallback: 'memory' }); export const saveDraft = (postId: string, draft: IDraft): void => { drafts.set(postId, draft); // localStorage was probed on the store's first call, and the answer is remembered draftNotice.hidden = drafts.isCompatible(); }; export const readDraft = (postId: string): IDraft | undefined => drafts.get(postId); ``` An IndexedDB store falls back the same way, with promises. Its records keep their types, such as a `Blob` or a `Date`, in IndexedDB and in memory alike: ```ts import { createIndexedDbStore } from 'jg-storage'; // a photo taken while offline, waiting to be uploaded type IPendingUpload = { photo: Blob; takenAt: Date }; const pendingUploads = createIndexedDbStore('pending-uploads', { fallback: 'memory', }); // queues a photo, and resolves to whether it survives the page closing before it's uploaded export const queueUpload = async (uploadId: string, photo: Blob): Promise => { await pendingUploads.set(uploadId, { photo, takenAt: new Date() }); return pendingUploads.isCompatible(); }; ``` The fallback covers a mechanism that's unavailable when the store first probes it. Errors from a mechanism that's available still propagate, such as a full localStorage quota from `set`, so catch them where a save can fail. In memory, a record is the object you saved, not a copy, so don't change an object after saving it. Leave the fallback out where data kept only in memory would mislead, and catch `STORE_UNAVAILABLE` instead. --- # jg-sw > A generated caching worker with a Vite plugin and CLI, plus registration, updates that reload once, and app installation. - **Version:** `1.4.0` - **Runtimes:** Browser, Node.js - **Install:** `npm install jg-sw` - **On npm:** [jg-sw](https://www.npmjs.com/package/jg-sw) - **Source:** [packages/jg-sw](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-sw) - **Recipes:** [jg-sw recipes](https://jg-packages.jesusgraterol.dev/packages/jg-sw/recipes/index.md) ## Usage ### Register the worker ```ts import { registerServiceWorker } from 'jg-sw'; const { registration, registrationError } = await registerServiceWorker(); // registers /sw.js ``` It never rejects: a failure lands in `registrationError`, an `Exception` whose code is one of `ERRORS`. Later calls return the first call's promise, and nothing runs until the first call, so it's safe to import during server-side rendering. ### Offer updates and installation The snapshot tells you when a new version waits and whether the app can be installed. It keeps its identity until something changes, so React can read it directly: ```tsx import { useSyncExternalStore } from 'react'; import { getServiceWorkerSnapshot, installApp, subscribeToServiceWorker, updateApp } from 'jg-sw'; export const AppBanner = () => { const { hasWaitingWorker, preloadErrorDetected, installation } = useSyncExternalStore( subscribeToServiceWorker, getServiceWorkerSnapshot, getServiceWorkerSnapshot, ); return ( <> {(hasWaitingWorker || preloadErrorDetected) && ( )} {installation.canInstall && } ); }; ``` Without React, `subscribeToServiceWorker(listener)` calls the listener on every change and returns a function that stops it. `updateApp()` reloads the page once, never in a loop: - When a new version waits, it asks it to activate and reloads once it controls the page. - Otherwise it checks for one, waits up to `UPDATE_INSTALL_TIMEOUT_SECONDS` (30) for it to install, then activates it the same way. - When nothing new is found, the installation fails, or the time runs out, it reloads the page as it is. `preloadErrorDetected` turns true when a chunk of a Vite build fails to load while the browser is online and the page has the build's entry module: the chunk most likely went away with a newer deployment. jg-sw then prevents the `vite:preloadError` event's default, so Vite doesn't throw, and the app offers `updateApp()`, which loads the new version. A failure while offline, or on a page without an entry module, is left to Vite, so the app's own error handling shows it. Call `registerServiceWorker()` as the app starts, so it hears the first failure. `installApp()` shows the browser's install prompt and resolves to `'accepted'` or `'dismissed'`. Only Chromium-based browsers offer it, so check `canInstallApp()` or `installation.canInstall` first. On an iPhone or an iPad, no browser offers an install prompt, and web push works only once the app is installed to the Home Screen. When installing unlocks something your app needs, such as notifications, show your own Add to Home Screen instructions, and only where they apply: `isIosBrowserTab()` is true in an iOS browser tab, including an iPad that asks for desktop sites, and false in the installed app and everywhere else. ```ts import { isIosBrowserTab } from 'jg-sw'; homeScreenHint.hidden = !isIosBrowserTab(); // Share, then Add to Home Screen ``` ### Describe the worker Create `jg-sw.config.json` next to your app's `package.json`: ```json { "outDir": "dist", "template": "base", "cacheNamePrefix": "my-app", "includeToPrecache": ["/assets"], "excludeFilesFromPrecache": ["some-ignorable-file.woff2"], "excludeMIMETypesFromCache": ["application/json", "text/plain", "text/html"] } ``` | Field | What it does | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `outDir` | The directory the app's build is written to. The worker is written there as `sw.js`. | | `template` | `base` caches the app. `firebase-fcm` also shows Firebase Cloud Messaging notifications. | | `cacheNamePrefix` | The namespace of the worker's caches, in lowercase words separated by hyphens, such as `my-app`. Each build caches under `--<10 characters>`. | | `includeToPrecache` | The files and directories, relative to `outDir`, cached when the worker installs. Directories are read recursively, and `/` stands for the app's root page. | | `excludeFilesFromPrecache` | File names left out of precaching, wherever they are. | | `excludeMIMETypesFromCache` | MIME types whose responses are never cached. | | `navigationFallback` | Optional. A precached page, such as `/index.html`, that answers a navigation the worker can't serve offline, so a single-page app's routes still open. The build fails with `NAVIGATION_FALLBACK_NOT_PRECACHED` when it isn't precached. | This config precaches the hashed files under `dist/assets`, but not `/` or `index.html`. Nothing is precached unless it's listed: add `/` and `/index.html` to precache the app's shell. ### Generate it with Vite ```ts // vite.config.ts import { defineConfig } from 'vite'; import { serviceWorkerPlugin } from 'jg-sw/build'; export default defineConfig({ plugins: [serviceWorkerPlugin({ config: 'jg-sw.config.json' })], }); ``` `vite build` writes `dist/sw.js` after the app's files. `vite` serves a worker without caching at `/sw.js`, so you can test registration and updates while every request still reaches the dev server. ### Generate it with the CLI Run `jg-sw` after your build: ```json { "scripts": { "build": "vite build && jg-sw" } } ``` ```text Usage: jg-sw [options] Options: --config The config file. Defaults to jg-sw.config.json. --environment development, staging, or production. --no-caching Generates a worker without precaching, cache cleanup, or a fetch handler. --help Prints this message. ``` It prints the path it wrote and exits 0, or prints the error and exits 1. ### Generate it from code ```ts import { readServiceWorkerConfig, writeServiceWorker } from 'jg-sw/build'; const config = await readServiceWorkerConfig('jg-sw.config.json'); await writeServiceWorker(config, { environment: 'production' }); // '/app/dist/sw.js' ``` `buildServiceWorker` returns the worker's source without writing it. ### What the worker does - `install` fills only the new version's cache, and doesn't skip waiting, so the previous worker keeps serving its clients. - The page activates a waiting worker by posting `{ type: 'JG_SW_SKIP_WAITING' }` to it. - `activate` deletes the older caches in its own namespace, keeps every other cache, then claims its clients. - It answers same-origin `GET` requests from the current version's cache, then from the network, and caches successful responses unless they're opaque, partial, marked `Vary: *`, marked `Cache-Control: no-store` or `private`, or of an excluded MIME type. A failed cache write never replaces a valid response. - When a navigation is in neither its cache nor the network's reach, such as offline, it answers with the `navigationFallback` page when one is set, and otherwise with a `408` network error. A `4xx` or `5xx` from the server is passed through as it is. ### Firebase Cloud Messaging The `firebase-fcm` template adds two fields: ```json { "template": "firebase-fcm", "firebaseConfigProcessEnvKey": "VITE_FIREBASE_CONFIG", "firebaseSdkVersion": "12.4.0" } ``` The environment variable holds the Firebase web app's options as JSON: ```bash VITE_FIREBASE_CONFIG='{"options":{"apiKey":"example-api-key","authDomain":"example.firebaseapp.com","projectId":"example-project","storageBucket":"example.firebasestorage.app","messagingSenderId":"123456789012","appId":"1:123456789012:web:example"}}' ``` Generate the worker with an environment, such as `jg-sw --environment production`. It loads `.env`, `.env.staging`, or `.env.production` when the file exists, and variables already set keep their values. The worker loads Firebase's compat SDK from `gstatic`. When no app window is visible, it shows a notification for each data-only message, using its `title`, `body`, `icon`, `badge`, and `url` fields, and Firebase shows the messages that carry a notification. Clicking a notification focuses or opens its URL, limited to the app's origin. To decide yourself, append code to the generated worker that assigns `self.jgSwFcmBackgroundMessageHandler = async (payload) => { … }`. The worker waits for it, and shows nothing else. ## jg-sw API reference Every export of `jg-sw@1.4.0`, 27 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. ### `jg-sw` - Functions: [`canInstallApp`](#api-canInstallApp), [`getServiceWorkerSnapshot`](#api-getServiceWorkerSnapshot), [`installApp`](#api-installApp), [`isIosBrowserTab`](#api-isIosBrowserTab), [`registerServiceWorker`](#api-registerServiceWorker), [`subscribeToServiceWorker`](#api-subscribeToServiceWorker), [`updateApp`](#api-updateApp) - Constants: [`DEFAULT_SERVICE_WORKER_PATH`](#api-DEFAULT_SERVICE_WORKER_PATH), [`DEFAULT_SERVICE_WORKER_SCOPE`](#api-DEFAULT_SERVICE_WORKER_SCOPE), [`ERRORS`](#api-ERRORS), [`REGISTRATION_DURATION_SECONDS`](#api-REGISTRATION_DURATION_SECONDS), [`SKIP_WAITING_MESSAGE_TYPE`](#api-SKIP_WAITING_MESSAGE_TYPE), [`UPDATE_INSTALL_TIMEOUT_SECONDS`](#api-UPDATE_INSTALL_TIMEOUT_SECONDS) - Interfaces: [`IBeforeInstallPromptEvent`](#api-IBeforeInstallPromptEvent) - Types: [`IAppInstallationState`](#api-IAppInstallationState), [`IInstallationPromptOutcome`](#api-IInstallationPromptOutcome), [`IRegisterServiceWorkerOptions`](#api-IRegisterServiceWorkerOptions), [`IServiceWorkerSnapshot`](#api-IServiceWorkerSnapshot), [`IUserChoice`](#api-IUserChoice) #### Functions in `jg-sw` ##### `canInstallApp` *Function* Checks whether installApp can show the installation prompt now: the browser offered one, and the page doesn't already run as the installed app. Only Chromium-based browsers offer it. ```ts canInstallApp(): boolean ``` **Returns** `boolean`: Whether the app can be installed. **Example** ```ts import { canInstallApp } from 'jg-sw'; installButton.hidden = !canInstallApp(); ``` ##### `getServiceWorkerSnapshot` *Function* Reads the service worker's state: its registration, its newest worker, whether an update waits, and whether the app can be installed. The same object comes back until something changes, so it can be passed to React's useSyncExternalStore, during server rendering too. ```ts getServiceWorkerSnapshot(): IServiceWorkerSnapshot ``` **Returns** `IServiceWorkerSnapshot`: The snapshot. **Example** ```ts import { getServiceWorkerSnapshot, subscribeToServiceWorker } from 'jg-sw'; const { hasWaitingWorker, installation } = useSyncExternalStore( subscribeToServiceWorker, getServiceWorkerSnapshot, getServiceWorkerSnapshot, ); ``` ##### `installApp` *Function* Shows the browser's installation prompt and resolves to the user's answer, also kept in the snapshot as installation.promptOutcome. The browser offers each prompt once, so canInstallApp is false afterwards until it offers another. ```ts installApp(): Promise ``` **Returns** `Promise`: A promise that resolves to 'accepted' or 'dismissed'. **Throws** - `NO_APP_INSTALLER`: The app can't be installed because the browser didn't offer the prompt. - `UNKNOWN_INSTALLATION_PROMPT_ERROR`: The installation prompt failed. **Example** ```ts import { installApp } from 'jg-sw'; installButton.addEventListener('click', async () => { const outcome = await installApp(); // 'accepted' or 'dismissed' }); ``` ##### `isIosBrowserTab` *Function* Checks whether the page runs in a browser tab on an iPhone, an iPad, or an iPod touch, rather than as an app installed to the Home Screen. There, web push and some storage only work once the app is installed, and no browser offers an install prompt, so this decides when to show your own Add to Home Screen instructions. An iPad that asks for desktop sites reports a Mac, so a Mac with a touchscreen counts as one. ```ts isIosBrowserTab(): boolean ``` **Returns** `boolean`: Whether it's an iOS browser tab; `false` outside a browser. **Example** ```ts import { isIosBrowserTab } from 'jg-sw'; homeScreenHint.hidden = !isIosBrowserTab(); ``` ##### `registerServiceWorker` *Function* Registers the service worker and starts following its updates, whether the app can be installed, and whether a chunk of a replaced build failed to load, which sets the snapshot's preloadErrorDetected. Call it as the app starts, so it hears the first failure. Later calls return the first call's promise. It never rejects: a failure lands in the snapshot's registrationError, an Exception with SW_NO_BROWSER_SUPPORT, EMPTY_SW_REGISTRATION, or SW_REGISTRATION_FAILED as its code, and the browser's error as its cause. ```ts registerServiceWorker( options?: IRegisterServiceWorkerOptions, ): Promise ``` **Parameters** - `options?` `IRegisterServiceWorkerOptions`: The worker's path and registration options, and whether to log. Defaults to `{}`. **Returns** `Promise`: A promise that resolves to the snapshot once the registration settles. **Example** ```ts import { hasErrorCode } from 'jg-exception'; import { ERRORS, registerServiceWorker } from 'jg-sw'; const { registration, registrationError } = await registerServiceWorker(); if (hasErrorCode(registrationError, ERRORS.SW_NO_BROWSER_SUPPORT)) { // the browser has no service workers } ``` ##### `subscribeToServiceWorker` *Function* Calls a listener whenever the snapshot changes, such as when an update finishes installing or the app becomes installable. ```ts subscribeToServiceWorker(listener: () => void): () => void ``` **Parameters** - `listener` `() => void`: The function to call, without arguments; read the new state with getServiceWorkerSnapshot. **Returns** `() => void`: A function that stops the calls. **Example** ```ts import { getServiceWorkerSnapshot, subscribeToServiceWorker } from 'jg-sw'; const unsubscribe = subscribeToServiceWorker(() => { if (getServiceWorkerSnapshot().hasWaitingWorker) { showUpdateBanner(); } }); ``` ##### `updateApp` *Function* Updates the app to its newest version and reloads the page once, never in a loop. When a new version waits, it activates it and reloads once the new worker controls the page. Otherwise, it checks for one, waits up to UPDATE_INSTALL_TIMEOUT_SECONDS for it to install, then activates it the same way. When nothing new is found, the installation fails, or the time runs out, it reloads the page as it is. ```ts updateApp(): Promise ``` **Returns** `Promise`: A promise that resolves once the new worker was asked to activate, or the page started reloading. **Example** ```ts import { getServiceWorkerSnapshot, updateApp } from 'jg-sw'; if (getServiceWorkerSnapshot().hasWaitingWorker) { updateButton.addEventListener('click', () => updateApp()); } ``` #### Constants in `jg-sw` ##### `DEFAULT_SERVICE_WORKER_PATH` *Constant* the path the runtime registers the worker from, and the Vite plugin serves it at in development ```ts const DEFAULT_SERVICE_WORKER_PATH: "/sw.js" ``` ##### `DEFAULT_SERVICE_WORKER_SCOPE` *Constant* the scope the runtime registers the worker with ```ts const DEFAULT_SERVICE_WORKER_SCOPE: "/" ``` ##### `ERRORS` *Constant* the codes for every error thrown or reported by the runtime ```ts const ERRORS: { SW_NO_BROWSER_SUPPORT: "SW_NO_BROWSER_SUPPORT"; EMPTY_SW_REGISTRATION: "EMPTY_SW_REGISTRATION"; SW_REGISTRATION_FAILED: "SW_REGISTRATION_FAILED"; NO_APP_INSTALLER: "NO_APP_INSTALLER"; UNKNOWN_INSTALLATION_PROMPT_ERROR: "UNKNOWN_INSTALLATION_PROMPT_ERROR" } ``` ##### `REGISTRATION_DURATION_SECONDS` *Constant* the seconds a registration usually takes, for apps that wait before reading the snapshot ```ts const REGISTRATION_DURATION_SECONDS: 5 ``` ##### `SKIP_WAITING_MESSAGE_TYPE` *Constant* the type of the message the runtime posts to make a waiting worker activate ```ts const SKIP_WAITING_MESSAGE_TYPE: "JG_SW_SKIP_WAITING" ``` ##### `UPDATE_INSTALL_TIMEOUT_SECONDS` *Constant* the seconds updateApp waits for a new worker to finish installing before it reloads anyway ```ts const UPDATE_INSTALL_TIMEOUT_SECONDS: 30 ``` #### Interfaces in `jg-sw` ##### `IBeforeInstallPromptEvent` *Interface* the event Chromium-based browsers fire when the app can be installed, which lets the app show the installation prompt when it chooses ```ts interface IBeforeInstallPromptEvent extends Event { readonly platforms: string[]; readonly userChoice: Promise; prompt(): Promise; } ``` **Members** - `platforms: string[]` (property): the platforms the event was dispatched for, such as 'web' or 'play' - `userChoice: Promise` (property): resolves to the user's answer once the prompt closes - `prompt(): Promise` (method): shows the installation prompt #### Types in `jg-sw` ##### `IAppInstallationState` *Type* whether the app can be installed, and what happened when it was offered ```ts type IAppInstallationState = { canInstall: boolean; isAppInstalled: boolean | undefined; isRunningInstalledApp: boolean; promptOutcome: IInstallationPromptOutcome | undefined; }; ``` **Members** - `canInstall: boolean` (property): whether installApp can show the installation prompt now - `isAppInstalled: boolean | undefined` (property): true once the browser reports the app was installed, undefined until then - `isRunningInstalledApp: boolean` (property): whether the page runs as the installed app, in a standalone window - `promptOutcome: IInstallationPromptOutcome | undefined` (property): the user's answer to the last prompt installApp showed, undefined until one is answered ##### `IInstallationPromptOutcome` *Type* the action the user took on the installation prompt ```ts type IInstallationPromptOutcome = 'accepted' | 'dismissed'; ``` ##### `IRegisterServiceWorkerOptions` *Type* the options registerServiceWorker accepts ```ts type IRegisterServiceWorkerOptions = { path?: string; options?: RegistrationOptions; debugMode?: boolean; }; ``` **Members** - `path: string` (property): the worker's script. Defaults to DEFAULT_SERVICE_WORKER_PATH, '/sw.js' - `options: RegistrationOptions` (property): the options passed to navigator.serviceWorker.register. Defaults to { scope: '/' } - `debugMode: boolean` (property): logs the registration, update, and installation events to the console ##### `IServiceWorkerSnapshot` *Type* the service worker's state. The object keeps its identity until something changes, so useSyncExternalStore(subscribeToServiceWorker, getServiceWorkerSnapshot) re-renders only on changes ```ts type IServiceWorkerSnapshot = { registration: ServiceWorkerRegistration | undefined; worker: ServiceWorker | undefined; registrationError: Exception | undefined; hasWaitingWorker: boolean; preloadErrorDetected: boolean; installation: IAppInstallationState; }; ``` **Members** - `registration: ServiceWorkerRegistration | undefined` (property): the registration, once registerServiceWorker succeeds - `worker: ServiceWorker | undefined` (property): the newest worker: the installing one, else the waiting one, else the active one - `registrationError: Exception | undefined` (property): why the registration failed, with one of the ERRORS codes - `hasWaitingWorker: boolean` (property): whether a new version is installed and waits to activate, which updateApp does - `preloadErrorDetected: boolean` (property): whether a chunk of the page's build failed to load while online, most likely because a newer deployment replaced it; updateApp loads the new version - `installation: IAppInstallationState` (property): whether the app can be installed, and what happened when it was offered ##### `IUserChoice` *Type* the user's answer to the installation prompt ```ts type IUserChoice = { outcome: IInstallationPromptOutcome; platform: string; }; ``` **Members** - `outcome: IInstallationPromptOutcome` (property) - `platform: string` (property) ### `jg-sw/build` - Functions: [`buildServiceWorker`](#api-build-buildServiceWorker), [`readServiceWorkerConfig`](#api-build-readServiceWorkerConfig), [`serviceWorkerPlugin`](#api-build-serviceWorkerPlugin), [`writeServiceWorker`](#api-build-writeServiceWorker) - Constants: [`ERRORS`](#api-build-ERRORS), [`ServiceWorkerConfigSchema`](#api-build-ServiceWorkerConfigSchema) - Types: [`IBuildServiceWorkerOptions`](#api-build-IBuildServiceWorkerOptions), [`IServiceWorkerConfig`](#api-build-IServiceWorkerConfig) #### Functions in `jg-sw/build` ##### `buildServiceWorker` *Function* Generates the service worker's source from a config, without writing it. Every generated worker gets a new cache name, `--<10 random characters>`, so a new deployment replaces the previous one's cache when it activates. ```ts buildServiceWorker( config: { outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "base" } | { outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "firebase-fcm"; firebaseConfigProcessEnvKey: string; firebaseSdkVersion: string }, options?: IBuildServiceWorkerOptions, ): Promise ``` **Parameters** - `config` `{ outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "base" } | { outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "firebase-fcm"; firebaseConfigProcessEnvKey: string; firebaseSdkVersion: string }`: The service worker config, as read by readServiceWorkerConfig or written inline. - `options?` `IBuildServiceWorkerOptions`: The environment the firebase-fcm template reads its Firebase config from, whether the worker caches (false for development), and the directory paths are resolved from. Defaults to `{}`. **Returns** `Promise`: A promise that resolves to the worker's source. **Throws** - `FAILED_TO_READ_BASE_CONFIG`: The service worker config is invalid. - `INVALID_TEMPLATE_NAME`: The template must be 'base' or 'firebase-fcm'. - `INVALID_FIREBASE_SDK_VERSION`: The Firebase SDK version must be a version such as 12.4.0. - `INVALID_ENVIRONMENT`: The environment isn't supported, or the firebase-fcm template needs one. - `NOT_A_PATH_ELEMENT`: An asset to precache is outside outDir, or isn't a file or a directory. - `NAVIGATION_FALLBACK_NOT_PRECACHED`: The navigation fallback isn't one of the precached paths. - `FAILED_TO_BUILD_CONFIG`: Unable to load the environment file. - `INVALID_FIREBASE_CONFIG`: The environment variable that holds the Firebase config is missing or - empty. - `FAILED_TO_READ_FIREBASE_CONFIG`: Unable to read the Firebase config from the environment - variable. **Example** ```ts import { buildServiceWorker } from 'jg-sw/build'; const source = await buildServiceWorker( { outDir: 'dist', cacheNamePrefix: 'my-app', template: 'base', includeToPrecache: ['/', '/assets'], excludeFilesFromPrecache: [], excludeMIMETypesFromCache: ['application/json'], }, { caching: true }, ); ``` ##### `readServiceWorkerConfig` *Function* Reads and validates a service worker config file, such as `jg-sw.config.json`. ```ts readServiceWorkerConfig( path: string, ): Promise<{ outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "base" } | { outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "firebase-fcm"; firebaseConfigProcessEnvKey: string; firebaseSdkVersion: string }> ``` **Parameters** - `path` `string`: The config file's path. **Returns** `Promise<{ outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "base" } | { outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "firebase-fcm"; firebaseConfigProcessEnvKey: string; firebaseSdkVersion: string }>`: A promise that resolves to the config. **Throws** - `FAILED_TO_READ_BASE_CONFIG`: Unable to read the service worker config, or it is invalid. - `INVALID_TEMPLATE_NAME`: The template must be 'base' or 'firebase-fcm'. - `INVALID_FIREBASE_SDK_VERSION`: The Firebase SDK version must be a version such as 12.4.0. **Example** ```ts import { readServiceWorkerConfig } from 'jg-sw/build'; const config = await readServiceWorkerConfig('jg-sw.config.json'); config.template; // 'base' ``` ##### `serviceWorkerPlugin` *Function* Creates a Vite plugin that generates the service worker. After a production build, it writes `sw.js` into the config's outDir, once the client bundle is written. During development, the dev server answers `/sw.js` with a worker generated with `caching: false`, so every request reaches the dev server and updates show up without stale caches. Paths are resolved from Vite's root unless `cwd` is set. When generating fails, the build fails with writeServiceWorker's error, and the dev server answers the worker's request with it. ```ts serviceWorkerPlugin( options: { config: string | { outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "base" } | { outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "firebase-fcm"; firebaseConfigProcessEnvKey: string; firebaseSdkVersion: string } } & IBuildServiceWorkerOptions, ): Plugin ``` **Parameters** - `options` `{ config: string | { outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "base" } | { outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "firebase-fcm"; firebaseConfigProcessEnvKey: string; firebaseSdkVersion: string } } & IBuildServiceWorkerOptions`: The config, as a file path or an object, plus the build options. The dev server uses the environment option, or `development` when it isn't set. **Returns** `Plugin`: The Vite plugin. **Example** ```ts // vite.config.ts import { defineConfig } from 'vite'; import { serviceWorkerPlugin } from 'jg-sw/build'; export default defineConfig({ plugins: [serviceWorkerPlugin({ config: 'jg-sw.config.json', environment: 'production' })], }); ``` ##### `writeServiceWorker` *Function* Generates the service worker and writes it to `sw.js` in the config's outDir, after the app's build has written its files there. ```ts writeServiceWorker( config: { outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "base" } | { outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "firebase-fcm"; firebaseConfigProcessEnvKey: string; firebaseSdkVersion: string }, options?: IBuildServiceWorkerOptions, ): Promise ``` **Parameters** - `config` `{ outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "base" } | { outDir: string; cacheNamePrefix: string; includeToPrecache: string[]; excludeFilesFromPrecache: string[]; excludeMIMETypesFromCache: string[]; navigationFallback?: string; template: "firebase-fcm"; firebaseConfigProcessEnvKey: string; firebaseSdkVersion: string }`: The service worker config, as read by readServiceWorkerConfig or written inline. - `options?` `IBuildServiceWorkerOptions`: The environment the firebase-fcm template reads its Firebase config from, whether the worker caches (false for development), and the directory paths are resolved from. Defaults to `{}`. **Returns** `Promise`: A promise that resolves to the written file's path. **Throws** - `FAILED_TO_READ_BASE_CONFIG`: The service worker config is invalid. - `INVALID_TEMPLATE_NAME`: The template must be 'base' or 'firebase-fcm'. - `INVALID_FIREBASE_SDK_VERSION`: The Firebase SDK version must be a version such as 12.4.0. - `INVALID_ENVIRONMENT`: The environment isn't supported, or the firebase-fcm template needs one. - `NOT_A_PATH_ELEMENT`: An asset to precache is outside outDir, or isn't a file or a directory. - `NAVIGATION_FALLBACK_NOT_PRECACHED`: The navigation fallback isn't one of the precached paths. - `FAILED_TO_BUILD_CONFIG`: Unable to load the environment file. - `INVALID_FIREBASE_CONFIG`: The environment variable that holds the Firebase config is missing or - empty. - `FAILED_TO_READ_FIREBASE_CONFIG`: Unable to read the Firebase config from the environment - variable. **Example** ```ts import { readServiceWorkerConfig, writeServiceWorker } from 'jg-sw/build'; const config = await readServiceWorkerConfig('jg-sw.config.json'); await writeServiceWorker(config, { environment: 'production' }); // '/app/dist/sw.js' ``` #### Constants in `jg-sw/build` ##### `ERRORS` *Constant* the codes for every error thrown by the build side ```ts const ERRORS: { INVALID_TEMPLATE_NAME: "INVALID_TEMPLATE_NAME"; NOT_A_PATH_ELEMENT: "NOT_A_PATH_ELEMENT"; FAILED_TO_READ_BASE_CONFIG: "FAILED_TO_READ_BASE_CONFIG"; INVALID_FIREBASE_CONFIG: "INVALID_FIREBASE_CONFIG"; INVALID_ENVIRONMENT: "INVALID_ENVIRONMENT"; FAILED_TO_BUILD_CONFIG: "FAILED_TO_BUILD_CONFIG"; FAILED_TO_READ_FIREBASE_CONFIG: "FAILED_TO_READ_FIREBASE_CONFIG"; INVALID_FIREBASE_SDK_VERSION: "INVALID_FIREBASE_SDK_VERSION"; NAVIGATION_FALLBACK_NOT_PRECACHED: "NAVIGATION_FALLBACK_NOT_PRECACHED" } ``` ##### `ServiceWorkerConfigSchema` *Constant* The schema of a service worker config: the `jg-sw.config.json` file the CLI reads, or the object passed to `buildServiceWorker`. `template` selects the worker: `base` caches the app, and `firebase-fcm` also shows Firebase Cloud Messaging notifications. ```ts const ServiceWorkerConfigSchema: ZodDiscriminatedUnion<[ZodObject<{ outDir: ZodString; cacheNamePrefix: ZodString; includeToPrecache: ZodArray; excludeFilesFromPrecache: ZodArray; excludeMIMETypesFromCache: ZodArray; navigationFallback: ZodOptional; template: ZodLiteral<"base"> }, $strict>, ZodObject<{ outDir: ZodString; cacheNamePrefix: ZodString; includeToPrecache: ZodArray; excludeFilesFromPrecache: ZodArray; excludeMIMETypesFromCache: ZodArray; navigationFallback: ZodOptional; template: ZodLiteral<"firebase-fcm">; firebaseConfigProcessEnvKey: ZodString; firebaseSdkVersion: ZodString }, $strict>], "template"> ``` **Example** ```ts import { ServiceWorkerConfigSchema } from 'jg-sw/build'; const config = ServiceWorkerConfigSchema.parse({ outDir: 'dist', cacheNamePrefix: 'my-app', template: 'base', includeToPrecache: ['/', '/index.html', '/assets'], excludeFilesFromPrecache: [], excludeMIMETypesFromCache: ['application/json'], }); ``` #### Types in `jg-sw/build` ##### `IBuildServiceWorkerOptions` *Type* the options buildServiceWorker and writeServiceWorker accept ```ts type IBuildServiceWorkerOptions = { environment?: 'development' | 'staging' | 'production'; caching?: boolean; cwd?: string; }; ``` **Members** - `environment: "development" | "staging" | "production"` (property): the environment whose file the firebase-fcm template reads its Firebase config from: .env, .env.staging, or .env.production. The firebase-fcm template requires it - `caching: boolean` (property): false leaves out precaching, cache cleanup, and the fetch handler, for development. Defaults to true - `cwd: string` (property): the directory outDir and the environment files are resolved from. Defaults to process.cwd() ##### `IServiceWorkerConfig` *Type* a service worker config, validated by ServiceWorkerConfigSchema ```ts type IServiceWorkerConfig = z.infer; ``` --- # jg-sw recipes Patterns for Vite apps that keep working offline, and tell people when a new version is ready. ## An offline Vite app Use this when the app should open without a network after the first visit. The plugin writes `sw.js` into the build after Vite's own files, and the worker caches the files you list when it installs. Putting the config in `vite.config.ts` gets it typechecked with the rest of the file: ```ts // vite.config.ts import { defineConfig } from 'vite'; import { serviceWorkerPlugin } from 'jg-sw/build'; export default defineConfig({ plugins: [ serviceWorkerPlugin({ config: { // Vite's build.outDir, where the worker is written too outDir: 'dist', template: 'base', // the namespace of this app's caches, so a new version deletes only its own old ones cacheNamePrefix: 'my-app', // the page and Vite's hashed bundles, which is all the app needs to start offline includeToPrecache: ['/', '/index.html', '/assets'], excludeFilesFromPrecache: [], // the worker answers from its cache first, so keep API data and other pages out of it excludeMIMETypesFromCache: ['application/json', 'text/html'], // offline, a route the app handles, such as /settings, opens through the page navigationFallback: '/index.html', }, }), ], }); ``` The worker answers same-origin `GET` requests from its cache before the network, and caches what it fetches, so whatever it caches stays the same until the next build replaces the cache. That suits hashed bundles, and would serve stale data from an API on the same origin: `excludeMIMETypesFromCache` keeps JSON out, and responses marked `Cache-Control: no-store` or `private` never enter the cache either. Add the files from `public/` that the page loads, such as its icons, to `includeToPrecache` when they should show offline. `config` also takes a path, such as `'jg-sw.config.json'`, when the CLI reads the same file. Register the worker from the app's entry module. Precaching downloads every listed file, so it starts once the page has loaded: ```ts // src/main.ts import { hasErrorCode } from 'jg-exception'; import { ERRORS, registerServiceWorker } from 'jg-sw'; window.addEventListener( 'load', async () => { // it never rejects: a failure lands in registrationError const { registrationError } = await registerServiceWorker(); // without service workers the app still runs, online only, so that case needs no report if ( registrationError !== undefined && !hasErrorCode(registrationError, ERRORS.SW_NO_BROWSER_SUPPORT) ) { console.error(registrationError); } }, { once: true }, ); ``` During `vite`, the dev server answers `/sw.js` with a worker that caches nothing, so registration works and every request still reaches the dev server. To try the offline app, run `vite build` and `vite preview`, load the page once, then turn the network off in the browser's devtools. The worker matches each request exactly, so a client-side route, such as `/settings`, is never in its cache. Offline, `navigationFallback` answers the navigation with the precached page, at the route's URL, so the app's router shows it; without it, reloading the route gets the worker's network error. The fallback must be one of the precached paths, or the build fails with `NAVIGATION_FALLBACK_NOT_PRECACHED`. ## Telling people an update is ready Each build makes a new worker, which installs next to the running one and waits, so open pages keep the version they loaded. Use this to tell people a new version is ready and let them choose when to switch, because switching reloads the page. The state comes from `registerServiceWorker`, so call it first, as in the previous recipe: ```ts import { getServiceWorkerSnapshot, subscribeToServiceWorker, updateApp } from 'jg-sw'; // shows the button only while a new version waits, or a chunk of a replaced build failed to load const showUpdateState = (): void => { const { hasWaitingWorker, preloadErrorDetected } = getServiceWorkerSnapshot(); updateButton.hidden = !(hasWaitingWorker || preloadErrorDetected); }; subscribeToServiceWorker(showUpdateState); // a version may already wait when this runs, such as one installed during an earlier visit showUpdateState(); updateButton.addEventListener('click', () => { // it reloads the page, so it runs only when the person asks updateButton.disabled = true; void updateApp(); }); ``` `updateApp()` asks the waiting worker to take over, and reloads the page a single time when the new worker controls it, never in a loop. Don't call it on your own while a form holds unsaved input. A "Check for updates" item, such as in a settings menu, can call it with no version waiting: it looks for one, waits up to `UPDATE_INSTALL_TIMEOUT_SECONDS` for it to install, and reloads the page either way. Browsers look for a new worker when a page in its scope loads. A single-page app can stay open for days without loading another page, so look again when the person returns to the tab: ```ts import { getServiceWorkerSnapshot } from 'jg-sw'; document.addEventListener('visibilitychange', () => { const { registration } = getServiceWorkerSnapshot(); if (document.visibilityState === 'visible' && registration !== undefined) { // offline, the check fails, and the next return to the tab tries again registration.update().catch(() => undefined); } }); ``` When the check finds a new version, the snapshot changes once it has installed, and the button from the previous block appears. A deployment also replaces the build's chunks, so a page that stayed open on the old version can fail to load one, such as when the person opens a route for the first time. `preloadErrorDetected` turns true then, and the same button loads the new version. jg-sw handles only a failure while online, on a page with the build's entry module; one while offline stays Vite's error, for the app's error handling, because reloading wouldn't help. --- # jg-fs > Filesystem helpers, async and sync: path checks, directories, text and JSON files, atomic writes, and bounded reads. - **Version:** `1.4.0` - **Runtimes:** Node.js - **Install:** `npm install jg-fs` - **On npm:** [jg-fs](https://www.npmjs.com/package/jg-fs) - **Source:** [packages/jg-fs](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-fs) - **Recipes:** [jg-fs recipes](https://jg-packages.jesusgraterol.dev/packages/jg-fs/recipes/index.md) ## Usage The root entry is async, and `jg-fs/sync` has the same functions, synchronous: ```ts import { readJsonFile, writeFileAtomic, writeJsonFile, writeTextFile } from 'jg-fs'; import { readTextFile } from 'jg-fs/sync'; import { z } from 'zod'; const Config = z.object({ port: z.number() }); await writeTextFile('notes/today.md', '# Today'); // creates notes/ when it's missing const config = await readJsonFile('config.json', { schema: Config }); // validated, and typed by the schema await writeJsonFile('config.json', { ...config, port: 3000 }); // atomic: readers never see a partial file await writeFileAtomic('state.txt', 'step 2'); // any content, atomically const upload = await readJsonFile('uploads/import.json', { maxBytes: 1_048_576 }); // FILE_TOO_LARGE past 1 MB const readme = readTextFile('README.md'); ``` List, filter, and sort a directory: ```ts import { getDirectoryElements } from 'jg-fs'; const { files, directories } = await getDirectoryElements('content', { includeExts: ['.md'], sortByKey: 'creation', sortOrder: 'desc', }); ``` Read files from untrusted paths with a size limit, without following a symbolic link: ```ts import { isPathWithin, readBoundedFile } from 'jg-fs'; if (isPathWithin(uploadsDirectory, path)) { const upload = await readBoundedFile(path, 5 * 1024 * 1024); } ``` Keep a job from running twice at once, across processes on one machine, with a lock: ```ts import { acquireLock } from 'jg-fs'; const lock = await acquireLock('.locks/nightly-report'); // LOCK_BUSY while another process holds it try { await buildNightlyReport(); } finally { await lock.release(); } ``` The lock is a directory holding an `owner.json` that names the process. When its owner's process is gone, as after a crash, the next `acquireLock` on the same host takes it over. Use it on local filesystems only: a lock is never taken over from another host. Keep keys, tokens, and sessions private to the user who owns them: ```ts import { createPrivateDirectory, readPrivateFile, writePrivateFile } from 'jg-fs'; await createPrivateDirectory(configDirectory); // 0o700; DIRECTORY_NOT_PRIVATE if it exists and isn't await writePrivateFile(`${configDirectory}/token.json`, token, { rootPath: configDirectory }); // atomic, 0o600 const saved = await readPrivateFile(`${configDirectory}/token.json`, { maxBytes: 16 * 1024, rootPath: configDirectory, }); ``` A private file is a regular file, never a symbolic link, with no symbolic link anywhere in its path (`PATH_NOT_CANONICAL`), and without group or other permissions (`FILE_NOT_PRIVATE`). The checks run on the open file, so the content read is the content checked. A shared file is never overwritten, and permissions are never changed for you: fix them, or remove the file. `writePrivateFile` with `isExclusive` creates the file and throws `FILE_ALREADY_EXISTS` when anything is already at the path. On Windows, Node.js reports only a file's read-only attribute as its mode, so the permission checks are skipped there. A private file on Windows depends on its directory's access control list, such as a directory inside the user's profile. Failures throw `Exception`s from `jg-exception`, with the codes in `ERRORS`. ### Removing a stuck lock A lock left in a state no process can safely take over stays busy, and `LOCK_BUSY`'s `data.reason` says why: - `held`: the owner named in `owner.json` is still running. Wait, or stop that process. - `other-host`: the owner runs on another host, which this host can't check. - `ownerless`: `owner.json` is missing or invalid, such as after a crash right after the directory was created. A lock being created or released at that moment looks the same, so try again first. - `recovery-in-progress`: a `recovery` directory is inside, so another process is taking the lock over, or crashed while doing so. - `unexpected-entries`: the directory holds something besides `owner.json`, such as a `.tmp` file left by an interrupted write. Before deleting a stuck lock's directory by hand, confirm that no process is using it: check the `pid` and `hostname` in `owner.json`, when there is one. Then delete the whole directory. ## jg-fs API reference Every export of `jg-fs@1.4.0`, 84 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. ### `jg-fs` - Functions: [`acquireLock`](#api-acquireLock), [`assertPrivateDirectory`](#api-assertPrivateDirectory), [`assertPrivateFile`](#api-assertPrivateFile), [`copyDirectory`](#api-copyDirectory), [`copyFile`](#api-copyFile), [`createDirectory`](#api-createDirectory), [`createDirectorySymLink`](#api-createDirectorySymLink), [`createFileSymLink`](#api-createFileSymLink), [`createPrivateDirectory`](#api-createPrivateDirectory), [`deleteDirectory`](#api-deleteDirectory), [`deleteFile`](#api-deleteFile), [`getDirectoryElements`](#api-getDirectoryElements), [`getPathElement`](#api-getPathElement), [`isDirectory`](#api-isDirectory), [`isFile`](#api-isFile), [`isPathWithin`](#api-isPathWithin), [`pathExists`](#api-pathExists), [`readBoundedFile`](#api-readBoundedFile), [`readBufferFile`](#api-readBufferFile), [`readDirectory`](#api-readDirectory), [`readFile`](#api-readFile), [`readJsonFile`](#api-readJsonFile), [`readPrivateFile`](#api-readPrivateFile), [`readTextFile`](#api-readTextFile), [`writeBufferFile`](#api-writeBufferFile), [`writeFile`](#api-writeFile), [`writeFileAtomic`](#api-writeFileAtomic), [`writeJsonFile`](#api-writeJsonFile), [`writePrivateFile`](#api-writePrivateFile), [`writeTextFile`](#api-writeTextFile) - Constants: [`ERRORS`](#api-ERRORS) - Types: [`IDirectoryElementsKeySort`](#api-IDirectoryElementsKeySort), [`IDirectoryElementsOptions`](#api-IDirectoryElementsOptions), [`IDirectoryPathElements`](#api-IDirectoryPathElements), [`IFileLock`](#api-IFileLock), [`IFileLockOwner`](#api-IFileLockOwner), [`IPathElement`](#api-IPathElement), [`IPrivateFileOptions`](#api-IPrivateFileOptions), [`IReadJsonFileOptions`](#api-IReadJsonFileOptions), [`IReadPrivateFileOptions`](#api-IReadPrivateFileOptions), [`ISchema`](#api-ISchema), [`IWritePrivateFileOptions`](#api-IWritePrivateFileOptions) #### Functions in `jg-fs` ##### `acquireLock` *Function* Acquires a lock that only one process at a time can hold, such as one guarding a script that mustn't run twice at once. The lock is a directory at `path`, created with mode `0o700`, holding an owner.json, mode `0o600`, that names the process. When the lock already exists, it's taken over only from an owner on this host whose process is gone, and only when the directory holds nothing but owner.json. Every other case fails with `LOCK_BUSY`, whose `data.reason` says why: `held`, `other-host`, `ownerless`, `recovery-in-progress`, or `unexpected-entries`. A lock left behind by a crash at the wrong moment stays busy until it's removed by hand, as the README describes. Use it on local filesystems only. POSIX modes don't apply on Windows, and a lock is never taken over from another host. ```ts acquireLock(path: string): Promise ``` **Parameters** - `path` `string`: The lock's directory, whose parent must exist. **Returns** `Promise`: A promise that resolves to the lock, released with `release()`. **Throws** - `LOCK_BUSY`: The lock '' is busy. - `NOT_A_DIRECTORY`: The path '' is not a directory. - `LOCK_ACQUISITION_FAILED`: The lock '' could not be acquired. **Example** ```ts import { acquireLock, writeFileAtomic } from 'jg-fs'; const lock = await acquireLock('.locks/nightly-report'); try { await writeFileAtomic('reports/nightly.json', JSON.stringify({ generatedAt: Date.now() })); } finally { await lock.release(); } ``` ##### `assertPrivateDirectory` *Function* Checks that a directory is private to its owner: a directory that isn't a symbolic link, with no symbolic link anywhere in its path, and, on POSIX, without group or other permissions. On Windows, the permission check is skipped, as for files. ```ts assertPrivateDirectory(path: string): Promise ``` **Parameters** - `path` `string`: The directory's path. **Returns** `Promise`: A promise that resolves once the directory passes every check. **Throws** - `NOT_A_DIRECTORY`: if the path doesn't exist, is a symbolic link, or isn't a directory - `PATH_NOT_CANONICAL`: if the path goes through a symbolic link - `DIRECTORY_NOT_PRIVATE`: if other users can access the directory **Example** ```ts import { assertPrivateDirectory } from 'jg-fs'; await assertPrivateDirectory('/home/ada/.config/app'); ``` ##### `assertPrivateFile` *Function* Checks that a file is private to its owner, such as a key or a session file before it's trusted: a regular file that isn't a symbolic link, with no symbolic link anywhere in its path, and, on POSIX, without group or other permissions. With `rootPath`, it must be strictly inside that directory; with `maxBytes`, no larger. The checks run on the open file, so a file swapped in between them is caught. On Windows, Node.js reports only a file's read-only attribute as its mode, so the permission check is skipped there: a private file on Windows depends on its directory's access control list. ```ts assertPrivateFile(path: string, options?: IPrivateFileOptions): Promise ``` **Parameters** - `path` `string`: The file's path. - `options?` `IPrivateFileOptions`: The root it must be inside, and the largest size accepted, in bytes. Defaults to `{}`. **Returns** `Promise`: A promise that resolves once the file passes every check. **Throws** - `NON_EXISTENT_FILE`: if the file doesn't exist - `NOT_A_FILE`: if the path's final component is a symbolic link, or it isn't a regular file - `FILE_NOT_PRIVATE`: if other users can access the file - `FILE_TOO_LARGE`: if the file holds more than maxBytes bytes - `PATH_NOT_CANONICAL`: if the path or the root goes through a symbolic link - `PATH_OUTSIDE_ROOT`: if the file isn't strictly inside the root - `NOT_A_DIRECTORY`: if the root doesn't exist or isn't a directory **Example** ```ts import { assertPrivateFile } from 'jg-fs'; await assertPrivateFile('/home/ada/.config/app/token.json', { rootPath: '/home/ada/.config/app' }); ``` ##### `copyDirectory` *Function* Copies a directory and everything in it, replacing the destination completely. ```ts copyDirectory(srcPath: string, destPath: string): Promise ``` **Parameters** - `srcPath` `string`: The directory to copy. - `destPath` `string`: Where to copy it. **Returns** `Promise`: A promise that resolves once the copy is complete. **Throws** - `NOT_A_DIRECTORY`: if the srcPath is not a directory **Example** ```ts import { copyDirectory } from 'jg-fs'; await copyDirectory('templates', 'dist/templates'); ``` ##### `copyFile` *Function* Copies a file, replacing the destination when it exists. ```ts copyFile(srcPath: string, destPath: string): Promise ``` **Parameters** - `srcPath` `string`: The file to copy. - `destPath` `string`: Where to copy it. **Returns** `Promise`: A promise that resolves once the copy exists. **Throws** - `NOT_A_FILE`: if the srcPath doesn't exist or is not a file **Example** ```ts import { copyFile } from 'jg-fs'; await copyFile('config.example.json', 'config.json'); ``` ##### `createDirectory` *Function* Creates a directory, with any missing parents. An existing directory is kept, or replaced with an empty one when `deleteIfExists` is set. ```ts createDirectory(path: string, deleteIfExists?: boolean): Promise ``` **Parameters** - `path` `string`: The directory's path. - `deleteIfExists?` `boolean`: Whether to delete an existing directory and its contents first. **Returns** `Promise`: A promise that resolves once the directory exists. **Throws** - `DIRECTORY_ALREADY_EXISTS`: if something other than a directory exists at the path **Example** ```ts import { createDirectory } from 'jg-fs'; await createDirectory('dist/assets'); // creates missing parents too await createDirectory('dist', true); // replaces it with an empty directory ``` ##### `createDirectorySymLink` *Function* Creates a symbolic link to a directory. ```ts createDirectorySymLink(target: string, path: string): Promise ``` **Parameters** - `target` `string`: The directory the link points to. - `path` `string`: Where to create the link. **Returns** `Promise`: A promise that resolves once the link exists. **Throws** - `NON_EXISTENT_DIRECTORY`: if the target directory doesn't exist - `NOT_A_DIRECTORY`: if the target directory is not considered a directory by the OS **Example** ```ts import { createDirectorySymLink } from 'jg-fs'; await createDirectorySymLink('releases/1.4.0', 'current'); ``` ##### `createFileSymLink` *Function* Creates a symbolic link to a file. ```ts createFileSymLink(target: string, path: string): Promise ``` **Parameters** - `target` `string`: The file the link points to. - `path` `string`: Where to create the link. **Returns** `Promise`: A promise that resolves once the link exists. **Throws** - `NON_EXISTENT_FILE`: if the target file does not exist - `NOT_A_FILE`: if the target is not a file **Example** ```ts import { createFileSymLink } from 'jg-fs'; await createFileSymLink('config.production.json', 'config.json'); ``` ##### `createPrivateDirectory` *Function* Creates a directory, and any missing parents, with `0o700` permissions, and checks that it's private, as `assertPrivateDirectory` does. An existing directory that isn't private throws: its permissions are never changed silently. ```ts createPrivateDirectory(path: string): Promise ``` **Parameters** - `path` `string`: The directory's path. **Returns** `Promise`: A promise that resolves once the directory exists and is private. **Throws** - `NOT_A_DIRECTORY`: if the path exists and isn't a directory - `PATH_NOT_CANONICAL`: if the path goes through a symbolic link - `DIRECTORY_NOT_PRIVATE`: if other users can access the directory **Example** ```ts import { createPrivateDirectory } from 'jg-fs'; await createPrivateDirectory('/home/ada/.config/app/sessions'); ``` ##### `deleteDirectory` *Function* Deletes a directory and everything in it. A missing directory is not an error. ```ts deleteDirectory(path: string): Promise ``` **Parameters** - `path` `string`: The directory's path. **Returns** `Promise`: A promise that resolves once the directory is gone. **Example** ```ts import { deleteDirectory } from 'jg-fs'; await deleteDirectory('dist'); ``` ##### `deleteFile` *Function* Deletes a file. ```ts deleteFile(path: string): Promise ``` **Parameters** - `path` `string`: The file's path. **Returns** `Promise`: A promise that resolves once the file is gone. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file **Example** ```ts import { deleteFile } from 'jg-fs'; await deleteFile('cache.json'); ``` ##### `getDirectoryElements` *Function* Lists a directory's elements grouped by kind: directories, files (filtered by extension), and symbolic links, each group sorted. Entries removed while the directory is read are left out, and at most 32 entries are read at once. ```ts getDirectoryElements( path: string, options?: Partial, ): Promise ``` **Parameters** - `path` `string`: The directory's path. - `options?` `Partial`: The extensions to include and how to sort. **Returns** `Promise`: A promise that resolves to the grouped elements. **Throws** - `NOT_A_DIRECTORY`: if the path doesn't exist or is not a directory **Example** ```ts import { getDirectoryElements } from 'jg-fs'; const { files } = await getDirectoryElements('content', { includeExts: ['.md'], sortByKey: 'creation', sortOrder: 'desc' }); ``` ##### `getPathElement` *Function* Reads a path's details without following a symbolic link at the path. ```ts getPathElement(path: string): Promise ``` **Parameters** - `path` `string`: The path. **Returns** `Promise`: A promise that resolves to the path's details, or `null` when it doesn't exist. **Example** ```ts import { getPathElement } from 'jg-fs'; const element = await getPathElement('logo.png'); element?.size; // the size in bytes, or undefined when the path doesn't exist ``` ##### `isDirectory` *Function* Checks whether a path exists and is a directory. ```ts isDirectory(path: string): Promise ``` **Parameters** - `path` `string`: The path. **Returns** `Promise`: A promise that resolves to whether the path is a directory. **Example** ```ts import { isDirectory } from 'jg-fs'; await isDirectory('dist'); // true ``` ##### `isFile` *Function* Checks whether a path exists and is a regular file. ```ts isFile(path: string): Promise ``` **Parameters** - `path` `string`: The path. **Returns** `Promise`: A promise that resolves to whether the path is a file. **Example** ```ts import { isFile } from 'jg-fs'; await isFile('package.json'); // true ``` ##### `isPathWithin` *Function* Checks whether a path is a root path or nested inside it. The check is lexical: both paths are resolved without touching the filesystem, and symbolic links aren't followed. Sibling paths that share a prefix, such as `/data/app` and `/data/application`, aren't nested, and neither is a path on another drive on Windows. ```ts isPathWithin(rootPath: string, targetPath: string): boolean ``` **Parameters** - `rootPath` `string`: The root path. - `targetPath` `string`: The path to check. **Returns** `boolean`: Whether the target is the root or inside it. **Example** ```ts import { isPathWithin } from 'jg-fs'; isPathWithin('/data/app', '/data/app/logs/today.log'); // true isPathWithin('/data/app', '/data/app/../secrets'); // false isPathWithin('/data/app', '/data/application'); // false ``` ##### `pathExists` *Function* Checks whether a path exists, whether it's a file, a directory, or anything else. ```ts pathExists(path: string): Promise ``` **Parameters** - `path` `string`: The path. **Returns** `Promise`: A promise that resolves to whether the path exists. **Example** ```ts import { pathExists } from 'jg-fs'; await pathExists('config.json'); // true when the file exists ``` ##### `readBoundedFile` *Function* Reads a regular file of at most `maxBytes` bytes. It never follows a symbolic link in the path's last component, though links in parent directories are followed, so callers that need containment combine it with `isPathWithin`. It reads at most `maxBytes + 1` bytes, even when the file grows while it's read. ```ts readBoundedFile( path: string, maxBytes: number, ): Promise> ``` **Parameters** - `path` `string`: The file's path. - `maxBytes` `number`: The largest size accepted, in bytes. **Returns** `Promise>`: A promise that resolves to the content. **Throws** - `NOT_A_FILE`: if the path doesn't exist, its final component is a symbolic link, or it isn't a regular file - `FILE_TOO_LARGE`: if the file holds more than maxBytes bytes **Example** ```ts import { join } from 'node:path'; import { readBoundedFile } from 'jg-fs'; const upload = await readBoundedFile(join('uploads', 'avatar.png'), 5 * 1024 * 1024); // at most 5 MB ``` ##### `readBufferFile` *Function* Reads a binary file. An empty file reads as an empty Buffer. ```ts readBufferFile(path: string): Promise> ``` **Parameters** - `path` `string`: The file's path. **Returns** `Promise>`: A promise that resolves to the content. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file **Example** ```ts import { readBufferFile } from 'jg-fs'; const image = await readBufferFile('avatar.png'); ``` ##### `readDirectory` *Function* Lists a directory's contents as paths joined to the directory's path. ```ts readDirectory(path: string, recursive?: boolean): Promise ``` **Parameters** - `path` `string`: The directory's path. - `recursive?` `boolean`: Whether to include the contents of subdirectories. Defaults to `false`. **Returns** `Promise`: A promise that resolves to the content paths. **Throws** - `NOT_A_DIRECTORY`: if the path doesn't exist or is not a directory **Example** ```ts import { readDirectory } from 'jg-fs'; await readDirectory('dist'); // ['dist/index.js', 'dist/assets'] await readDirectory('dist', true); // includes the contents of dist/assets ``` ##### `readFile` *Function* Reads a file as a string when an encoding is given, or as a Buffer otherwise. ```ts readFile(path: string, options: IReadStringFileOptions): Promise ``` **Parameters** - `path` `string`: The file's path. - `options` `IReadStringFileOptions`: Node.js's readFile options, such as the encoding. **Returns** `Promise`: A promise that resolves to the content. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file ```ts readFile( path: string, options?: IReadBufferFileOptions, ): Promise> ``` **Parameters** - `path` `string`: The file's path. - `options?` `IReadBufferFileOptions`: Node.js's readFile options, such as the encoding. **Returns** `Promise>`: A promise that resolves to the content. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file ```ts readFile( path: string, options?: IReadFileOptions, ): Promise> ``` **Parameters** - `path` `string`: The file's path. - `options?` `IReadFileOptions`: Node.js's readFile options, such as the encoding. **Returns** `Promise>`: A promise that resolves to the content. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file **Example** ```ts import { readFile } from 'jg-fs'; await readFile('notes.md', 'utf-8'); // a string await readFile('avatar.png'); // a Buffer ``` ##### `readJsonFile` *Function* Reads and parses a JSON file, and validates it with the schema when there is one. Without a schema, the value is `unknown`, or the type you pass. With `maxBytes`, it reads the file as readBoundedFile does, for a file whose size isn't yours to decide. ```ts readJsonFile( path: string, options?: IReadJsonFileOptions, ): Promise ``` **Parameters** - `path` `string`: The file's path. - `options?` `IReadJsonFileOptions`: The schema, such as a Zod schema, whose errors propagate unchanged, and the largest file accepted, in bytes. Defaults to `{}`. **Returns** `Promise`: A promise that resolves to the parsed value. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file - `FILE_TOO_LARGE`: if the file holds more than maxBytes bytes - `FILE_CONTENT_IS_EMPTY_OR_INVALID`: if the file's JSON content cannot be parsed **Example 1** ```ts import { readJsonFile } from 'jg-fs'; const config = await readJsonFile('config.json', { schema: Config }); // Config's type ``` **Example 2** ```ts import { readJsonFile } from 'jg-fs'; const value = await readJsonFile('data.json'); // unknown ``` ##### `readPrivateFile` *Function* Reads a file after checking that it's private, as `assertPrivateFile` does, through the same open file, so the content read is the content checked. It reads at most `maxBytes` bytes, even when the file grows while it's read. ```ts readPrivateFile( path: string, options: IReadPrivateFileOptions, ): Promise> ``` **Parameters** - `path` `string`: The file's path. - `options` `IReadPrivateFileOptions`: The largest size accepted, in bytes, and the root the file must be inside. **Returns** `Promise>`: A promise that resolves to the content. **Throws** - `NON_EXISTENT_FILE`: if the file doesn't exist - `NOT_A_FILE`: if the path's final component is a symbolic link, or it isn't a regular file - `FILE_NOT_PRIVATE`: if other users can access the file - `FILE_TOO_LARGE`: if the file holds more than maxBytes bytes - `PATH_NOT_CANONICAL`: if the path or the root goes through a symbolic link - `PATH_OUTSIDE_ROOT`: if the file isn't strictly inside the root - `NOT_A_DIRECTORY`: if the root doesn't exist or isn't a directory **Example** ```ts import { readPrivateFile } from 'jg-fs'; const key = await readPrivateFile('/home/ada/.ssh/id_ed25519', { maxBytes: 16 * 1024 }); ``` ##### `readTextFile` *Function* Reads a UTF-8 text file. An empty file reads as an empty string. ```ts readTextFile(path: string): Promise ``` **Parameters** - `path` `string`: The file's path. **Returns** `Promise`: A promise that resolves to the text. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file **Example** ```ts import { readTextFile } from 'jg-fs'; const markdown = await readTextFile('README.md'); ``` ##### `writeBufferFile` *Function* Writes a binary file. An empty Buffer writes an empty file. ```ts writeBufferFile(path: string, data: Buffer): Promise ``` **Parameters** - `path` `string`: The file's path. - `data` `Buffer`: The content. **Returns** `Promise`: A promise that resolves once the file is written. **Throws** - `FILE_CONTENT_IS_EMPTY_OR_INVALID`: if the data isn't a Buffer **Example** ```ts import { writeBufferFile } from 'jg-fs'; const imageBuffer = Buffer.from('iVBORw0KGgo=', 'base64'); await writeBufferFile('avatar.png', imageBuffer); ``` ##### `writeFile` *Function* Writes a file, creating its directory first when it doesn't exist. ```ts writeFile( path: string, data: string | ArrayBufferView, options?: WriteFileOptions, ): Promise ``` **Parameters** - `path` `string`: The file's path. - `data` `string | ArrayBufferView`: The content. - `options?` `WriteFileOptions`: Node.js's writeFile options, such as the encoding or mode. **Returns** `Promise`: A promise that resolves once the file is written. **Example** ```ts import { writeFile } from 'jg-fs'; const csv = 'id,name\n1,Ada\n'; await writeFile('out/report.csv', csv, { encoding: 'utf-8' }); ``` ##### `writeFileAtomic` *Function* Writes a file atomically: readers see either the previous content or the complete new content, never a partial write. The data goes to a temporary file in the same directory, which is flushed to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins; without one, a replaced file keeps its permission bits and a new file gets the platform default. The temporary file is never more widely readable than the final mode. When writing fails before the rename, the temporary file is removed and the destination is untouched. While Windows refuses the rename because another process, such as an antivirus scanner, holds the file, it tries again for about 4 seconds. A symbolic link at the path is replaced by the file. ```ts writeFileAtomic( path: string, data: string | ArrayBufferView, mode?: number, ): Promise ``` **Parameters** - `path` `string`: The file's path. - `data` `string | ArrayBufferView`: The content. - `mode?` `number`: The permission bits of the written file, such as `0o600`. **Returns** `Promise`: A promise that resolves once the file is written and flushed. **Example** ```ts import { writeFileAtomic } from 'jg-fs'; const state = { step: 2 }; await writeFileAtomic('state.json', JSON.stringify(state)); // readers never see a partial file await writeFileAtomic('secrets.env', 'TOKEN=abc\n', 0o600); // only the owner can read it ``` ##### `writeJsonFile` *Function* Writes a JSON file atomically, as writeFileAtomic does, so a crash or a full disk never leaves a truncated file that won't parse. A symbolic link at the path keeps pointing at the file, whose target is replaced. A string is written as given; anything else is stringified. ```ts writeJsonFile>( path: string, data: string | T, space?: number, ): Promise ``` **Parameters** - `path` `string`: The file's path. - `data` `string | T`: The value, or its JSON text. - `space?` `number`: The indentation for stringified values. Defaults to `2`. **Returns** `Promise`: A promise that resolves once the file is written. **Throws** - `FILE_CONTENT_IS_EMPTY_OR_INVALID`: if the JSON content cannot be stringified or is empty **Example** ```ts import { writeJsonFile } from 'jg-fs'; await writeJsonFile('config.json', { port: 8080 }); ``` ##### `writePrivateFile` *Function* Writes a file that only its owner can read and write, such as a token or a session. The file's directory must have no symbolic link in its path, and with `rootPath`, the file must be strictly inside that directory. By default, it replaces the file atomically with `0o600` permissions, through a temporary file that's never more widely readable; a file already at the path must be private, as `assertPrivateFile` checks, so a symbolic link or a shared file is never overwritten. With `isExclusive`, it creates the file and fails when anything is already at the path. ```ts writePrivateFile( path: string, data: string | ArrayBufferView, options?: IWritePrivateFileOptions, ): Promise ``` **Parameters** - `path` `string`: The file's path. - `data` `string | ArrayBufferView`: The content. - `options?` `IWritePrivateFileOptions`: The root the file must be inside, and whether to create it exclusively. Defaults to `{}`. **Returns** `Promise`: A promise that resolves once the file is written. **Throws** - `FILE_ALREADY_EXISTS`: if isExclusive is set and anything already exists at the path - `NOT_A_FILE`: if the path's final component is a symbolic link, or it isn't a regular file - `FILE_NOT_PRIVATE`: if other users can access the file at the path, or the file written - `PATH_NOT_CANONICAL`: if the file's directory or the root goes through a symbolic link - `PATH_OUTSIDE_ROOT`: if the file isn't strictly inside the root - `NOT_A_DIRECTORY`: if the root or the file's directory doesn't exist or isn't a directory **Example** ```ts import { writePrivateFile } from 'jg-fs'; await writePrivateFile('/home/ada/.config/app/token.json', '{"token":"…"}'); ``` ##### `writeTextFile` *Function* Writes a UTF-8 text file. An empty string writes an empty file. ```ts writeTextFile(path: string, data: string): Promise ``` **Parameters** - `path` `string`: The file's path. - `data` `string`: The text. **Returns** `Promise`: A promise that resolves once the file is written. **Throws** - `FILE_CONTENT_IS_EMPTY_OR_INVALID`: if the data isn't a string **Example** ```ts import { writeTextFile } from 'jg-fs'; await writeTextFile('notes/today.md', '# Today'); ``` #### Constants in `jg-fs` ##### `ERRORS` *Constant* the codes of every error the package throws, from both the async and the sync entry ```ts const ERRORS: { NOT_A_DIRECTORY: "NOT_A_DIRECTORY"; NON_EXISTENT_DIRECTORY: "NON_EXISTENT_DIRECTORY"; DIRECTORY_ALREADY_EXISTS: "DIRECTORY_ALREADY_EXISTS"; NON_EXISTENT_FILE: "NON_EXISTENT_FILE"; NOT_A_FILE: "NOT_A_FILE"; FILE_CONTENT_IS_EMPTY_OR_INVALID: "FILE_CONTENT_IS_EMPTY_OR_INVALID"; FILE_TOO_LARGE: "FILE_TOO_LARGE"; LOCK_BUSY: "LOCK_BUSY"; LOCK_NOT_OWNED: "LOCK_NOT_OWNED"; LOCK_RELEASE_FAILED: "LOCK_RELEASE_FAILED"; LOCK_ACQUISITION_FAILED: "LOCK_ACQUISITION_FAILED"; FILE_NOT_PRIVATE: "FILE_NOT_PRIVATE"; DIRECTORY_NOT_PRIVATE: "DIRECTORY_NOT_PRIVATE"; PATH_NOT_CANONICAL: "PATH_NOT_CANONICAL"; PATH_OUTSIDE_ROOT: "PATH_OUTSIDE_ROOT"; FILE_ALREADY_EXISTS: "FILE_ALREADY_EXISTS" } ``` #### Types in `jg-fs` ##### `IDirectoryElementsKeySort` *Type* the keys a directory's elements can be sorted by ```ts type IDirectoryElementsKeySort = 'baseName' | 'size' | 'creation'; ``` ##### `IDirectoryElementsOptions` *Type* how getDirectoryElements filters and sorts a directory's elements ```ts type IDirectoryElementsOptions = { sortByKey: IDirectoryElementsKeySort; sortOrder: ISortDirection; includeExts: string[]; }; ``` **Members** - `sortByKey: IDirectoryElementsKeySort` (property): the key the elements are sorted by. Defaults to 'baseName' - `sortOrder: ISortDirection` (property): the sort direction. Defaults to 'asc' - `includeExts: string[]` (property): the extensions of the files to include, such as ['.json'], matched case-insensitively. Defaults to all ##### `IDirectoryPathElements` *Type* a directory's elements, grouped by kind ```ts type IDirectoryPathElements = { directories: IPathElement[]; files: IPathElement[]; symbolicLinks: IPathElement[]; }; ``` **Members** - `directories: IPathElement[]` (property) - `files: IPathElement[]` (property) - `symbolicLinks: IPathElement[]` (property) ##### `IFileLock` *Type* a lock acquireLock holds until it's released ```ts type IFileLock = { path: string; owner: IFileLockOwner; release: () => Promise; }; ``` **Members** - `path: string` (property): the lock's directory - `owner: IFileLockOwner` (property): the process that holds it, as its owner.json names it - `release: () => Promise` (property): removes the lock; calling it again after it succeeded does nothing ##### `IFileLockOwner` *Type* the process that holds a lock, as its lock directory's owner.json names it ```ts type IFileLockOwner = { pid: number; nonce: string; hostname: string; }; ``` **Members** - `pid: number` (property): the process id - `nonce: string` (property): a random id that tells this acquisition apart from any other by the same process id - `hostname: string` (property): the host the process runs on; a lock is never recovered from another host ##### `IPathElement` *Type* the details of a path, read with lstat, so a symbolic link is described rather than followed ```ts type IPathElement = { path: string; baseName: string; extName: string; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean; size: number; creation: number; }; ``` **Members** - `path: string` (property): the path, as given - `baseName: string` (property): the last segment of the path - `extName: string` (property): the extension, such as '.json', or an empty string when there is none - `isFile: boolean` (property): whether it's a regular file - `isDirectory: boolean` (property): whether it's a directory - `isSymbolicLink: boolean` (property): whether it's a symbolic link, in which case isFile and isDirectory are false - `size: number` (property): the size in bytes - `creation: number` (property): when it was created, in milliseconds since the epoch ##### `IPrivateFileOptions` *Type* the options assertPrivateFile takes ```ts type IPrivateFileOptions = { rootPath?: string | undefined; maxBytes?: number | undefined; }; ``` **Members** - `rootPath: string` (property): a directory the file must be strictly inside; it must exist and contain no symbolic link - `maxBytes: number` (property): the largest size accepted, in bytes ##### `IReadJsonFileOptions` *Type* what readJsonFile accepts ```ts type IReadJsonFileOptions = { schema?: ISchema; maxBytes?: number; }; ``` **Members** - `schema: ISchema` (property): validates the parsed content, such as a Zod schema. Its errors propagate unchanged - `maxBytes: number` (property): the largest file accepted, in bytes, for a file whose size isn't yours to decide, such as one a user uploaded. With it, the file is read as readBoundedFile reads it ##### `IReadPrivateFileOptions` *Type* the options readPrivateFile takes ```ts type IReadPrivateFileOptions = { maxBytes: number; rootPath?: string | undefined; }; ``` **Members** - `maxBytes: number` (property): the largest size accepted, in bytes; it's required, because the file's size isn't the reader's - `rootPath: string` (property): a directory the file must be strictly inside; it must exist and contain no symbolic link ##### `ISchema` *Type* validates parsed data and returns it typed, which a Zod schema does ```ts type ISchema = { parse(value: unknown): T; }; ``` **Members** - `parse(value: unknown): T` (method) ##### `IWritePrivateFileOptions` *Type* the options writePrivateFile takes ```ts type IWritePrivateFileOptions = { rootPath?: string | undefined; isExclusive?: boolean | undefined; }; ``` **Members** - `rootPath: string` (property): a directory the file must be strictly inside; it must exist and contain no symbolic link - `isExclusive: boolean` (property): creates the file, and fails when anything already exists at the path, instead of replacing it ### `jg-fs/sync` - Functions: [`acquireLock`](#api-sync-acquireLock), [`assertPrivateDirectory`](#api-sync-assertPrivateDirectory), [`assertPrivateFile`](#api-sync-assertPrivateFile), [`copyDirectory`](#api-sync-copyDirectory), [`copyFile`](#api-sync-copyFile), [`createDirectory`](#api-sync-createDirectory), [`createDirectorySymLink`](#api-sync-createDirectorySymLink), [`createFileSymLink`](#api-sync-createFileSymLink), [`createPrivateDirectory`](#api-sync-createPrivateDirectory), [`deleteDirectory`](#api-sync-deleteDirectory), [`deleteFile`](#api-sync-deleteFile), [`getDirectoryElements`](#api-sync-getDirectoryElements), [`getPathElement`](#api-sync-getPathElement), [`isDirectory`](#api-sync-isDirectory), [`isFile`](#api-sync-isFile), [`isPathWithin`](#api-sync-isPathWithin), [`pathExists`](#api-sync-pathExists), [`readBoundedFile`](#api-sync-readBoundedFile), [`readBufferFile`](#api-sync-readBufferFile), [`readDirectory`](#api-sync-readDirectory), [`readFile`](#api-sync-readFile), [`readJsonFile`](#api-sync-readJsonFile), [`readPrivateFile`](#api-sync-readPrivateFile), [`readTextFile`](#api-sync-readTextFile), [`writeBufferFile`](#api-sync-writeBufferFile), [`writeFile`](#api-sync-writeFile), [`writeFileAtomic`](#api-sync-writeFileAtomic), [`writeJsonFile`](#api-sync-writeJsonFile), [`writePrivateFile`](#api-sync-writePrivateFile), [`writeTextFile`](#api-sync-writeTextFile) - Constants: [`ERRORS`](#api-sync-ERRORS) - Types: [`IDirectoryElementsKeySort`](#api-sync-IDirectoryElementsKeySort), [`IDirectoryElementsOptions`](#api-sync-IDirectoryElementsOptions), [`IDirectoryPathElements`](#api-sync-IDirectoryPathElements), [`IFileLock`](#api-sync-IFileLock), [`IFileLockOwner`](#api-sync-IFileLockOwner), [`IPathElement`](#api-sync-IPathElement), [`IPrivateFileOptions`](#api-sync-IPrivateFileOptions), [`IReadJsonFileOptions`](#api-sync-IReadJsonFileOptions), [`IReadPrivateFileOptions`](#api-sync-IReadPrivateFileOptions), [`ISchema`](#api-sync-ISchema), [`IWritePrivateFileOptions`](#api-sync-IWritePrivateFileOptions) #### Functions in `jg-fs/sync` ##### `acquireLock` *Function* Acquires a lock that only one process at a time can hold, such as one guarding a script that mustn't run twice at once. The lock is a directory at `path`, created with mode `0o700`, holding an owner.json, mode `0o600`, that names the process. When the lock already exists, it's taken over only from an owner on this host whose process is gone, and only when the directory holds nothing but owner.json. Every other case fails with `LOCK_BUSY`, whose `data.reason` says why: `held`, `other-host`, `ownerless`, `recovery-in-progress`, or `unexpected-entries`. A lock left behind by a crash at the wrong moment stays busy until it's removed by hand, as the README describes. Use it on local filesystems only. POSIX modes don't apply on Windows, and a lock is never taken over from another host. ```ts acquireLock(path: string): IFileLock ``` **Parameters** - `path` `string`: The lock's directory, whose parent must exist. **Returns** `IFileLock`: The lock, released with `release()`. **Throws** - `LOCK_BUSY`: The lock '' is busy. - `NOT_A_DIRECTORY`: The path '' is not a directory. - `LOCK_ACQUISITION_FAILED`: The lock '' could not be acquired. **Example** ```ts import { acquireLock, writeFileAtomic } from 'jg-fs/sync'; const lock = acquireLock('.locks/nightly-report'); try { writeFileAtomic('reports/nightly.json', JSON.stringify({ generatedAt: Date.now() })); } finally { lock.release(); } ``` ##### `assertPrivateDirectory` *Function* Checks that a directory is private to its owner: a directory that isn't a symbolic link, with no symbolic link anywhere in its path, and, on POSIX, without group or other permissions. On Windows, the permission check is skipped, as for files. ```ts assertPrivateDirectory(path: string): void ``` **Parameters** - `path` `string`: The directory's path. **Throws** - `NOT_A_DIRECTORY`: if the path doesn't exist, is a symbolic link, or isn't a directory - `PATH_NOT_CANONICAL`: if the path goes through a symbolic link - `DIRECTORY_NOT_PRIVATE`: if other users can access the directory **Example** ```ts import { assertPrivateDirectory } from 'jg-fs/sync'; assertPrivateDirectory('/home/ada/.config/app'); ``` ##### `assertPrivateFile` *Function* Checks that a file is private to its owner, such as a key or a session file before it's trusted: a regular file that isn't a symbolic link, with no symbolic link anywhere in its path, and, on POSIX, without group or other permissions. With `rootPath`, it must be strictly inside that directory; with `maxBytes`, no larger. The checks run on the open file, so a file swapped in between them is caught. On Windows, Node.js reports only a file's read-only attribute as its mode, so the permission check is skipped there: a private file on Windows depends on its directory's access control list. ```ts assertPrivateFile(path: string, options?: IPrivateFileOptions): void ``` **Parameters** - `path` `string`: The file's path. - `options?` `IPrivateFileOptions`: The root it must be inside, and the largest size accepted, in bytes. Defaults to `{}`. **Throws** - `NON_EXISTENT_FILE`: if the file doesn't exist - `NOT_A_FILE`: if the path's final component is a symbolic link, or it isn't a regular file - `FILE_NOT_PRIVATE`: if other users can access the file - `FILE_TOO_LARGE`: if the file holds more than maxBytes bytes - `PATH_NOT_CANONICAL`: if the path or the root goes through a symbolic link - `PATH_OUTSIDE_ROOT`: if the file isn't strictly inside the root - `NOT_A_DIRECTORY`: if the root doesn't exist or isn't a directory **Example** ```ts import { assertPrivateFile } from 'jg-fs/sync'; assertPrivateFile('/home/ada/.config/app/token.json', { rootPath: '/home/ada/.config/app' }); ``` ##### `copyDirectory` *Function* Copies a directory and everything in it, replacing the destination completely. ```ts copyDirectory(srcPath: string, destPath: string): void ``` **Parameters** - `srcPath` `string`: The directory to copy. - `destPath` `string`: Where to copy it. **Throws** - `NOT_A_DIRECTORY`: if the srcPath is not a directory **Example** ```ts import { copyDirectory } from 'jg-fs/sync'; copyDirectory('templates', 'dist/templates'); ``` ##### `copyFile` *Function* Copies a file, replacing the destination when it exists. ```ts copyFile(srcPath: string, destPath: string): void ``` **Parameters** - `srcPath` `string`: The file to copy. - `destPath` `string`: Where to copy it. **Throws** - `NOT_A_FILE`: if the srcPath doesn't exist or is not a file **Example** ```ts import { copyFile } from 'jg-fs/sync'; copyFile('config.example.json', 'config.json'); ``` ##### `createDirectory` *Function* Creates a directory, with any missing parents. An existing directory is kept, or replaced with an empty one when `deleteIfExists` is set. ```ts createDirectory(path: string, deleteIfExists?: boolean): void ``` **Parameters** - `path` `string`: The directory's path. - `deleteIfExists?` `boolean`: Whether to delete an existing directory and its contents first. **Throws** - `DIRECTORY_ALREADY_EXISTS`: if something other than a directory exists at the path **Example** ```ts import { createDirectory } from 'jg-fs/sync'; createDirectory('dist/assets'); // creates missing parents too createDirectory('dist', true); // replaces it with an empty directory ``` ##### `createDirectorySymLink` *Function* Creates a symbolic link to a directory. ```ts createDirectorySymLink(target: string, path: string): void ``` **Parameters** - `target` `string`: The directory the link points to. - `path` `string`: Where to create the link. **Throws** - `NON_EXISTENT_DIRECTORY`: if the target directory doesn't exist - `NOT_A_DIRECTORY`: if the target directory is not considered a directory by the OS **Example** ```ts import { createDirectorySymLink } from 'jg-fs/sync'; createDirectorySymLink('releases/1.4.0', 'current'); ``` ##### `createFileSymLink` *Function* Creates a symbolic link to a file. ```ts createFileSymLink(target: string, path: string): void ``` **Parameters** - `target` `string`: The file the link points to. - `path` `string`: Where to create the link. **Throws** - `NON_EXISTENT_FILE`: if the target file does not exist - `NOT_A_FILE`: if the target is not a file **Example** ```ts import { createFileSymLink } from 'jg-fs/sync'; createFileSymLink('config.production.json', 'config.json'); ``` ##### `createPrivateDirectory` *Function* Creates a directory, and any missing parents, with `0o700` permissions, and checks that it's private, as `assertPrivateDirectory` does. An existing directory that isn't private throws: its permissions are never changed silently. ```ts createPrivateDirectory(path: string): void ``` **Parameters** - `path` `string`: The directory's path. **Throws** - `NOT_A_DIRECTORY`: if the path exists and isn't a directory - `PATH_NOT_CANONICAL`: if the path goes through a symbolic link - `DIRECTORY_NOT_PRIVATE`: if other users can access the directory **Example** ```ts import { createPrivateDirectory } from 'jg-fs/sync'; createPrivateDirectory('/home/ada/.config/app/sessions'); ``` ##### `deleteDirectory` *Function* Deletes a directory and everything in it. A missing directory is not an error. ```ts deleteDirectory(path: string): void ``` **Parameters** - `path` `string`: The directory's path. **Example** ```ts import { deleteDirectory } from 'jg-fs/sync'; deleteDirectory('dist'); ``` ##### `deleteFile` *Function* Deletes a file. ```ts deleteFile(path: string): void ``` **Parameters** - `path` `string`: The file's path. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file **Example** ```ts import { deleteFile } from 'jg-fs/sync'; deleteFile('cache.json'); ``` ##### `getDirectoryElements` *Function* Lists a directory's elements grouped by kind: directories, files (filtered by extension), and symbolic links, each group sorted. Entries removed while the directory is read are left out. ```ts getDirectoryElements( path: string, options?: Partial, ): IDirectoryPathElements ``` **Parameters** - `path` `string`: The directory's path. - `options?` `Partial`: The extensions to include and how to sort. **Returns** `IDirectoryPathElements`: The grouped elements. **Throws** - `NOT_A_DIRECTORY`: if the path doesn't exist or is not a directory **Example** ```ts import { getDirectoryElements } from 'jg-fs/sync'; const { files } = getDirectoryElements('content', { includeExts: ['.md'], sortByKey: 'creation', sortOrder: 'desc' }); ``` ##### `getPathElement` *Function* Reads a path's details without following a symbolic link at the path. ```ts getPathElement(path: string): IPathElement | null ``` **Parameters** - `path` `string`: The path. **Returns** `IPathElement | null`: The path's details, or `null` when it doesn't exist. **Example** ```ts import { getPathElement } from 'jg-fs/sync'; const element = getPathElement('logo.png'); element?.size; // the size in bytes, or undefined when the path doesn't exist ``` ##### `isDirectory` *Function* Checks whether a path exists and is a directory. ```ts isDirectory(path: string): boolean ``` **Parameters** - `path` `string`: The path. **Returns** `boolean`: Whether the path is a directory. **Example** ```ts import { isDirectory } from 'jg-fs/sync'; isDirectory('dist'); // true ``` ##### `isFile` *Function* Checks whether a path exists and is a regular file. ```ts isFile(path: string): boolean ``` **Parameters** - `path` `string`: The path. **Returns** `boolean`: Whether the path is a file. **Example** ```ts import { isFile } from 'jg-fs/sync'; isFile('package.json'); // true ``` ##### `isPathWithin` *Function* Checks whether a path is a root path or nested inside it. The check is lexical: both paths are resolved without touching the filesystem, and symbolic links aren't followed. Sibling paths that share a prefix, such as `/data/app` and `/data/application`, aren't nested, and neither is a path on another drive on Windows. ```ts isPathWithin(rootPath: string, targetPath: string): boolean ``` **Parameters** - `rootPath` `string`: The root path. - `targetPath` `string`: The path to check. **Returns** `boolean`: Whether the target is the root or inside it. **Example** ```ts import { isPathWithin } from 'jg-fs'; isPathWithin('/data/app', '/data/app/logs/today.log'); // true isPathWithin('/data/app', '/data/app/../secrets'); // false isPathWithin('/data/app', '/data/application'); // false ``` ##### `pathExists` *Function* Checks whether a path exists, whether it's a file, a directory, or anything else. ```ts pathExists(path: string): boolean ``` **Parameters** - `path` `string`: The path. **Returns** `boolean`: Whether the path exists. **Example** ```ts import { pathExists } from 'jg-fs/sync'; pathExists('config.json'); // true when the file exists ``` ##### `readBoundedFile` *Function* Reads a regular file of at most `maxBytes` bytes. It never follows a symbolic link in the path's last component, though links in parent directories are followed, so callers that need containment combine it with `isPathWithin`. It reads at most `maxBytes + 1` bytes, even when the file grows while it's read. ```ts readBoundedFile(path: string, maxBytes: number): Buffer ``` **Parameters** - `path` `string`: The file's path. - `maxBytes` `number`: The largest size accepted, in bytes. **Returns** `Buffer`: The content. **Throws** - `NOT_A_FILE`: if the path doesn't exist, its final component is a symbolic link, or it isn't a regular file - `FILE_TOO_LARGE`: if the file holds more than maxBytes bytes **Example** ```ts import { join } from 'node:path'; import { readBoundedFile } from 'jg-fs/sync'; const upload = readBoundedFile(join('uploads', 'avatar.png'), 5 * 1024 * 1024); // at most 5 MB ``` ##### `readBufferFile` *Function* Reads a binary file. An empty file reads as an empty Buffer. ```ts readBufferFile(path: string): Buffer ``` **Parameters** - `path` `string`: The file's path. **Returns** `Buffer`: The content. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file **Example** ```ts import { readBufferFile } from 'jg-fs/sync'; const image = readBufferFile('avatar.png'); ``` ##### `readDirectory` *Function* Lists a directory's contents as paths joined to the directory's path. ```ts readDirectory(path: string, recursive?: boolean): string[] ``` **Parameters** - `path` `string`: The directory's path. - `recursive?` `boolean`: Whether to include the contents of subdirectories. Defaults to `false`. **Returns** `string[]`: The content paths. **Throws** - `NOT_A_DIRECTORY`: if the path doesn't exist or is not a directory **Example** ```ts import { readDirectory } from 'jg-fs/sync'; readDirectory('dist'); // ['dist/index.js', 'dist/assets'] readDirectory('dist', true); // includes the contents of dist/assets ``` ##### `readFile` *Function* Reads a file as a string when an encoding is given, or as a Buffer otherwise. ```ts readFile(path: string, options: IReadStringFileOptions): string ``` **Parameters** - `path` `string`: The file's path. - `options` `IReadStringFileOptions`: Node.js's readFile options, such as the encoding. **Returns** `string`: The content. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file ```ts readFile(path: string, options?: IReadBufferFileOptions): Buffer ``` **Parameters** - `path` `string`: The file's path. - `options?` `IReadBufferFileOptions`: Node.js's readFile options, such as the encoding. **Returns** `Buffer`: The content. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file ```ts readFile( path: string, options?: IReadFileOptions, ): string | Buffer ``` **Parameters** - `path` `string`: The file's path. - `options?` `IReadFileOptions`: Node.js's readFile options, such as the encoding. **Returns** `string | Buffer`: The content. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file **Example** ```ts import { readFile } from 'jg-fs/sync'; readFile('notes.md', 'utf-8'); // a string readFile('avatar.png'); // a Buffer ``` ##### `readJsonFile` *Function* Reads and parses a JSON file, and validates it with the schema when there is one. Without a schema, the value is `unknown`, or the type you pass. With `maxBytes`, it reads the file as readBoundedFile does, for a file whose size isn't yours to decide. ```ts readJsonFile(path: string, options?: IReadJsonFileOptions): T ``` **Parameters** - `path` `string`: The file's path. - `options?` `IReadJsonFileOptions`: The schema, such as a Zod schema, whose errors propagate unchanged, and the largest file accepted, in bytes. Defaults to `{}`. **Returns** `T`: The parsed value. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file - `FILE_TOO_LARGE`: if the file holds more than maxBytes bytes - `FILE_CONTENT_IS_EMPTY_OR_INVALID`: if the file's JSON content cannot be parsed **Example 1** ```ts import { readJsonFile } from 'jg-fs/sync'; const config = readJsonFile('config.json', { schema: Config }); // Config's type ``` **Example 2** ```ts import { readJsonFile } from 'jg-fs/sync'; const value = readJsonFile('data.json'); // unknown ``` ##### `readPrivateFile` *Function* Reads a file after checking that it's private, as `assertPrivateFile` does, through the same open file, so the content read is the content checked. It reads at most `maxBytes` bytes, even when the file grows while it's read. ```ts readPrivateFile(path: string, options: IReadPrivateFileOptions): Buffer ``` **Parameters** - `path` `string`: The file's path. - `options` `IReadPrivateFileOptions`: The largest size accepted, in bytes, and the root the file must be inside. **Returns** `Buffer`: The content. **Throws** - `NON_EXISTENT_FILE`: if the file doesn't exist - `NOT_A_FILE`: if the path's final component is a symbolic link, or it isn't a regular file - `FILE_NOT_PRIVATE`: if other users can access the file - `FILE_TOO_LARGE`: if the file holds more than maxBytes bytes - `PATH_NOT_CANONICAL`: if the path or the root goes through a symbolic link - `PATH_OUTSIDE_ROOT`: if the file isn't strictly inside the root - `NOT_A_DIRECTORY`: if the root doesn't exist or isn't a directory **Example** ```ts import { readPrivateFile } from 'jg-fs/sync'; const key = readPrivateFile('/home/ada/.ssh/id_ed25519', { maxBytes: 16 * 1024 }); ``` ##### `readTextFile` *Function* Reads a UTF-8 text file. An empty file reads as an empty string. ```ts readTextFile(path: string): string ``` **Parameters** - `path` `string`: The file's path. **Returns** `string`: The text. **Throws** - `NOT_A_FILE`: if the path doesn't exist or is not a file **Example** ```ts import { readTextFile } from 'jg-fs/sync'; const markdown = readTextFile('README.md'); ``` ##### `writeBufferFile` *Function* Writes a binary file. An empty Buffer writes an empty file. ```ts writeBufferFile(path: string, data: Buffer): void ``` **Parameters** - `path` `string`: The file's path. - `data` `Buffer`: The content. **Throws** - `FILE_CONTENT_IS_EMPTY_OR_INVALID`: if the data isn't a Buffer **Example** ```ts import { writeBufferFile } from 'jg-fs/sync'; const imageBuffer = Buffer.from('iVBORw0KGgo=', 'base64'); writeBufferFile('avatar.png', imageBuffer); ``` ##### `writeFile` *Function* Writes a file, creating its directory first when it doesn't exist. ```ts writeFile( path: string, data: string | ArrayBufferView, options?: WriteFileOptions, ): void ``` **Parameters** - `path` `string`: The file's path. - `data` `string | ArrayBufferView`: The content. - `options?` `WriteFileOptions`: Node.js's writeFile options, such as the encoding or mode. **Example** ```ts import { writeFile } from 'jg-fs/sync'; const csv = 'id,name\n1,Ada\n'; writeFile('out/report.csv', csv, { encoding: 'utf-8' }); ``` ##### `writeFileAtomic` *Function* Writes a file atomically: readers see either the previous content or the complete new content, never a partial write. The data goes to a temporary file in the same directory, which is flushed to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins; without one, a replaced file keeps its permission bits and a new file gets the platform default. The temporary file is never more widely readable than the final mode. When writing fails before the rename, the temporary file is removed and the destination is untouched. While Windows refuses the rename because another process, such as an antivirus scanner, holds the file, it tries again for about 4 seconds. A symbolic link at the path is replaced by the file. ```ts writeFileAtomic( path: string, data: string | ArrayBufferView, mode?: number, ): void ``` **Parameters** - `path` `string`: The file's path. - `data` `string | ArrayBufferView`: The content. - `mode?` `number`: The permission bits of the written file, such as `0o600`. **Example** ```ts import { writeFileAtomic } from 'jg-fs/sync'; const state = { step: 2 }; writeFileAtomic('state.json', JSON.stringify(state)); // readers never see a partial file writeFileAtomic('secrets.env', 'TOKEN=abc\n', 0o600); // only the owner can read it ``` ##### `writeJsonFile` *Function* Writes a JSON file atomically, as writeFileAtomic does, so a crash or a full disk never leaves a truncated file that won't parse. A symbolic link at the path keeps pointing at the file, whose target is replaced. A string is written as given; anything else is stringified. ```ts writeJsonFile>( path: string, data: string | T, space?: number, ): void ``` **Parameters** - `path` `string`: The file's path. - `data` `string | T`: The value, or its JSON text. - `space?` `number`: The indentation for stringified values. Defaults to `2`. **Throws** - `FILE_CONTENT_IS_EMPTY_OR_INVALID`: if the JSON content cannot be stringified or is empty **Example** ```ts import { writeJsonFile } from 'jg-fs/sync'; writeJsonFile('config.json', { port: 8080 }); ``` ##### `writePrivateFile` *Function* Writes a file that only its owner can read and write, such as a token or a session. The file's directory must have no symbolic link in its path, and with `rootPath`, the file must be strictly inside that directory. By default, it replaces the file atomically with `0o600` permissions, through a temporary file that's never more widely readable; a file already at the path must be private, as `assertPrivateFile` checks, so a symbolic link or a shared file is never overwritten. With `isExclusive`, it creates the file and fails when anything is already at the path. ```ts writePrivateFile( path: string, data: string | ArrayBufferView, options?: IWritePrivateFileOptions, ): void ``` **Parameters** - `path` `string`: The file's path. - `data` `string | ArrayBufferView`: The content. - `options?` `IWritePrivateFileOptions`: The root the file must be inside, and whether to create it exclusively. Defaults to `{}`. **Throws** - `FILE_ALREADY_EXISTS`: if isExclusive is set and anything already exists at the path - `NOT_A_FILE`: if the path's final component is a symbolic link, or it isn't a regular file - `FILE_NOT_PRIVATE`: if other users can access the file at the path, or the file written - `PATH_NOT_CANONICAL`: if the file's directory or the root goes through a symbolic link - `PATH_OUTSIDE_ROOT`: if the file isn't strictly inside the root - `NOT_A_DIRECTORY`: if the root or the file's directory doesn't exist or isn't a directory **Example** ```ts import { writePrivateFile } from 'jg-fs/sync'; writePrivateFile('/home/ada/.config/app/token.json', '{"token":"…"}'); ``` ##### `writeTextFile` *Function* Writes a UTF-8 text file. An empty string writes an empty file. ```ts writeTextFile(path: string, data: string): void ``` **Parameters** - `path` `string`: The file's path. - `data` `string`: The text. **Throws** - `FILE_CONTENT_IS_EMPTY_OR_INVALID`: if the data isn't a string **Example** ```ts import { writeTextFile } from 'jg-fs/sync'; writeTextFile('notes/today.md', '# Today'); ``` #### Constants in `jg-fs/sync` ##### `ERRORS` *Constant* the codes of every error the package throws, from both the async and the sync entry ```ts const ERRORS: { NOT_A_DIRECTORY: "NOT_A_DIRECTORY"; NON_EXISTENT_DIRECTORY: "NON_EXISTENT_DIRECTORY"; DIRECTORY_ALREADY_EXISTS: "DIRECTORY_ALREADY_EXISTS"; NON_EXISTENT_FILE: "NON_EXISTENT_FILE"; NOT_A_FILE: "NOT_A_FILE"; FILE_CONTENT_IS_EMPTY_OR_INVALID: "FILE_CONTENT_IS_EMPTY_OR_INVALID"; FILE_TOO_LARGE: "FILE_TOO_LARGE"; LOCK_BUSY: "LOCK_BUSY"; LOCK_NOT_OWNED: "LOCK_NOT_OWNED"; LOCK_RELEASE_FAILED: "LOCK_RELEASE_FAILED"; LOCK_ACQUISITION_FAILED: "LOCK_ACQUISITION_FAILED"; FILE_NOT_PRIVATE: "FILE_NOT_PRIVATE"; DIRECTORY_NOT_PRIVATE: "DIRECTORY_NOT_PRIVATE"; PATH_NOT_CANONICAL: "PATH_NOT_CANONICAL"; PATH_OUTSIDE_ROOT: "PATH_OUTSIDE_ROOT"; FILE_ALREADY_EXISTS: "FILE_ALREADY_EXISTS" } ``` #### Types in `jg-fs/sync` ##### `IDirectoryElementsKeySort` *Type* the keys a directory's elements can be sorted by ```ts type IDirectoryElementsKeySort = 'baseName' | 'size' | 'creation'; ``` ##### `IDirectoryElementsOptions` *Type* how getDirectoryElements filters and sorts a directory's elements ```ts type IDirectoryElementsOptions = { sortByKey: IDirectoryElementsKeySort; sortOrder: ISortDirection; includeExts: string[]; }; ``` **Members** - `sortByKey: IDirectoryElementsKeySort` (property): the key the elements are sorted by. Defaults to 'baseName' - `sortOrder: ISortDirection` (property): the sort direction. Defaults to 'asc' - `includeExts: string[]` (property): the extensions of the files to include, such as ['.json'], matched case-insensitively. Defaults to all ##### `IDirectoryPathElements` *Type* a directory's elements, grouped by kind ```ts type IDirectoryPathElements = { directories: IPathElement[]; files: IPathElement[]; symbolicLinks: IPathElement[]; }; ``` **Members** - `directories: IPathElement[]` (property) - `files: IPathElement[]` (property) - `symbolicLinks: IPathElement[]` (property) ##### `IFileLock` *Type* a lock acquireLock holds until it's released ```ts type IFileLock = { path: string; owner: IFileLockOwner; release: () => void; }; ``` **Members** - `path: string` (property): the lock's directory - `owner: IFileLockOwner` (property): the process that holds it, as its owner.json names it - `release: () => void` (property): removes the lock; calling it again after it succeeded does nothing ##### `IFileLockOwner` *Type* the process that holds a lock, as its lock directory's owner.json names it ```ts type IFileLockOwner = { pid: number; nonce: string; hostname: string; }; ``` **Members** - `pid: number` (property): the process id - `nonce: string` (property): a random id that tells this acquisition apart from any other by the same process id - `hostname: string` (property): the host the process runs on; a lock is never recovered from another host ##### `IPathElement` *Type* the details of a path, read with lstat, so a symbolic link is described rather than followed ```ts type IPathElement = { path: string; baseName: string; extName: string; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean; size: number; creation: number; }; ``` **Members** - `path: string` (property): the path, as given - `baseName: string` (property): the last segment of the path - `extName: string` (property): the extension, such as '.json', or an empty string when there is none - `isFile: boolean` (property): whether it's a regular file - `isDirectory: boolean` (property): whether it's a directory - `isSymbolicLink: boolean` (property): whether it's a symbolic link, in which case isFile and isDirectory are false - `size: number` (property): the size in bytes - `creation: number` (property): when it was created, in milliseconds since the epoch ##### `IPrivateFileOptions` *Type* the options assertPrivateFile takes ```ts type IPrivateFileOptions = { rootPath?: string | undefined; maxBytes?: number | undefined; }; ``` **Members** - `rootPath: string` (property): a directory the file must be strictly inside; it must exist and contain no symbolic link - `maxBytes: number` (property): the largest size accepted, in bytes ##### `IReadJsonFileOptions` *Type* what readJsonFile accepts ```ts type IReadJsonFileOptions = { schema?: ISchema; maxBytes?: number; }; ``` **Members** - `schema: ISchema` (property): validates the parsed content, such as a Zod schema. Its errors propagate unchanged - `maxBytes: number` (property): the largest file accepted, in bytes, for a file whose size isn't yours to decide, such as one a user uploaded. With it, the file is read as readBoundedFile reads it ##### `IReadPrivateFileOptions` *Type* the options readPrivateFile takes ```ts type IReadPrivateFileOptions = { maxBytes: number; rootPath?: string | undefined; }; ``` **Members** - `maxBytes: number` (property): the largest size accepted, in bytes; it's required, because the file's size isn't the reader's - `rootPath: string` (property): a directory the file must be strictly inside; it must exist and contain no symbolic link ##### `ISchema` *Type* validates parsed data and returns it typed, which a Zod schema does ```ts type ISchema = { parse(value: unknown): T; }; ``` **Members** - `parse(value: unknown): T` (method) ##### `IWritePrivateFileOptions` *Type* the options writePrivateFile takes ```ts type IWritePrivateFileOptions = { rootPath?: string | undefined; isExclusive?: boolean | undefined; }; ``` **Members** - `rootPath: string` (property): a directory the file must be strictly inside; it must exist and contain no symbolic link - `isExclusive: boolean` (property): creates the file, and fails when anything already exists at the path, instead of replacing it --- # jg-fs recipes Patterns for Node.js apps that keep their files whole through crashes, and read files from users within a size limit. ## A config file that's never half-written Use this for a file the app reads at startup and rewrites while it runs, such as settings changed from an admin page. A plain write that's cut short, by a crash or a full disk, leaves a truncated file that won't parse at the next start. `writeJsonFile` writes a temporary file next to it, flushes it to disk, and renames it over the old one, so the file holds either the old config or the new one: ```ts import { hasErrorCode } from 'jg-exception'; import { ERRORS, readJsonFile, writeJsonFile } from 'jg-fs'; // the config's type, from your schema type IConfig = ReturnType; const CONFIG_PATH = 'data/config.json'; // what the app runs with until someone saves a change const DEFAULT_CONFIG: IConfig = { port: 8080 }; export const loadConfig = async (): Promise => { try { // Config is your schema, such as a Zod object, so a file edited by hand into a wrong shape // fails here, at startup, instead of somewhere later return await readJsonFile(CONFIG_PATH, { schema: Config }); } catch (error) { // nothing was saved yet if (hasErrorCode(error, ERRORS.NOT_A_FILE)) { return DEFAULT_CONFIG; } throw error; } }; // the update in progress, which the next one waits for let pendingUpdate: Promise = Promise.resolve(); // reads, changes, and saves the config one update at a time, so two updates never start from // the same version and lose one of the changes export const updateConfig = (change: (config: IConfig) => IConfig): Promise => { const update = pendingUpdate.then(async () => { const config = change(await loadConfig()); await writeJsonFile(CONFIG_PATH, config); return config; }); // a failed update rejects for its caller, and the next one still runs pendingUpdate = update.catch(() => undefined); return update; }; await updateConfig((config) => ({ ...config, port: 3000 })); ``` `writeFileAtomic` writes any other content the same way, and both try again for a few seconds while Windows refuses the rename because a scanner holds the file. A symbolic link at the path keeps pointing at the config. Any error other than a missing file propagates, such as `FILE_CONTENT_IS_EMPTY_OR_INVALID` for a file that isn't JSON, or the schema's own error, so the app never starts from a config it can't trust. The atomic write keeps each save whole, but doesn't order saves: the queue does that inside one process, and when several processes write the file, give it one owner. A crash during a save can leave a temporary `..tmp` file next to the config, which is safe to delete. `jg-fs/sync` has the same functions without promises, for code that can't await, such as an `exit` handler or a short CLI command. An explicit mode keeps a secret private from the first byte written: ```ts import { homedir } from 'node:os'; import { join } from 'node:path'; import { writeFileAtomic } from 'jg-fs/sync'; // the token your CLI's login command received declare const token: string; // kept outside any project, so it's never committed with one const credentialsPath = join(homedir(), '.my-cli', 'credentials.json'); // on POSIX, only the owner can read it, and the temporary file is never more widely readable writeFileAtomic(credentialsPath, JSON.stringify({ token }), 0o600); ``` Without a mode, a replaced file keeps its permission bits, so a config someone restricted with `chmod` stays restricted after the app saves it. ## Reading uploads with a size limit Use this when a request names a file that someone uploaded earlier, such as an import or an attachment, and the app reads it into memory. The name comes from a client, and the file can be larger than you expect, so the read checks three things: the path stays inside the uploads directory, the file is a regular file and not a symbolic link, and its size is within a limit: ```ts import { join, resolve } from 'node:path'; import { Exception, hasErrorCode } from 'jg-exception'; import { ERRORS, isPathWithin, readBoundedFile } from 'jg-fs'; // where your upload handler saves files, a directory that only the app writes to const UPLOADS_DIRECTORY = resolve('uploads'); // the largest upload the app loads into memory const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; // reads an upload by the name a request gave, such as 'report.csv'. The messages name the upload // and not the server's path, and the cause keeps jg-fs's error for the logs export const readUpload = async (name: string): Promise => { // a name such as '../.env' leads outside the directory, which counts as not found const path = join(UPLOADS_DIRECTORY, name); if (!isPathWithin(UPLOADS_DIRECTORY, path)) { throw new Exception('The upload was not found.', { code: 'UPLOAD_NOT_FOUND', data: { name }, }); } try { return await readBoundedFile(path, MAX_UPLOAD_BYTES); } catch (error) { // a missing file, a directory, or a symbolic link someone planted if (hasErrorCode(error, ERRORS.NOT_A_FILE)) { throw new Exception('The upload was not found.', { code: 'UPLOAD_NOT_FOUND', data: { name }, cause: error, }); } if (hasErrorCode(error, ERRORS.FILE_TOO_LARGE)) { throw new Exception('The upload is too large to read.', { code: 'UPLOAD_TOO_LARGE', data: { name, maxBytes: MAX_UPLOAD_BYTES }, cause: error, }); } throw error; } }; ``` `readBoundedFile` checks the size before it reads, and reads at most one byte past the limit, so a file that grows during the read can't make it load more. It refuses a symbolic link as the last part of the path, but follows links in the directories above it, which is why the uploads go in a directory that only the app writes to. A request handler can answer `UPLOAD_NOT_FOUND` with a 404, and `jg-fs/sync` has the same `readBoundedFile` for code that can't await. --- # jg-process > Run and stop child processes without a shell: timeouts, output limits, abort signals, and whole process trees. - **Version:** `1.3.0` - **Runtimes:** Node.js - **Install:** `npm install jg-process` - **On npm:** [jg-process](https://www.npmjs.com/package/jg-process) - **Source:** [packages/jg-process](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-process) - **Recipes:** [jg-process recipes](https://jg-packages.jesusgraterol.dev/packages/jg-process/recipes/index.md) ## Usage Run a command to completion and read its output. The run is bounded by a timeout and an output limit, and both stop the whole process tree: ```ts import { runProcess } from 'jg-process'; const { exitCode, stdout, stderr, durationMs } = await runProcess({ command: 'git', args: ['status', '--porcelain'], cwd: repositoryDirectory, timeoutSeconds: 30, signal: AbortSignal.timeout(60_000), }); ``` A non-zero exit code resolves, so callers decide what counts as failure. A timeout, too much output, or a failure to start rejects with `PROCESS_TIMED_OUT`, `PROCESS_OUTPUT_TOO_LARGE`, or `PROCESS_START_FAILED`. Start a long-running process that you own, and stop it together with everything it started: ```ts import { startProcess, stopProcessTree } from 'jg-process'; const server = startProcess({ command: 'node', args: ['server.js'], cwd: 'apps/api' }); process.once('SIGINT', () => void stopProcessTree(server)); ``` Start a server and wait until it answers, or stop everything it started when it doesn't: ```ts import { startProcessAndWait } from 'jg-process'; const api = await startProcessAndWait( { command: process.execPath, args: ['server.js'], cwd: 'apps/api' }, { readyWhen: { url: 'http://127.0.0.1:3000/health' }, timeoutSeconds: 60 }, ); ``` Run a package manager without a shell on every platform, including Windows, where its `.cmd` shim can't be spawned directly: ```ts import { resolvePackageManagerCommand, runProcess } from 'jg-process'; await runProcess({ ...resolvePackageManagerCommand(['run', 'build']), timeoutSeconds: 600 }); ``` Reserve a free port before handing it to a server, keeping the preferred one when it's free: ```ts import { reservePort } from 'jg-process'; const reservation = await reservePort({ preferredPort: 3000 }); // 3000, or another free port await reservation.release(); // right before the server binds it ``` ## jg-process API reference Every export of `jg-process@1.3.0`, 25 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. - Functions: [`reservePort`](#api-reservePort), [`resolvePackageManagerCommand`](#api-resolvePackageManagerCommand), [`runProcess`](#api-runProcess), [`startProcess`](#api-startProcess), [`startProcessAndWait`](#api-startProcessAndWait), [`stopProcessTree`](#api-stopProcessTree), [`waitForPort`](#api-waitForPort), [`waitForUrl`](#api-waitForUrl) - Constants: [`DEFAULT_GRACE_PERIOD_SECONDS`](#api-DEFAULT_GRACE_PERIOD_SECONDS), [`DEFAULT_MAX_OUTPUT_BYTES`](#api-DEFAULT_MAX_OUTPUT_BYTES), [`ERRORS`](#api-ERRORS) - Types: [`IPackageManager`](#api-IPackageManager), [`IPackageManagerCommand`](#api-IPackageManagerCommand), [`IPortReservation`](#api-IPortReservation), [`IProcessResult`](#api-IProcessResult), [`IReadinessWaitOptions`](#api-IReadinessWaitOptions), [`IReadyWhen`](#api-IReadyWhen), [`IReservePortOptions`](#api-IReservePortOptions), [`IResolvePackageManagerOptions`](#api-IResolvePackageManagerOptions), [`IRunProcessOptions`](#api-IRunProcessOptions), [`IStartProcessAndWaitOptions`](#api-IStartProcessAndWaitOptions), [`IStartProcessCommand`](#api-IStartProcessCommand), [`IStartProcessOptions`](#api-IStartProcessOptions), [`IWaitForPortOptions`](#api-IWaitForPortOptions), [`IWaitForUrlOptions`](#api-IWaitForUrlOptions) ### Functions #### `reservePort` *Function* Reserves a free TCP port by listening on it, so nothing else can take it until it's released, such as before passing it to a dev server or a test fixture. The preferred port is kept when it's free, and any free port is reserved when it's taken. Connections to the reserved port are closed at once, and the reservation doesn't keep the process running. A child process that needs the port itself can't bind it while it's reserved, so release it right before starting the child. Another process can take the port in between. ```ts reservePort(options?: IReservePortOptions): Promise ``` **Parameters** - `options?` `IReservePortOptions`: The preferred port and the address. The address defaults to 127.0.0.1. Defaults to `{}`. **Returns** `Promise`: A promise that resolves to the reservation. **Throws** - `INVALID_PORT`: The preferred port must be an integer from 1 to 65535. - `PORT_RESERVATION_FAILED`: The port couldn't be reserved. **Example** ```ts import { reservePort } from 'jg-process'; const reservation = await reservePort({ preferredPort: 3000 }); console.log(`The port is ${reservation.port}`); // 3000, or another free port when it's taken await reservation.release(); ``` #### `resolvePackageManagerCommand` *Function* Builds the command that runs a package manager with the given arguments, without a shell, so it can be passed to `runProcess` or `startProcess` as it is on Windows, macOS, and Linux. It looks, in order, at: 1. `npm_execpath`, which a package manager sets for the scripts it runs, when it names the requested package manager, or any one when none is requested 2. the package manager installed in the project, resolved from `cwd` 3. PATH: an executable on POSIX, and on Windows an `.exe`, or a `.cmd` or `.ps1` shim whose script it runs through Node.js, because Node.js refuses to spawn a `.cmd` file without a shell A JavaScript entry, such as `pnpm.cjs`, runs through the current Node.js. Without a requested package manager or `npm_execpath`, there's nothing to look for, so it throws. ```ts resolvePackageManagerCommand( args: string[], options?: IResolvePackageManagerOptions, ): IPackageManagerCommand ``` **Parameters** - `args` `string[]`: The arguments for the package manager, such as `['run', 'build']`. - `options?` `IResolvePackageManagerOptions`: The package manager, the environment, and the directory to resolve from. Defaults to `{}`. **Returns** `IPackageManagerCommand`: The command and its arguments. **Throws** - `UNSUPPORTED_PACKAGE_MANAGER`: The package manager must be pnpm, npm, yarn, or bun. - `PACKAGE_MANAGER_NOT_FOUND`: The package manager's executable couldn't be found. **Example** ```ts import { resolvePackageManagerCommand, runProcess } from 'jg-process'; const command = resolvePackageManagerCommand(['run', 'build'], { packageManager: 'pnpm' }); const { exitCode } = await runProcess({ ...command, timeoutSeconds: 600 }); ``` #### `runProcess` *Function* Runs a command to completion and resolves with its exit code, signal and output. It runs without a shell and, on POSIX, as the leader of its own process group. Every exit resolves, whatever its code: callers decide which codes are acceptable, so process output never ends up in an error message. The call rejects only when the command can't start, the timeout passes, the output exceeds maxOutputBytes, or the signal aborts; in those cases the process tree is stopped first, so the rejection can arrive up to the grace period plus two seconds after the trigger. Input that the child never reads is discarded without an error. When the child exits, descendants left in its process group are stopped. If one that couldn't be reached, such as a descendant that left the group with setsid or one orphaned on Windows, keeps stdout or stderr open for longer than the grace period, the call rejects instead of hanging. Because the child leads its own group, a terminal's Ctrl+C reaches only the parent: CLIs should abort the signal when they receive SIGINT or SIGTERM. ```ts runProcess(options: IRunProcessOptions): Promise ``` **Parameters** - `options` `IRunProcessOptions`: The command, its arguments, and the limits of the run. **Returns** `Promise`: A promise that resolves with the exit code, the signal and the output. **Throws** - `PROCESS_START_FAILED`: Unable to start the command - `PROCESS_TIMED_OUT`: The process didn't finish within the timeout - `PROCESS_OUTPUT_TOO_LARGE`: The process wrote more output than allowed - `PROCESS_TREE_SURVIVED`: The process tree is still running after it was force-stopped - The signal's reason when the signal aborts. **Example** ```ts import { runProcess } from 'jg-process'; const { exitCode, stdout } = await runProcess({ command: 'git', args: ['status', '--porcelain'], timeoutSeconds: 30 }); if (exitCode !== 0) { // handle the failure } ``` #### `startProcess` *Function* Starts a long-running child that the caller owns, without a shell and, on POSIX, as the leader of its own process group, so stopProcessTree can later stop it together with its descendants. Because the child leads its own group, a terminal's Ctrl+C reaches only the parent: CLIs should stop the child when they receive SIGINT or SIGTERM. A command that can't start emits an 'error' event on the returned child. ```ts startProcess( command: string, args: string[], options?: IStartProcessOptions, ): ChildProcess ``` **Parameters** - `command` `string`: The executable to run. - `args` `string[]`: The arguments passed to the executable. - `options?` `IStartProcessOptions`: The working directory, the environment and the stdio configuration ('inherit' by default). **Returns** `ChildProcess`: The started child process. Starts a long-running child from a command object, the form runProcess takes, with the same behavior as the positional form. ```ts startProcess(command: IStartProcessCommand): ChildProcess ``` **Parameters** - `command` `IStartProcessCommand`: The executable, its arguments, and the working directory, environment, and stdio. **Returns** `ChildProcess`: The started child process. **Example 1** ```ts import { startProcess, stopProcessTree } from 'jg-process'; const server = startProcess('node', ['server.js'], { stdio: 'inherit' }); process.once('SIGINT', () => void stopProcessTree(server)); ``` **Example 2** ```ts import { startProcess } from 'jg-process'; const server = startProcess({ command: 'node', args: ['server.js'], cwd: 'apps/api' }); ``` #### `startProcessAndWait` *Function* Starts a long-running process, such as a dev server, with `startProcess`, and resolves with it once it's ready: its port accepts connections, its URL answers, or a probe returns a value other than null, undefined, or false. When it exits, fails to start, isn't ready in time, or the signal aborts, its whole process tree is stopped before the call rejects, so nothing is left running. ```ts startProcessAndWait( command: IStartProcessCommand, options: IStartProcessAndWaitOptions, ): Promise ``` **Parameters** - `command` `IStartProcessCommand`: The command, its arguments, and the working directory, environment, and stdio. - `options` `IStartProcessAndWaitOptions`: What ready means, the deadline and pace, the signal, and the grace period stopProcessTree gets on failure. **Returns** `Promise`: A promise that resolves to the ready child process, which the caller owns. **Throws** - `INVALID_PORT`: The port must be an integer from 1 to 65535. - `INVALID_READINESS_OPTIONS`: The readiness options need finite, positive seconds. - `PROCESS_NOT_READY`: The process wasn't ready in time. - `PROCESS_EXITED_BEFORE_READY`: The process exited before it was ready. - `PROCESS_START_FAILED`: Unable to start the process. - `PROCESS_TREE_SURVIVED`: The process tree is still running after it was force-stopped. - The signal's reason when the signal aborts. **Example** ```ts import { startProcessAndWait, stopProcessTree } from 'jg-process'; const server = await startProcessAndWait( { command: process.execPath, args: ['server.js'], cwd: 'apps/api' }, { readyWhen: { url: 'http://127.0.0.1:3000/health' }, timeoutSeconds: 60 }, ); await stopProcessTree(server); ``` #### `stopProcessTree` *Function* Stops a child started by startProcess or runProcess together with its descendants, and resolves once they are gone. It resolves immediately for a child without a pid. On POSIX, it sends SIGTERM to the child's process group, waits up to the grace period, then sends SIGKILL and waits up to two more seconds. It is designed for children that lead their own process group, as startProcess and runProcess start them; for any other child, it signals the child alone. Descendants that start their own session or process group, for example with setsid, aren't stopped. On Windows, it runs taskkill /T /F, which reaches only descendants of a root process that is still running: when the root has already exited, it resolves and orphaned descendants may remain. ```ts stopProcessTree(child: ChildProcess, gracePeriodSeconds?: number): Promise ``` **Parameters** - `child` `ChildProcess`: The child process to stop. - `gracePeriodSeconds?` `number`: No description. Defaults to `DEFAULT_GRACE_PERIOD_SECONDS`. **Returns** `Promise`: A promise that resolves once the process tree is gone. **Throws** - `PROCESS_TREE_SURVIVED`: The process tree is still running after it was force-stopped. **Example** ```ts import { startProcess, stopProcessTree } from 'jg-process'; const server = startProcess('node', ['server.js']); await stopProcessTree(server, 10); // SIGTERM, then SIGKILL after 10 seconds ``` #### `waitForPort` *Function* Waits until a TCP port accepts connections, such as a database or a dev server that a script started, probing every `intervalSeconds` until `timeoutSeconds` pass. With a `child`, its exit or failure to start ends the wait at once, since it can no longer become ready. ```ts waitForPort(port: number, options: IWaitForPortOptions): Promise ``` **Parameters** - `port` `number`: The port, from 1 to 65535. - `options` `IWaitForPortOptions`: The address, the deadline and pace, the signal, and the child to watch. **Returns** `Promise`: A promise that resolves once a connection succeeds. **Throws** - `INVALID_PORT`: The port must be an integer from 1 to 65535. - `INVALID_READINESS_OPTIONS`: The readiness options need finite, positive seconds. - `PROCESS_NOT_READY`: The process wasn't ready in time. - `PROCESS_EXITED_BEFORE_READY`: The process exited before it was ready. - `PROCESS_START_FAILED`: Unable to start the process. - The signal's reason when the signal aborts. **Example** ```ts import { waitForPort } from 'jg-process'; await waitForPort(5432, { timeoutSeconds: 30 }); // PostgreSQL accepts connections ``` #### `waitForUrl` *Function* Waits until a URL answers in a way that means ready, by default with a 2xx status, such as a health endpoint. Redirects aren't followed, a network error means not ready yet, and every response's body is discarded. With a `child`, its exit or failure to start ends the wait at once. ```ts waitForUrl(url: string | URL, options: IWaitForUrlOptions): Promise ``` **Parameters** - `url` `string | URL`: The URL. - `options` `IWaitForUrlOptions`: The deadline and pace, the request timeout, the predicate, the signal, and the child to watch. **Returns** `Promise`: A promise that resolves once a response means ready. **Throws** - `INVALID_READINESS_OPTIONS`: The readiness options need finite, positive seconds. - `PROCESS_NOT_READY`: The process wasn't ready in time. - `PROCESS_EXITED_BEFORE_READY`: The process exited before it was ready. - `PROCESS_START_FAILED`: Unable to start the process. - The signal's reason when the signal aborts. **Example** ```ts import { waitForUrl } from 'jg-process'; await waitForUrl('http://127.0.0.1:3000/health', { timeoutSeconds: 60 }); ``` ### Constants #### `DEFAULT_GRACE_PERIOD_SECONDS` *Constant* the seconds stopProcessTree waits after SIGTERM before it sends SIGKILL ```ts const DEFAULT_GRACE_PERIOD_SECONDS: 5 ``` #### `DEFAULT_MAX_OUTPUT_BYTES` *Constant* the bytes stdout and stderr can hold together when runProcess gets no maxOutputBytes (1 MiB) ```ts const DEFAULT_MAX_OUTPUT_BYTES: 1048576 ``` #### `ERRORS` *Constant* the codes for every error thrown by the package ```ts const ERRORS: { PROCESS_START_FAILED: "PROCESS_START_FAILED"; PROCESS_TIMED_OUT: "PROCESS_TIMED_OUT"; PROCESS_OUTPUT_TOO_LARGE: "PROCESS_OUTPUT_TOO_LARGE"; PROCESS_TREE_SURVIVED: "PROCESS_TREE_SURVIVED"; INVALID_PORT: "INVALID_PORT"; PORT_RESERVATION_FAILED: "PORT_RESERVATION_FAILED"; UNSUPPORTED_PACKAGE_MANAGER: "UNSUPPORTED_PACKAGE_MANAGER"; PACKAGE_MANAGER_NOT_FOUND: "PACKAGE_MANAGER_NOT_FOUND"; INVALID_READINESS_OPTIONS: "INVALID_READINESS_OPTIONS"; PROCESS_NOT_READY: "PROCESS_NOT_READY"; PROCESS_EXITED_BEFORE_READY: "PROCESS_EXITED_BEFORE_READY" } ``` ### Types #### `IPackageManager` *Type* the package managers resolvePackageManagerCommand finds ```ts type IPackageManager = 'pnpm' | 'npm' | 'yarn' | 'bun'; ``` #### `IPackageManagerCommand` *Type* a command to spawn without a shell, which runProcess and startProcess take as they are ```ts type IPackageManagerCommand = { command: string; args: string[]; }; ``` **Members** - `command: string` (property) - `args: string[]` (property) #### `IPortReservation` *Type* a port held until it's released, so nothing else can take it meanwhile ```ts type IPortReservation = { port: number; host: string; release: () => Promise; }; ``` **Members** - `port: number` (property): the reserved port - `host: string` (property): the address it's reserved on - `release: () => Promise` (property): stops holding the port; calling it again does nothing #### `IProcessResult` *Type* the outcome of a process that ran to completion ```ts type IProcessResult = { exitCode: number | null; signalCode: NodeJS.Signals | null; stdout: string; stderr: string; durationMs: number; }; ``` **Members** - `exitCode: number | null` (property): the exit code, or null when a signal ended the process - `signalCode: NodeJS.Signals | null` (property): the signal that ended the process, or null when it exited on its own - `stdout: string` (property): the output, decoded as UTF-8 - `stderr: string` (property) - `durationMs: number` (property): the milliseconds between the start and the end of the run #### `IReadinessWaitOptions` *Type* what a readiness wait shares: its deadline, its pace, and what ends it early ```ts type IReadinessWaitOptions = { timeoutSeconds: number; intervalSeconds?: number | undefined; signal?: AbortSignal | undefined; child?: ChildProcess | undefined; }; ``` **Members** - `timeoutSeconds: number` (property): the most seconds the whole wait can take - `intervalSeconds: number` (property): the seconds between probes. Defaults to 0.25 - `signal: AbortSignal` (property): stops the wait and rejects with the signal's reason when it aborts - `child: ChildProcess` (property): a process whose exit or start failure ends the wait, because it can no longer become ready #### `IReadyWhen` *Type* what startProcessAndWait waits for: a port, a URL, or a probe returning other than null, undefined, or false ```ts type IReadyWhen = { port: number; host?: string | undefined; } | { url: string | URL; isReady?: IWaitForUrlOptions['isReady']; } | { probe: (signal: AbortSignal) => unknown; }; ``` #### `IReservePortOptions` *Type* the options reservePort takes ```ts type IReservePortOptions = { preferredPort?: number | undefined; host?: string | undefined; }; ``` **Members** - `preferredPort: number` (property): the port to try first, from 1 to 65535; another free one is reserved when it's taken - `host: string` (property): the address to listen on. Defaults to 127.0.0.1 #### `IResolvePackageManagerOptions` *Type* the options resolvePackageManagerCommand takes ```ts type IResolvePackageManagerOptions = { packageManager?: IPackageManager | undefined; env?: NodeJS.ProcessEnv | undefined; cwd?: string | undefined; }; ``` **Members** - `packageManager: IPackageManager` (property): The package manager to run. Defaults to the one running the current package script, which npm_execpath names; without one, a package manager has to be named. - `env: NodeJS.ProcessEnv` (property): the environment that holds npm_execpath and PATH. Defaults to process.env - `cwd: string` (property): the directory a local install is resolved from. Defaults to the current one #### `IRunProcessOptions` *Type* the options accepted by runProcess ```ts type IRunProcessOptions = { command: string; args: string[]; cwd?: string; env?: NodeJS.ProcessEnv; input?: string | Uint8Array; timeoutSeconds?: number; maxOutputBytes?: number; signal?: AbortSignal; }; ``` **Members** - `command: string` (property): the executable to run, without a shell - `args: string[]` (property): the arguments passed to the executable - `cwd: string` (property): the working directory. Defaults to the current one - `env: NodeJS.ProcessEnv` (property): the environment variables. Defaults to process.env - `input: string | Uint8Array` (property): the data written to the process's stdin before it is closed - `timeoutSeconds: number` (property): the seconds after which the process tree is stopped and the call rejects - `maxOutputBytes: number` (property): the bytes stdout and stderr can hold together. Defaults to DEFAULT_MAX_OUTPUT_BYTES - `signal: AbortSignal` (property): stops the process tree and rejects with the signal's reason when it aborts #### `IStartProcessAndWaitOptions` *Type* the options startProcessAndWait takes ```ts type IStartProcessAndWaitOptions = Omit & { readyWhen: IReadyWhen; gracePeriodSeconds?: number | undefined; }; ``` #### `IStartProcessCommand` *Type* a command and its options in one object, the form runProcess takes, accepted by startProcess too ```ts type IStartProcessCommand = IStartProcessOptions & { command: string; args: string[]; }; ``` #### `IStartProcessOptions` *Type* the options accepted by startProcess ```ts type IStartProcessOptions = { cwd?: string; env?: NodeJS.ProcessEnv; stdio?: StdioOptions; }; ``` **Members** - `cwd: string` (property): the working directory. Defaults to the current one - `env: NodeJS.ProcessEnv` (property): the environment variables. Defaults to process.env - `stdio: StdioOptions` (property): the stdio configuration. Defaults to 'inherit' #### `IWaitForPortOptions` *Type* the options waitForPort takes ```ts type IWaitForPortOptions = IReadinessWaitOptions & { host?: string | undefined; connectTimeoutSeconds?: number | undefined; }; ``` #### `IWaitForUrlOptions` *Type* the options waitForUrl takes ```ts type IWaitForUrlOptions = IReadinessWaitOptions & { requestTimeoutSeconds?: number | undefined; isReady?: ((response: Response) => boolean | Promise) | undefined; }; ``` --- # jg-process recipes Patterns for Node.js scripts that run CLIs within a time and output budget, start servers and wait until they answer, and run package scripts on any platform. ## Running a CLI with a timeout and an output cap Use this when the app runs a command-line tool on input it doesn't control, such as extracting the text of an uploaded PDF with `pdftotext` for a search index. A crafted file can keep the tool busy for minutes, or make it print without end, so the run gets a deadline and a byte budget, and either one stops the tool together with everything it started: ```ts import { hasErrorCode } from 'jg-exception'; import { ERRORS, runProcess } from 'jg-process'; // a document's text for the search index, or why the app skipped it type IExtractedText = { text: string } | { skipped: 'unreadable' | 'too-slow' | 'too-large' }; // the path is one the app built, such as an absolute one, so it can't start with '-' and read // as an option export const extractText = async (path: string): Promise => { try { const { exitCode, stdout } = await runProcess({ command: 'pdftotext', // -q keeps warnings out of stderr, which counts toward the same budget as stdout args: ['-q', '-layout', path, '-'], timeoutSeconds: 30, // the index keeps at most 2 MiB of text per document maxOutputBytes: 2 * 1024 * 1024, }); // a failed exit resolves too, and the code says pdftotext couldn't read the file return exitCode === 0 ? { text: stdout } : { skipped: 'unreadable' }; } catch (error) { if (hasErrorCode(error, ERRORS.PROCESS_TIMED_OUT)) { return { skipped: 'too-slow' }; } if (hasErrorCode(error, ERRORS.PROCESS_OUTPUT_TOO_LARGE)) { return { skipped: 'too-large' }; } // PROCESS_START_FAILED means pdftotext isn't installed, which no document fixes throw error; } }; ``` `runProcess` resolves with whatever exit code the tool returned, so you decide which codes count as failure, and the tool's output never lands in an error message. It rejects only when the tool can't start (`PROCESS_START_FAILED`), runs past `timeoutSeconds` (`PROCESS_TIMED_OUT`), writes more than `maxOutputBytes` to stdout and stderr together (`PROCESS_OUTPUT_TOO_LARGE`), or leaves a process behind that survives the forced stop (`PROCESS_TREE_SURVIVED`). Without `maxOutputBytes`, the budget is `DEFAULT_MAX_OUTPUT_BYTES`, 1 MiB. The tool runs without a shell, so a file name can't inject a command. When your own program is a CLI, a terminal's Ctrl+C reaches only your process, because the tool runs as the leader of its own process group. Pass an abort signal, and abort it on `SIGINT`: ```ts import { runProcess } from 'jg-process'; // the PDF your CLI was given, as an absolute path declare const path: string; const controller = new AbortController(); // a second Ctrl+C ends the CLI at once, because the listener runs only for the first process.once('SIGINT', () => controller.abort()); try { const { exitCode, stdout } = await runProcess({ command: 'pdftotext', args: ['-q', '-layout', path, '-'], timeoutSeconds: 30, signal: controller.signal, }); process.stdout.write(stdout); process.exitCode = exitCode ?? 1; } catch (error) { if (!controller.signal.aborted) { throw error; } // 128 plus SIGINT's number, which tells a calling shell or script the run was interrupted process.exitCode = 130; } ``` The abort stops the tool's process tree before the call rejects with the signal's reason, so nothing the tool started keeps running after the CLI exits. ## Stopping a dev server and everything it started A dev server is rarely one process. `node --watch` runs your server in a second process, which it restarts on each change, and `npm run dev` starts a shell that starts the dev server, which can start processes of its own. Stopping only the process you started leaves the rest running, still holding the port. `startProcess` makes the child the leader of its own process group on POSIX, so `stopProcessTree` stops the whole group: SIGTERM first, then SIGKILL after the grace period, `DEFAULT_GRACE_PERIOD_SECONDS` unless you pass one. Use this for a dev script that runs a server until you press Ctrl+C: ```ts import { startProcess, stopProcessTree } from 'jg-process'; // process.execPath is the Node.js running this script, which works on every platform const server = startProcess(process.execPath, ['--watch', 'server.js'], { cwd: 'apps/api' }); // the server leads its own process group, so Ctrl+C reaches only this script, which stops the // group, then exits with 128 plus the signal's number, as a shell reports it const stop = async (exitCode: number): Promise => { try { await stopProcessTree(server); } catch (error) { // PROCESS_TREE_SURVIVED: part of the tree outlived SIGKILL, and may still hold the port console.error(error); } process.exitCode = exitCode; }; process.once('SIGINT', () => void stop(130)); process.once('SIGTERM', () => void stop(143)); ``` On Windows, `npm` and `npx` are `.cmd` shims, which Node.js won't start without a shell, so start a tool's JavaScript entry with `process.execPath` instead. There, `stopProcessTree` runs `taskkill /T /F`, which finds the descendants only while the root process still runs. On POSIX, a descendant that starts its own session or process group, such as with `setsid`, isn't stopped. Use this when a script starts a server for a task, such as smoke tests, and must stop it whether the task passes or fails: ```ts import { setTimeout as sleep } from 'node:timers/promises'; import { Exception } from 'jg-exception'; import { startProcess, stopProcessTree } from 'jg-process'; // the checks your script runs against the server declare function runSmokeTests(baseUrl: string): Promise; const BASE_URL = 'http://localhost:3000'; const server = startProcess(process.execPath, ['server.js'], { cwd: 'apps/api' }); // waits until the server answers, and fails early when it exits or takes too long const waitForServer = async (timeoutSeconds: number): Promise => { const deadline = Date.now() + timeoutSeconds * 1000; while (server.exitCode === null && server.signalCode === null && Date.now() < deadline) { try { // any response, even an error status, means it's listening, and HEAD asks for no body await fetch(BASE_URL, { method: 'HEAD', signal: AbortSignal.timeout(1000) }); return; } catch { await sleep(250); } } throw new Exception("The server didn't start.", { code: 'SERVER_NOT_READY', data: { baseUrl: BASE_URL, timeoutSeconds }, }); }; try { await waitForServer(30); await runSmokeTests(BASE_URL); } finally { // runs after a failure too, so no server is left holding the port for the next run await stopProcessTree(server); } ``` ## Starting a dev server on a free port Use this when a script starts a server that needs a port, such as a second copy of your API for an agent or a test run, while 3000 may already be taken. `reservePort` keeps the preferred port when it's free and reserves another free one when it isn't, so the server never fails to start because of a port conflict, and `startProcessAndWait` returns once the server answers: ```ts import { reservePort, startProcessAndWait, stopProcessTree } from 'jg-process'; // 3000 when it's free, or any free port when another server holds it const reservation = await reservePort({ preferredPort: 3000 }); const { port } = reservation; // the server must bind the port itself, so the reservation ends right before it starts await reservation.release(); const server = await startProcessAndWait( { command: process.execPath, args: ['server.js'], cwd: 'apps/api', env: { ...process.env, PORT: String(port) }, }, { readyWhen: { url: `http://127.0.0.1:${port}/health` }, timeoutSeconds: 60 }, ); console.log(`The API is ready on http://localhost:${port}`); process.once('SIGINT', () => { void stopProcessTree(server).finally(() => { process.exitCode = 130; }); }); ``` When the server exits, fails to start, or doesn't answer within a minute, `startProcessAndWait` stops its whole process tree and rejects, with `PROCESS_EXITED_BEFORE_READY`, `PROCESS_START_FAILED`, or `PROCESS_NOT_READY`, so a failed start leaves nothing running. Another process can take the port between the release and the server binding it. It's a short window, but when two scripts start servers at the same moment, the server exits on `EADDRINUSE`, and running the script again picks another port. ## Running a package script from your own script Use this in a script that runs other package scripts, such as a test orchestrator or a release script. On Windows, `pnpm` and `npm` are `.cmd` shims, which Node.js refuses to spawn without a shell, and a shell reads its arguments as commands. `resolvePackageManagerCommand` finds the package manager's own JavaScript entry instead, so the same command runs without a shell everywhere: ```ts import { resolvePackageManagerCommand, runProcess } from 'jg-process'; // the package manager running this script, read from npm_execpath, or else pnpm wherever it's // installed: in the project, or on PATH const command = resolvePackageManagerCommand(['run', 'test:unit'], { packageManager: 'pnpm' }); const { exitCode, stdout, stderr } = await runProcess({ ...command, timeoutSeconds: 600 }); process.stdout.write(stdout); process.stderr.write(stderr); process.exitCode = exitCode ?? 1; ``` It throws `PACKAGE_MANAGER_NOT_FOUND` when pnpm is nowhere to be found, which a script can turn into a message asking the developer to install it. --- # jg-clipboard > Copy and read text, HTML, and images on the browser clipboard, with support detection and coded errors that say why the browser refused. - **Version:** `0.2.0` - **Runtimes:** Browser - **Install:** `npm install jg-clipboard` - **On npm:** [jg-clipboard](https://www.npmjs.com/package/jg-clipboard) - **Source:** [packages/jg-clipboard](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-clipboard) - **Recipes:** [jg-clipboard recipes](https://jg-packages.jesusgraterol.dev/packages/jg-clipboard/recipes/index.md) ## Usage Copy from a click or key press handler. Each copy calls the clipboard before its first `await`, so the browser still sees the gesture: ```ts import { copyText, ERRORS } from 'jg-clipboard'; import { hasErrorCode } from 'jg-exception'; copyButton.addEventListener('click', async () => { try { await copyText('npm install jg-clipboard'); showToast('Copied'); } catch (error) { if (hasErrorCode(error, ERRORS.CLIPBOARD_PERMISSION_DENIED)) { showToast('Allow clipboard access to copy'); } } }); ``` Content that loads after the click can be a promise. It goes through a `ClipboardItem` that waits for it, which Safari needs: ```ts shareButton.addEventListener('click', () => { void copyText(fetch('/share-link').then((response) => response.text())); }); ``` Copy HTML with its plain text, and images. A PNG is copied as it is, and other types the browser can decode, such as JPEG or WebP, are redrawn as PNG, the only image type browsers accept on the clipboard: ```ts import { copyHtml, copyImage } from 'jg-clipboard'; await copyHtml('Order #42 shipped', { text: 'Order #42 shipped' }); await copyImage(fetch('/chart.jpg').then((response) => response.blob())); ``` Read from a paste button. `readClipboard` reads the text, HTML, and image with one call, so the person pasting sees one prompt at most. An image the browser can't read comes back as `null` when text or HTML was copied with it, so the text still pastes: ```ts import { readClipboard, readText } from 'jg-clipboard'; pasteButton.addEventListener('click', async () => { const { text, html, image } = await readClipboard(); // each null when the clipboard lacks it input.value = text ?? (await readText()); }); ``` Show only what the browser supports. `getClipboardSupport` never throws, and reports everything as `false` during server rendering: ```ts import { getClipboardSupport } from 'jg-clipboard'; const support = getClipboardSupport(); // { isSecureContext, copyText, copyHtml, copyImage, readText, read } copyImageButton.hidden = !support.copyImage; ``` ### Errors Every failure is an `Exception` with one of these codes, and the browser's error as its `cause`: | Code | When | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `CLIPBOARD_UNSUPPORTED` | The page isn't a secure context, such as HTTPS or localhost, or the browser lacks the API | | `CLIPBOARD_NOT_FOCUSED` | The browser refused because the page doesn't have focus | | `CLIPBOARD_NO_USER_GESTURE` | The browser refused because the call didn't follow a click or a key press | | `CLIPBOARD_PERMISSION_DENIED` | The browser refused for any other reason, such as a denied or dismissed permission; `data.permission` holds the Permissions API's state, or `null` | | `CLIPBOARD_INVALID_IMAGE` | The image can't be decoded as PNG: an image to copy, or one `readClipboard` read without text or HTML beside it | | `CLIPBOARD_WRITE_FAILED` | A copy failed otherwise, such as content whose promise rejected | | `CLIPBOARD_READ_FAILED` | A read failed otherwise | ### Browser limits - The clipboard needs a secure context: HTTPS, or `localhost` while developing. - Copy and read from a click or key press handler. Safari refuses calls that don't follow one directly, and Chromium needs the page to have focus. - Reading asks first. Safari and Firefox show a paste prompt each time, and Chromium asks once for the clipboard-read permission. - Images go on the clipboard as PNG only. Converting another type needs `createImageBitmap`, so it works in every current browser, but not in very old ones. - Without `ClipboardItem`, a promised text loads before the copy starts, and Safari may then refuse it. ## jg-clipboard API reference Every export of `jg-clipboard@0.2.0`, 10 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. - Functions: [`copyHtml`](#api-copyHtml), [`copyImage`](#api-copyImage), [`copyText`](#api-copyText), [`getClipboardSupport`](#api-getClipboardSupport), [`readClipboard`](#api-readClipboard), [`readText`](#api-readText) - Constants: [`ERRORS`](#api-ERRORS) - Types: [`IClipboardContent`](#api-IClipboardContent), [`IClipboardSupport`](#api-IClipboardSupport), [`ICopyHtmlOptions`](#api-ICopyHtmlOptions) ### Functions #### `copyHtml` *Function* Copies HTML to the clipboard, with plain text for places that can't paste HTML: `options.text`, or else the HTML's text content. Call it from a click or key press handler; like copyText, it calls the clipboard before its first await, and either value can be a promise. ```ts copyHtml( html: string | Promise, options?: ICopyHtmlOptions, ): Promise ``` **Parameters** - `html` `string | Promise`: The HTML, or a promise of it. - `options?` `ICopyHtmlOptions`: The plain text that goes with it. Defaults to `{}`. **Returns** `Promise`: A promise that resolves once the HTML is on the clipboard. **Throws** - `CLIPBOARD_UNSUPPORTED`: The clipboard can't copy HTML here. - `CLIPBOARD_NOT_FOCUSED`: The page must have focus to use the clipboard. - `CLIPBOARD_NO_USER_GESTURE`: The browser only allows this right after a click or a key press. - `CLIPBOARD_PERMISSION_DENIED`: The permission to use the clipboard was denied. - `CLIPBOARD_WRITE_FAILED`: The content couldn't be copied to the clipboard. **Example** ```ts import { copyHtml } from 'jg-clipboard'; button.addEventListener('click', () => { void copyHtml('Order #42 shipped', { text: 'Order #42 shipped' }); }); ``` #### `copyImage` *Function* Copies an image to the clipboard as PNG, the only image type browsers accept there. A PNG is written as it is, and other types the browser can decode, such as JPEG or WebP, are redrawn as PNG. Call it from a click or key press handler; it calls the clipboard before its first await, and the image can be a promise, such as a fetched Blob. ```ts copyImage(image: Blob | Promise): Promise ``` **Parameters** - `image` `Blob | Promise`: The image, or a promise of it. **Returns** `Promise`: A promise that resolves once the image is on the clipboard. **Throws** - `CLIPBOARD_UNSUPPORTED`: The clipboard can't copy images here. - `CLIPBOARD_INVALID_IMAGE`: The image couldn't be decoded, so it can't be copied as PNG. - `CLIPBOARD_NOT_FOCUSED`: The page must have focus to use the clipboard. - `CLIPBOARD_NO_USER_GESTURE`: The browser only allows this right after a click or a key press. - `CLIPBOARD_PERMISSION_DENIED`: The permission to use the clipboard was denied. - `CLIPBOARD_WRITE_FAILED`: The content couldn't be copied to the clipboard. **Example** ```ts import { copyImage } from 'jg-clipboard'; button.addEventListener('click', () => { void copyImage(fetch('/chart.png').then((response) => response.blob())); }); ``` #### `copyText` *Function* Copies text to the clipboard. Call it from a click or key press handler. It calls the clipboard before its first await, so the browser still sees that gesture. Text that loads after the click, such as from a request, can be a promise: it goes through a ClipboardItem that waits for it, which Safari needs. Browsers without ClipboardItem wait for the promise first, and may then refuse the copy. ```ts copyText(text: string | Promise): Promise ``` **Parameters** - `text` `string | Promise`: The text, or a promise of it. **Returns** `Promise`: A promise that resolves once the text is on the clipboard. **Throws** - `CLIPBOARD_UNSUPPORTED`: The clipboard can't copy text here. - `CLIPBOARD_NOT_FOCUSED`: The page must have focus to use the clipboard. - `CLIPBOARD_NO_USER_GESTURE`: The browser only allows this right after a click or a key press. - `CLIPBOARD_PERMISSION_DENIED`: The permission to use the clipboard was denied. - `CLIPBOARD_WRITE_FAILED`: The content couldn't be copied to the clipboard. **Example 1** ```ts import { copyText } from 'jg-clipboard'; button.addEventListener('click', () => { void copyText('npm install jg-clipboard'); }); ``` **Example 2** ```ts import { copyText } from 'jg-clipboard'; // the text loads after the click button.addEventListener('click', () => { void copyText(fetch('/share-link').then((response) => response.text())); }); ``` #### `getClipboardSupport` *Function* Tells what the browser lets the page do with the clipboard, from feature detection and `ClipboardItem.supports` where the browser has it. It never throws, and it's safe to call during server rendering, where everything is false. A supported feature can still be refused, such as without a click or a permission. ```ts getClipboardSupport(): IClipboardSupport ``` **Returns** `IClipboardSupport`: Whether the page is a secure context, and whether each function can run. **Example** ```ts import { getClipboardSupport } from 'jg-clipboard'; const support = getClipboardSupport(); if (support.copyImage) { showCopyImageButton(); } ``` #### `readClipboard` *Function* Reads the clipboard's text, HTML, and image with one `read()` call, so the person pasting sees one prompt at most. Call it from a click or key press handler, which Safari requires. An image the browser can't read never hides the text or HTML copied with it, such as the picture of spreadsheet cells that comes with their text: the image is then null. ```ts readClipboard(): Promise ``` **Returns** `Promise`: A promise that resolves to each kind of content, with null for the kinds the clipboard doesn't hold or the image it can't read. The image is PNG. **Throws** - `CLIPBOARD_UNSUPPORTED`: The clipboard can't read here. - `CLIPBOARD_NOT_FOCUSED`: The page must have focus to use the clipboard. - `CLIPBOARD_NO_USER_GESTURE`: The browser only allows this right after a click or a key press. - `CLIPBOARD_PERMISSION_DENIED`: The permission to use the clipboard was denied. - `CLIPBOARD_INVALID_IMAGE`: The clipboard holds only an image, which couldn't be decoded as PNG. - `CLIPBOARD_READ_FAILED`: The clipboard couldn't be read, or holds only an image it couldn't read. **Example** ```ts import { readClipboard } from 'jg-clipboard'; pasteButton.addEventListener('click', async () => { const { html, text, image } = await readClipboard(); if (image) { preview.src = URL.createObjectURL(image); } }); ``` #### `readText` *Function* Reads the text on the clipboard. Browsers ask before a page reads it: Safari and Firefox show a paste prompt, and Chromium asks for the clipboard-read permission once. Call it from a click or key press handler, which Safari requires. ```ts readText(): Promise ``` **Returns** `Promise`: A promise that resolves to the text, or `''` when the clipboard holds none. **Throws** - `CLIPBOARD_UNSUPPORTED`: The clipboard can't read text here. - `CLIPBOARD_NOT_FOCUSED`: The page must have focus to use the clipboard. - `CLIPBOARD_NO_USER_GESTURE`: The browser only allows this right after a click or a key press. - `CLIPBOARD_PERMISSION_DENIED`: The permission to use the clipboard was denied. - `CLIPBOARD_READ_FAILED`: The clipboard couldn't be read. **Example** ```ts import { readText } from 'jg-clipboard'; pasteButton.addEventListener('click', async () => { input.value = await readText(); }); ``` ### Constants #### `ERRORS` *Constant* the codes for every error the package throws ```ts const ERRORS: { CLIPBOARD_UNSUPPORTED: "CLIPBOARD_UNSUPPORTED"; CLIPBOARD_PERMISSION_DENIED: "CLIPBOARD_PERMISSION_DENIED"; CLIPBOARD_NO_USER_GESTURE: "CLIPBOARD_NO_USER_GESTURE"; CLIPBOARD_NOT_FOCUSED: "CLIPBOARD_NOT_FOCUSED"; CLIPBOARD_INVALID_IMAGE: "CLIPBOARD_INVALID_IMAGE"; CLIPBOARD_WRITE_FAILED: "CLIPBOARD_WRITE_FAILED"; CLIPBOARD_READ_FAILED: "CLIPBOARD_READ_FAILED" } ``` ### Types #### `IClipboardContent` *Type* what readClipboard found, with null for each kind of content the clipboard doesn't hold ```ts type IClipboardContent = { text: string | null; html: string | null; image: Blob | null; }; ``` **Members** - `text: string | null` (property) - `html: string | null` (property) - `image: Blob | null` (property): the image, as PNG #### `IClipboardSupport` *Type* what the browser lets the page do with the clipboard, each from feature detection ```ts type IClipboardSupport = { isSecureContext: boolean; copyText: boolean; copyHtml: boolean; copyImage: boolean; readText: boolean; read: boolean; }; ``` **Members** - `isSecureContext: boolean` (property): whether the page is in a secure context, such as HTTPS or localhost, which the clipboard needs - `copyText: boolean` (property): whether copyText can run - `copyHtml: boolean` (property): whether copyHtml can run - `copyImage: boolean` (property): whether copyImage can run - `readText: boolean` (property): whether readText can run - `read: boolean` (property): whether readClipboard can run #### `ICopyHtmlOptions` *Type* what copyHtml accepts besides the HTML ```ts type ICopyHtmlOptions = { text?: string | Promise; }; ``` **Members** - `text: string | Promise` (property): the plain text pasted where HTML can't go. Defaults to the HTML's text content --- # jg-clipboard recipes Patterns for copy and paste buttons that work across browsers, and say what to do when the browser refuses. ## A copy button that explains a refusal Use this for a button that copies a code snippet, a share link, or a key. Browsers refuse a copy for several reasons, and jg-clipboard names each one with a code, so the message can tell the person what to do next, and how to get the text another way: ```ts import { copyText, ERRORS } from 'jg-clipboard'; import { hasErrorCode } from 'jg-exception'; // the element whose text the button copies declare const snippet: HTMLElement; // a status line, such as an element with role="status", so screen readers announce the outcome declare const copyStatus: HTMLElement; // says what to do after a copy failed, so the text can still be copied by hand const describeCopyFailure = (error: unknown): string => { if (hasErrorCode(error, ERRORS.CLIPBOARD_PERMISSION_DENIED)) { return 'Your browser blocked copying. Select the text and copy it manually.'; } if (hasErrorCode(error, ERRORS.CLIPBOARD_NO_USER_GESTURE)) { return 'Your browser only copies right after a click. Click Copy again.'; } if (hasErrorCode(error, ERRORS.CLIPBOARD_NOT_FOCUSED)) { return 'Copying needs this tab to be in focus. Click the page, then click Copy again.'; } if (hasErrorCode(error, ERRORS.CLIPBOARD_UNSUPPORTED)) { return "This browser can't copy from this page. Select the text and copy it manually."; } return 'Copying failed. Select the text and copy it manually.'; }; button.addEventListener('click', async () => { // cleared first, so an earlier outcome never shows during this attempt copyStatus.textContent = ''; try { // nothing awaits before copyText, so the browser still sees the click await copyText(snippet.textContent ?? ''); copyStatus.textContent = 'Copied'; } catch (error) { copyStatus.textContent = describeCopyFailure(error); } }); ``` `CLIPBOARD_WRITE_FAILED`, and anything unexpected, gets the general message. A `CLIPBOARD_PERMISSION_DENIED` error's `data.permission` holds the Permissions API's state, or `null` where the browser can't say; `'denied'` means the site's settings block the clipboard, which no second click fixes. To hide a button that can never work, check `getClipboardSupport().copyText` first, and keep the messages, because a supported copy can still be refused. ## A paste button for text or an image Use this for a paste button in a composer that takes a screenshot or text. `readClipboard` reads every kind of content with one call, so the person sees one paste prompt at most, and returns an image as PNG, whatever type it was copied as. A browser that reads only text gets `readText` instead: ```ts import { ERRORS, getClipboardSupport, readClipboard, readText } from 'jg-clipboard'; import { hasErrorCode } from 'jg-exception'; // a status line that says why a paste failed declare const pasteStatus: HTMLElement; const support = getClipboardSupport(); // a browser that can't read the clipboard gets no button, and the keyboard still pastes there pasteButton.hidden = !support.read && !support.readText; // shows a pasted image, and releases the previous one, so repeated pastes don't keep it in memory const showImage = (image: Blob): void => { if (preview.src.startsWith('blob:')) { URL.revokeObjectURL(preview.src); } preview.src = URL.createObjectURL(image); }; const describePasteFailure = (error: unknown): string => { if (hasErrorCode(error, ERRORS.CLIPBOARD_PERMISSION_DENIED)) { return 'Your browser blocked pasting. Paste with Ctrl+V, or Cmd+V on a Mac, instead.'; } if (hasErrorCode(error, ERRORS.CLIPBOARD_INVALID_IMAGE)) { return "The copied image can't be read. Save it, then attach the file instead."; } return 'Pasting failed. Paste with Ctrl+V, or Cmd+V on a Mac, instead.'; }; pasteButton.addEventListener('click', async () => { pasteStatus.textContent = ''; try { // nothing awaits before the read, which Safari needs if (!support.read) { input.value = await readText(); return; } const { text, image } = await readClipboard(); if (image !== null) { showImage(image); } else if (text !== null) { input.value = text; } else { pasteStatus.textContent = 'The clipboard holds no text or image to paste.'; } } catch (error) { pasteStatus.textContent = describePasteFailure(error); } }); ``` Safari and Firefox show a paste prompt on every read, and Chromium asks for the clipboard-read permission once, so read only when the person clicks. Which content wins when the clipboard holds both depends on the app: copied spreadsheet cells can come with a picture of the cells, so a field for tables would check `text` first. `CLIPBOARD_INVALID_IMAGE` means the clipboard held only an image the browser can't decode. When text or HTML came with it, `readClipboard` returns them with `image: null` instead, so the paste falls through to the text. --- # jg-env > Strict environment variable readers for process.env or import.meta.env, plus secret files, with errors that name the key and never the value. - **Version:** `0.1.1` - **Runtimes:** Universal - **Install:** `npm install jg-env` - **On npm:** [jg-env](https://www.npmjs.com/package/jg-env) - **Source:** [packages/jg-env](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-env) - **Recipes:** [jg-env recipes](https://jg-packages.jesusgraterol.dev/packages/jg-env/recipes/index.md) ## Usage Read a service's settings once, at startup, and fail fast when one is missing or malformed: ```ts import { createEnvironmentReader } from 'jg-env'; const { readBoolean, readEnum, readInteger, readString } = createEnvironmentReader(process.env); export const NODE_ENV = readEnum('NODE_ENV', ['development', 'production', 'test']); export const PORT = readInteger('PORT'); // only plain decimal digits, so 1e3 and 0x10 fail export const DATABASE_HOST = readString('DATABASE_HOST'); export const MAINTENANCE_MODE = readBoolean('MAINTENANCE_MODE'); // exactly true or false ``` The same readers take any record of variables, such as Vite's `import.meta.env`, whose built-in fields are booleans: only string values count, and anything else is invalid. A variable that's absent, empty, or blank is unset. `readOptionalString`, `readOptionalBoolean`, `readOptionalInteger`, and `readOptionalJson` return `undefined` for an unset variable instead of throwing. Read JSON settings through a schema, such as a Zod schema: ```ts import { createEnvironmentReader } from 'jg-env'; import { z } from 'zod'; const { readJson } = createEnvironmentReader(process.env); export const ALLOWED_ORIGINS = readJson('ALLOWED_ORIGINS', z.array(z.url())); ``` ### Secret files `jg-env/node` reads secrets kept in files, such as Docker secrets. Inside a container, the secret's variable holds the path to its file; elsewhere, the file is `.txt` in a secrets directory: ```ts import { fileURLToPath } from 'node:url'; import { createSecretReader } from 'jg-env/node'; import { z } from 'zod'; const { readSecret, readSecretJson } = createSecretReader({ isRunningInContainer: process.env['IS_RUNNING_IN_CONTAINER'] === 'true', secretsDirectoryPath: fileURLToPath(new URL('../secrets/', import.meta.url)), }); export const DATABASE_PASSWORD = readSecret('DATABASE_PASSWORD'); // as it is, without trimming export const ROOT_ACCOUNT = readSecretJson('ROOT_ACCOUNT', z.object({ email: z.email() })); ``` ### Errors | Code | When | | ---------------------------- | --------------------------------------------------------------------------------------------------- | | `ENV_VARIABLE_MISSING` | A required variable is unset | | `ENV_VARIABLE_INVALID` | A value isn't a string, an allowed value, `true` or `false`, a plain integer, JSON, or its schema's | | `ENV_SECRET_FILE_UNREADABLE` | A secret's file is missing, unreadable, or empty | An error's message and data name the variable, never its value. `ENV_VARIABLE_INVALID` has no `cause`, because a JSON parser's or a schema's error can quote the value. Inside a container, `ENV_SECRET_FILE_UNREADABLE` leaves out the path and the file system's error too, since a variable set to the secret itself by mistake would otherwise print it. ## jg-env API reference Every export of `jg-env@0.1.1`, 8 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. ### `jg-env` - Functions: [`createEnvironmentReader`](#api-createEnvironmentReader) - Constants: [`ERRORS`](#api-ERRORS) - Types: [`IEnvironmentReader`](#api-IEnvironmentReader), [`IEnvironmentSource`](#api-IEnvironmentSource), [`ISchema`](#api-ISchema) #### Functions in `jg-env` ##### `createEnvironmentReader` *Function* Creates the readers for a source of environment variables. Each read looks the variable up again, so it sees the source's current value. A variable is unset when the source doesn't hold it, or holds an empty or blank string; any other value that isn't a string is invalid. Errors name the variable and never its value. ```ts createEnvironmentReader(source: IEnvironmentSource): IEnvironmentReader ``` **Parameters** - `source` `IEnvironmentSource`: Where the variables come from, such as process.env or Vite's import.meta.env. **Returns** `IEnvironmentReader`: The readers. **Example** ```ts import { createEnvironmentReader } from 'jg-env'; const { readEnum, readInteger, readString } = createEnvironmentReader({ NODE_ENV: 'production', PORT: '8080', DATABASE_HOST: 'db.internal', }); readString('DATABASE_HOST'); // 'db.internal' readInteger('PORT'); // 8080 readEnum('NODE_ENV', ['development', 'production']); // 'production' ``` #### Constants in `jg-env` ##### `ERRORS` *Constant* the codes for every error thrown by the package ```ts const ERRORS: { ENV_VARIABLE_MISSING: "ENV_VARIABLE_MISSING"; ENV_VARIABLE_INVALID: "ENV_VARIABLE_INVALID"; ENV_SECRET_FILE_UNREADABLE: "ENV_SECRET_FILE_UNREADABLE" } ``` #### Types in `jg-env` ##### `IEnvironmentReader` *Type* reads a source's variables, each time from its current values. A required read of an unset variable throws ENV_VARIABLE_MISSING, and a value that can't be used throws ENV_VARIABLE_INVALID, without a cause, so neither ever holds the value ```ts type IEnvironmentReader = { readString: (key: string) => string; readEnum: (key: string, values: readonly T[]) => T; readBoolean: (key: string) => boolean; readInteger: (key: string) => number; readJson: (key: string, schema: ISchema) => T; readOptionalString: (key: string) => string | undefined; readOptionalBoolean: (key: string) => boolean | undefined; readOptionalInteger: (key: string) => number | undefined; readOptionalJson: (key: string, schema: ISchema) => T | undefined; }; ``` **Members** - `readString: (key: string) => string` (property): reads a variable that must be set, as it is, without trimming - `readEnum: (key: string, values: readonly T[]) => T` (property): reads a variable that must be one of the allowed values - `readBoolean: (key: string) => boolean` (property): reads a variable that must be exactly true or false - `readInteger: (key: string) => number` (property): reads a variable that must be an integer in plain decimal digits, such as 8080 or -2 - `readJson: (key: string, schema: ISchema) => T` (property): reads a variable that must be JSON its schema accepts - `readOptionalString: (key: string) => string | undefined` (property): the same as readString, but undefined when the variable is unset - `readOptionalBoolean: (key: string) => boolean | undefined` (property): the same as readBoolean, but undefined when the variable is unset - `readOptionalInteger: (key: string) => number | undefined` (property): the same as readInteger, but undefined when the variable is unset - `readOptionalJson: (key: string, schema: ISchema) => T | undefined` (property): the same as readJson, but undefined when the variable is unset ##### `IEnvironmentSource` *Type* where the variables come from, such as process.env or Vite's import.meta.env. Only string values count as set, so a source may hold other values, such as import.meta.env's booleans ```ts type IEnvironmentSource = Readonly>; ``` ##### `ISchema` *Type* validates data and returns it typed, which a Zod schema does ```ts type ISchema = { parse(value: unknown): T; }; ``` **Members** - `parse(value: unknown): T` (method) ### `jg-env/node` - Functions: [`createSecretReader`](#api-node-createSecretReader) - Types: [`ISecretReader`](#api-node-ISecretReader), [`ISecretReaderOptions`](#api-node-ISecretReaderOptions) #### Functions in `jg-env/node` ##### `createSecretReader` *Function* Creates the readers for secrets kept in files, such as Docker secrets. Inside a container, each secret's variable holds the path to its file; elsewhere, the file is .txt in the secrets directory. A file is read as it is, without trimming. Errors name the secret and never its value, and inside a container they leave out the path and the file system's error, which repeat whatever the variable holds. ```ts createSecretReader(options: ISecretReaderOptions): ISecretReader ``` **Parameters** - `options` `ISecretReaderOptions`: Whether the program runs in a container, and the secrets directory. **Returns** `ISecretReader`: The readers. **Example** ```ts import { createSecretReader } from 'jg-env/node'; const { readSecret } = createSecretReader({ isRunningInContainer: process.env['IS_RUNNING_IN_CONTAINER'] === 'true', secretsDirectoryPath: '/srv/app/secrets', }); const databasePassword = readSecret('DATABASE_PASSWORD'); ``` #### Types in `jg-env/node` ##### `ISecretReader` *Type* reads secrets from files, at startup. A missing, unreadable, or empty file throws ENV_SECRET_FILE_UNREADABLE, and inside a container that error never names the path, since the path comes from a variable that might hold the secret itself by mistake ```ts type ISecretReader = { readSecret: (key: string) => string; readSecretJson: (key: string, schema: ISchema) => T; }; ``` **Members** - `readSecret: (key: string) => string` (property): reads a secret's file, as it is, without trimming - `readSecretJson: (key: string, schema: ISchema) => T` (property): reads a secret's file as JSON its schema accepts ##### `ISecretReaderOptions` *Type* where a secret reader finds each secret's file ```ts type ISecretReaderOptions = { isRunningInContainer: boolean; secretsDirectoryPath: string; }; ``` **Members** - `isRunningInContainer: boolean` (property): true where each secret's variable holds the path to its file, such as /run/secrets/DB_PASSWORD in a container - `secretsDirectoryPath: string` (property): the directory that holds each secret as .txt, used outside a container --- # jg-env recipes Patterns for reading a program's configuration once, at startup, from the environment and from secret files. ## A configuration module for a Node.js service Read every setting in one module, at startup, so a missing or malformed one stops the service before it serves anything. Node.js loads a `.env` file itself, so local development needs no other package: ```ts import { createEnvironmentReader } from 'jg-env'; import { fileURLToPath } from 'node:url'; import { createSecretReader } from 'jg-env/node'; import { z } from 'zod'; // containers set their variables themselves; local development reads them from .env if (process.env['IS_RUNNING_IN_CONTAINER'] !== 'true') { process.loadEnvFile(new URL('../.env', import.meta.url)); } const { readBoolean, readEnum, readInteger, readJson, readOptionalString, readString } = createEnvironmentReader(process.env); const { readSecret } = createSecretReader({ isRunningInContainer: readBoolean('IS_RUNNING_IN_CONTAINER'), secretsDirectoryPath: fileURLToPath(new URL('../secrets/', import.meta.url)), }); export const CONFIG = { nodeEnv: readEnum('NODE_ENV', ['development', 'production', 'test']), port: readInteger('PORT'), database: { host: readString('DATABASE_HOST'), password: readSecret('DATABASE_PASSWORD'), }, allowedOrigins: readJson('ALLOWED_ORIGINS', z.array(z.url())), sentryDsn: readOptionalString('SENTRY_DSN'), } as const; ``` Errors name the variable, never its value, so the startup failure can go to the logs as it is. ## A browser app's configuration with Vite Vite puts the app's `VITE_` variables in `import.meta.env`, next to its own booleans, such as `DEV`. The readers take the whole record, and read only the strings: ```ts import { createEnvironmentReader } from 'jg-env'; const { readOptionalBoolean, readString } = createEnvironmentReader(import.meta.env); export const API_ORIGIN = readString('VITE_API_ORIGIN'); export const SHOW_BETA_BANNER = readOptionalBoolean('VITE_SHOW_BETA_BANNER') ?? false; ``` Vite replaces `import.meta.env` when it builds, so every variable the app reads is in the bundle. Never put a secret in a `VITE_` variable. ## Docker secrets in a container and in local development In Compose, a secret's file appears under `/run/secrets`, and the service's variable points at it. Locally, the same secret is a file in a directory Git ignores: ```ts import { fileURLToPath } from 'node:url'; import { createSecretReader } from 'jg-env/node'; import { z } from 'zod'; // compose.yaml sets STRIPE_SECRET_KEY=/run/secrets/STRIPE_SECRET_KEY in the container; // local development keeps the key in secrets/STRIPE_SECRET_KEY.txt const { readSecret, readSecretJson } = createSecretReader({ isRunningInContainer: process.env['IS_RUNNING_IN_CONTAINER'] === 'true', secretsDirectoryPath: fileURLToPath(new URL('../secrets/', import.meta.url)), }); export const STRIPE_SECRET_KEY = readSecret('STRIPE_SECRET_KEY'); export const FIREBASE_OPTIONS = readSecretJson( 'FIREBASE_OPTIONS', z.object({ projectId: z.string(), apiKey: z.string() }), ); ``` A secret is read as it is, so write its file without a trailing newline, as `printf '%s' "$KEY" > secrets/STRIPE_SECRET_KEY.txt` does. An empty file fails with `ENV_SECRET_FILE_UNREADABLE`. --- # jg-postgres > node-postgres helpers: transactions that report an unknown commit, advisory locks, SQLSTATE checks, cursor pages, and ordered lists. - **Version:** `0.3.0` - **Runtimes:** Node.js - **Install:** `npm install jg-postgres` - **On npm:** [jg-postgres](https://www.npmjs.com/package/jg-postgres) - **Source:** [packages/jg-postgres](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-postgres) - **Recipes:** [jg-postgres recipes](https://jg-packages.jesusgraterol.dev/packages/jg-postgres/recipes/index.md) ## Usage Create one pool per database, with timeouts, and close it at shutdown: ```ts import { createPostgres } from 'jg-postgres'; const postgres = createPostgres({ host: 'db.internal', database: 'app', user: 'app', password: 'secret', max: 10, connectionTimeoutMillis: 30_000, // waiting for a connection statement_timeout: 60_000, // one statement, on the server lock_timeout: 30_000, // waiting for a row or table lock idle_in_transaction_session_timeout: 300_000, // a transaction left open onError: (error) => console.error('PostgreSQL', error), }); const users = await postgres.query<{ id: string }>('SELECT id FROM user_account LIMIT $1;', [10]); await postgres.close(); ``` The pool reads `NUMERIC` as a number and `TIMESTAMPTZ` as an ISO 8601 UTC string, such as `'2026-10-17T08:30:00.000Z'`, and keeps `BIGINT` as a string, so no digit of a large amount is lost. The parsers are set on the pool, not node-postgres's global registry, so other pools in the program keep theirs. Errors no caller awaits, such as an idle connection the server closed, go to `onError`, `console.error` by default, instead of crashing the process. ### Transactions ```ts import { createPostgres } from 'jg-postgres'; declare const postgres: ReturnType; const orderId = await postgres.transaction(async (client) => { const { rows } = await client.query<{ id: string }>( 'INSERT INTO purchase_order (user_account_id) VALUES ($1) RETURNING id;', ['42'], ); await client.query('UPDATE user_account SET order_count = order_count + 1 WHERE id = $1;', [ '42', ]); return rows[0]?.id; }); ``` `transaction` commits when the operation resolves, rolls back when it throws, and rejects with the operation's own error. It always returns its client to the pool, or destroys it when its state is unknown. When the connection breaks during `COMMIT`, nobody can tell whether the transaction committed, so it rejects with `TRANSACTION_OUTCOME_UNKNOWN`: check the data before retrying anything that isn't idempotent. Run work inside a transaction on its `client`, and never start a transaction inside another. ### Timeouts A profile says how long a kind of work may run: a statement on the server, a wait for a lock, and the client's wait for the answer, which adds a grace, 15 seconds by default, so the server's own error arrives first. Give the pool the profile of everyday work, and a slow transaction one of its own: ```ts import { createPostgres, defineTimeoutProfile, toPoolTimeouts, withQueryTimeout, } from 'jg-postgres'; const STANDARD = defineTimeoutProfile({ statementTimeoutSeconds: 15, lockTimeoutSeconds: 5 }); const REPORT = defineTimeoutProfile({ statementTimeoutSeconds: 300, lockTimeoutSeconds: 30 }); const postgres = createPostgres({ connectionString, ...toPoolTimeouts(STANDARD) }); await postgres.transaction( (client) => client.query(withQueryTimeout({ text: 'SELECT build_report($1);', values: [month] }, REPORT)), { timeouts: REPORT }, ); ``` `timeouts` sets the statement and lock timeouts for that transaction only, so the client goes back to the pool with the pool's. The pool's `query_timeout` still applies to each query that doesn't set its own, so pass the transaction's queries through `withQueryTimeout` with the same profile. A query the client stops waiting for is rolled back: the client goes back to the pool once `ROLLBACK`, which has the pool's `query_timeout` too, confirms the connection, and is destroyed otherwise. ### Optional reads `createOptionalReader` runs reads that are nice to have, such as a counter in a page's header, without ever delaying or crowding out essential work. A read is skipped, giving `null` at once, when the reader already runs `maxActiveReads` reads, a request waits for a client, or the pool can't give more clients than `reservedIdleClients`, counting idle ones and the room left to open new ones. It runs in a read-only transaction with short timeouts, gives `null` after `admissionSeconds`, and never throws: ```ts import { createOptionalReader } from 'jg-postgres'; const counters = createOptionalReader({ postgres }); // 2 reads at most, 2 clients left free, 1 second const unread = counters.read( `unread:${accountId}`, // callers of the same key share one read { text: 'SELECT count(*)::int AS count FROM notification WHERE account_id = $1;', values: [accountId], }, (row) => (row as { count: number }).count, ); const count = await unread.completion; // a number, or null ``` ### Usage limits `admitUsage` decides, atomically, whether an actor may do a rate-limited action now, such as sending an invitation, and records it when they may. It refuses with `cooldown` while the same action on the same scope was accepted less than `cooldownSeconds` ago, by any actor, and with `limit` once the actor did the action `limit` times within `windowSeconds`: ```ts import { admitUsage } from 'jg-postgres'; const admission = await admitUsage(postgres, { actorId: userId, actionId: 'send-invitation', scopeKey: inviteeEmailHash, // the cooldown applies per invitee limit: 20, windowSeconds: 86_400, cooldownSeconds: 60, }); // { outcome: 'accepted' | 'cooldown' | 'limit', retryAt: an ISO timestamp, or null } ``` Concurrent calls queue on transaction advisory locks, the scope's then the actor's, so two can never both pass, and every comparison uses the database's clock. The accepted actions go in a `usage_event` table: ```sql CREATE TABLE usage_event ( id bigint GENERATED ALWAYS AS IDENTITY, actor_id varchar(256) NOT NULL, action_id varchar(128) NOT NULL, scope_key varchar(256) NULL, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT pk_ue__id PRIMARY KEY (id), CONSTRAINT chk_ue__actor_id_non_empty CHECK (actor_id <> ''), CONSTRAINT chk_ue__action_id_non_empty CHECK (action_id <> ''), CONSTRAINT chk_ue__scope_key_non_empty CHECK (scope_key <> '') ); CREATE INDEX idx_ue__actor_id_action_id_created_at ON usage_event (actor_id, action_id, created_at); CREATE INDEX idx_ue__action_id_scope_key_created_at_scoped ON usage_event (action_id, scope_key, created_at) WHERE scope_key IS NOT NULL; CREATE INDEX idx_ue__created_at ON usage_event (created_at); ``` The table grows with every accepted action. Prune it on a schedule, deleting in batches the rows older than the longest window or cooldown, `$1` seconds, `$2` rows at a time, such as 1024, and running it again while it deletes a whole batch. It compares with `now()`, which in a statement of its own is the statement's start, because the index can bound a scan by `now()` but not by `clock_timestamp()`: ```sql DELETE FROM usage_event WHERE id IN ( SELECT id FROM usage_event WHERE created_at < now() - make_interval(secs => $1) ORDER BY created_at LIMIT $2 ); ``` ### Verification challenges `createVerificationChallenges` issues and checks short codes bound to an actor, an action, and a target, such as the code that confirms a new email address. Each action has a policy: how long a code is valid, how many wrong codes it takes, and how many codes an actor may be issued within a window, over every target: ```ts import { createVerificationChallenges } from 'jg-postgres'; const challenges = createVerificationChallenges(postgres, { policies: { 'verify-email': { validitySeconds: 600, maxFailedAttempts: 5, issueLimit: 5, issueWindowSeconds: 3_600, }, }, codeLength: 6, // the default }); const binding = { actorId: userId, actionId: 'verify-email', targetId: newEmail }; const issued = await challenges.issue(binding, ({ code, challengeId }) => mailer.send(newEmail, code, { idempotencyKey: challengeId }), ); // { outcome: 'issued', challengeToken, expiresAt } or { outcome: 'limit', retryAt } // later, with the token the client kept and the code the user typed const isVerified = await challenges.consume({ ...binding, challengeToken: savedToken, code: typedCode, }); ``` `issue` refuses with `limit` once the actor was issued `issueLimit` codes within the window, with `retryAt`, when the oldest of them leaves it. Otherwise it supersedes the binding's active challenge and commits the new one, then calls `deliver` with the code, the only time it's available. A delivery that throws makes `issue` throw `VERIFICATION_DELIVERY_FAILED`, with the code and the token redacted from it and its cause; the challenge still counts toward the limit. Concurrent issues for an actor and an action queue on a transaction advisory lock, and every comparison uses the database's clock. `consume` resolves to `true` once, for the right code. A wrong code counts a failed attempt, and a challenge stops accepting codes after `maxFailedAttempts` of them, so the policy's current value applies. A used, superseded, or expired challenge, another binding's token, or a token or code of the wrong shape resolves to `false` without counting, and every `false` looks the same to the caller. Only the SHA-256 hashes of the token and of the code, combined with its token, are stored, in a `verification_challenge` table: ```sql CREATE TABLE verification_challenge ( id bigint GENERATED ALWAYS AS IDENTITY, actor_id varchar(256) NOT NULL, action_id varchar(128) NOT NULL, target_id varchar(256) NOT NULL, token_hash text NOT NULL, code_hash text NOT NULL, failed_attempt_count smallint NOT NULL DEFAULT 0, expires_at timestamptz NOT NULL, used_at timestamptz NULL, superseded_at timestamptz NULL, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT pk_vc__id PRIMARY KEY (id), CONSTRAINT uq_vc__token_hash UNIQUE (token_hash), CONSTRAINT chk_vc__actor_id_non_empty CHECK (actor_id <> ''), CONSTRAINT chk_vc__action_id_non_empty CHECK (action_id <> ''), CONSTRAINT chk_vc__target_id_non_empty CHECK (target_id <> ''), CONSTRAINT chk_vc__token_hash_sha256 CHECK (token_hash ~ '^[0-9a-f]{64}$'), CONSTRAINT chk_vc__code_hash_sha256 CHECK (code_hash ~ '^[0-9a-f]{64}$'), CONSTRAINT chk_vc__failed_attempt_count_non_negative CHECK (failed_attempt_count >= 0), CONSTRAINT chk_vc__expires_at_chronological CHECK (expires_at > created_at), CONSTRAINT chk_vc__used_at_superseded_at_mutually_exclusive CHECK (used_at IS NULL OR superseded_at IS NULL) ); CREATE UNIQUE INDEX uidx_vc__actor_id_action_id_target_id_active ON verification_challenge (actor_id, action_id, target_id) WHERE used_at IS NULL AND superseded_at IS NULL; CREATE INDEX idx_vc__actor_id_action_id_created_at ON verification_challenge (actor_id, action_id, created_at); CREATE INDEX idx_vc__created_at ON verification_challenge (created_at); ``` Prune it on a schedule as `usage_event` is pruned, deleting in batches the rows older than the longest `validitySeconds` or `issueWindowSeconds` of any policy, `$1` seconds, `$2` rows at a time. A younger row may still be consumed, or count toward a limit: ```sql DELETE FROM verification_challenge WHERE id IN ( SELECT id FROM verification_challenge WHERE created_at < now() - make_interval(secs => $1) ORDER BY created_at LIMIT $2 ); ``` ### Database summaries `readDatabaseSummary` and `listTableSizes` read what an admin dashboard or a health check shows about the database, and `diffTables` compares the tables with the ones a migration should have created: ```ts import { diffTables, listTableSizes, readDatabaseSummary } from 'jg-postgres'; const summary = await readDatabaseSummary(postgres.pool); // the public schema, or { schema } // { name, serverVersion, serverVersionNumber, sizeBytes, activeConnections, maxConnections, tableCount } const tables = await listTableSizes(postgres.pool); // [{ name, totalBytes }], sorted by name const { missing, unexpected } = diffTables( tables.map(({ name }) => name), ['app_user', 'session'], ); ``` Sizes and counts are numbers, and sizes include indexes and TOAST data. `activeConnections` counts the connections to this database running a query. Without the `pg_read_all_stats` role, PostgreSQL shows a role only its own connections, so the count is lower than the server's. ### Counter tables `defineCounterTable` builds the queries that change counter rows, such as an account's daily usage or a balance, quoting every name once and sending every value as a parameter. `applyCounterQueries` runs them in the caller's transaction, sorted by row, so two transactions that change the same rows lock them in the same order and never deadlock: ```ts import { applyCounterQueries, defineCounterTable } from 'jg-postgres'; const dailyUsage = defineCounterTable({ table: 'daily_usage', keyColumns: ['account_id', 'day'], counters: ['request_count', 'token_count'], }); await postgres.transaction((client) => applyCounterQueries(client, [ // inserts the row, or adds to it dailyUsage.buildIncrement( { account_id: accountId, day }, { request_count: 1, token_count: tokens }, ), // adds to a row that must exist, or throws COUNTER_ROW_NOT_FOUND dailyUsage.buildAdjustment({ account_id: accountId, day }, { token_count: -refunded }), ]), ); ``` An increment is an `INSERT … ON CONFLICT (key columns) DO UPDATE`, so the table needs a unique constraint on exactly the key columns, and a default, such as 0, for every counter. Amounts are whole numbers or bigints within `BIGINT`: positive to increment, and non-zero to adjust. A counter that would leave `BIGINT`'s range fails with SQLSTATE 22003, which `isNumericValueOutOfRangeError` checks, and the transaction rolls back. ### Constraint errors ```ts import { isUniqueViolationError } from 'jg-postgres'; declare const error: unknown; if (isUniqueViolationError(error, 'uq_user_account__email')) { // the email is already registered } ``` `isDatabaseError` and the checks for unique, foreign key, not-null, check, and restrict violations, string truncation, numeric overflow, invalid text, serialization failures, deadlocks, and canceled queries read the SQLSTATE code. The constraint checks take an optional constraint name. ### Advisory locks `createAdvisoryLockKey` turns a name into the 64-bit key PostgreSQL's advisory locks take. `lockTransaction` holds one until the transaction ends, waiting while another holds it. `withSessionLock` holds one on a connection across several transactions, or skips the work when another session holds it: ```ts import { createAdvisoryLockKey, createPostgres, withSessionLock } from 'jg-postgres'; declare const postgres: ReturnType; const outcome = await withSessionLock( postgres, createAdvisoryLockKey('user-account-deletion:42'), async (lock) => { lock.assertActive(); // throws SESSION_LOCK_LOST once the lock's connection is gone return 'deleted'; }, ); // { acquired: false } when another request holds the lock ``` ### Cursor pages and ordered lists `listByCursor` lists a page of rows after a cursor, and `buildCursorPage` turns a page listed with one extra row into `{ items, nextCursor }`. `moveUp` and `moveDown` swap a row's sort order with its neighbor's, in their own transaction under the list's lock, and `lockOrderedList` takes the same lock for an append. Every table and column name is quoted. ### A record store `jg-postgres/record-store` keeps small JSON records, such as counters or a feature's settings, one row each in a `record_store` table, which the app creates: ```sql CREATE TABLE IF NOT EXISTS record_store ( id varchar(512), value jsonb NOT NULL, CONSTRAINT pk_rs__id PRIMARY KEY (id), CONSTRAINT chk_rs__id_non_empty CHECK (id <> ''), CONSTRAINT chk_rs__value_object CHECK (jsonb_typeof(value) = 'object') ); ``` ```ts import { createPostgres } from 'jg-postgres'; import { createRecordStore } from 'jg-postgres/record-store'; import { z } from 'zod'; declare const postgres: ReturnType; const counters = createRecordStore({ postgres, id: 'admin:unread-counts', defaults: { notifications: 0, reports: 0 }, schema: z.object({ notifications: z.number(), reports: z.number() }), }); await counters.incrementNumber('notifications', 1); // atomic, so concurrent increments all count await counters.setNumber('reports', 0); // leaves the other fields as they are await counters.get(); // { notifications: 1, reports: 0 }, checked with the schema ``` The defaults fill every missing or null field when the record is read, and are the only keys a write keeps. Every method takes an optional transaction's client, to run in that transaction without committing it. ### Errors | Code | When | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `TRANSACTION_OUTCOME_UNKNOWN` | The connection was lost during `COMMIT` | | `SESSION_LOCK_LOST` | The connection that held a session lock was lost | | `INVALID_IDENTIFIER` | A name is empty, holds a null character, or is longer than 63 bytes | | `ROW_NOT_FOUND` | The row to move doesn't exist in the ordered list | | `ROW_ALREADY_FIRST` | The row to move up is already first | | `ROW_ALREADY_LAST` | The row to move down is already last | | `MOVE_FAILED` | The move didn't update exactly two rows | | `INVALID_TIMEOUT_PROFILE` | A timeout profile's seconds aren't positive and finite, or its lock timeout is longer than its statement timeout | | `INVALID_OPTIONAL_READ_OPTIONS` | An optional reader's database, limits, or timeouts aren't valid | | `INVALID_USAGE_ADMISSION` | A usage admission's ids, limit, or seconds aren't valid, or it has a cooldown without a scope | | `INVALID_VERIFICATION_POLICY` | The verification policies or the code length aren't valid | | `INVALID_VERIFICATION_INPUT` | A verification binding's ids aren't valid, its action has no policy, or issue has no delivery function | | `VERIFICATION_DELIVERY_FAILED` | The delivery function threw, after the challenge was committed | | `INVALID_COUNTER_TABLE` | A counter table has no key column or no counter, or repeats a name | | `INVALID_COUNTER_CHANGE` | A counter row's key doesn't have exactly the key columns, or an amount isn't a whole, non-zero amount within `BIGINT` | | `COUNTER_ROW_NOT_FOUND` | A counter query changed no row, because the row to adjust doesn't exist | A query's own error, such as a constraint violation, is node-postgres's `DatabaseError`, unchanged. ## jg-postgres API reference Every export of `jg-postgres@0.3.0`, 76 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. ### `jg-postgres` - Functions: [`admitUsage`](#api-admitUsage), [`applyCounterQueries`](#api-applyCounterQueries), [`buildCursorPage`](#api-buildCursorPage), [`createAdvisoryLockKey`](#api-createAdvisoryLockKey), [`createOptionalReader`](#api-createOptionalReader), [`createPostgres`](#api-createPostgres), [`createVerificationChallenges`](#api-createVerificationChallenges), [`defineCounterTable`](#api-defineCounterTable), [`defineTimeoutProfile`](#api-defineTimeoutProfile), [`diffTables`](#api-diffTables), [`isCheckViolationError`](#api-isCheckViolationError), [`isDatabaseError`](#api-isDatabaseError), [`isDeadlockDetectedError`](#api-isDeadlockDetectedError), [`isForeignKeyViolationError`](#api-isForeignKeyViolationError), [`isInvalidTextRepresentationError`](#api-isInvalidTextRepresentationError), [`isNotNullViolationError`](#api-isNotNullViolationError), [`isNumericValueOutOfRangeError`](#api-isNumericValueOutOfRangeError), [`isQueryCanceledError`](#api-isQueryCanceledError), [`isRestrictViolationError`](#api-isRestrictViolationError), [`isSerializationFailureError`](#api-isSerializationFailureError), [`isStringDataRightTruncationError`](#api-isStringDataRightTruncationError), [`isUniqueViolationError`](#api-isUniqueViolationError), [`listByCursor`](#api-listByCursor), [`listTableSizes`](#api-listTableSizes), [`lockOrderedList`](#api-lockOrderedList), [`lockTransaction`](#api-lockTransaction), [`moveDown`](#api-moveDown), [`moveUp`](#api-moveUp), [`quoteIdentifier`](#api-quoteIdentifier), [`readDatabaseSummary`](#api-readDatabaseSummary), [`toPoolTimeouts`](#api-toPoolTimeouts), [`withQueryTimeout`](#api-withQueryTimeout), [`withSessionLock`](#api-withSessionLock) - Constants: [`ERRORS`](#api-ERRORS), [`POSTGRES_BIGINT_MAX`](#api-POSTGRES_BIGINT_MAX), [`POSTGRES_BIGINT_MIN`](#api-POSTGRES_BIGINT_MIN) - Types: [`IColumnFilter`](#api-IColumnFilter), [`ICounterChanges`](#api-ICounterChanges), [`ICounterKey`](#api-ICounterKey), [`ICounterQuery`](#api-ICounterQuery), [`ICounterTable`](#api-ICounterTable), [`ICounterTableDefinition`](#api-ICounterTableDefinition), [`ICursorListingOptions`](#api-ICursorListingOptions), [`ICursorPage`](#api-ICursorPage), [`IDatabaseSummary`](#api-IDatabaseSummary), [`IIntrospectionOptions`](#api-IIntrospectionOptions), [`IMoveOptions`](#api-IMoveOptions), [`IOptionalRead`](#api-IOptionalRead), [`IOptionalReader`](#api-IOptionalReader), [`IOptionalReaderOptions`](#api-IOptionalReaderOptions), [`IOrderedList`](#api-IOrderedList), [`IPostgres`](#api-IPostgres), [`IPostgresConfig`](#api-IPostgresConfig), [`IQueryExecutor`](#api-IQueryExecutor), [`IRowId`](#api-IRowId), [`ISessionLock`](#api-ISessionLock), [`ISessionLockResult`](#api-ISessionLockResult), [`ITableDiff`](#api-ITableDiff), [`ITableSize`](#api-ITableSize), [`ITimeoutProfile`](#api-ITimeoutProfile), [`ITransactionOptions`](#api-ITransactionOptions), [`IUsageAdmission`](#api-IUsageAdmission), [`IUsageAdmissionInput`](#api-IUsageAdmissionInput), [`IVerificationAttempt`](#api-IVerificationAttempt), [`IVerificationBinding`](#api-IVerificationBinding), [`IVerificationChallenges`](#api-IVerificationChallenges), [`IVerificationChallengesOptions`](#api-IVerificationChallengesOptions), [`IVerificationDeliver`](#api-IVerificationDeliver), [`IVerificationDelivery`](#api-IVerificationDelivery), [`IVerificationIssue`](#api-IVerificationIssue), [`IVerificationPolicy`](#api-IVerificationPolicy) #### Functions in `jg-postgres` ##### `admitUsage` *Function* Decides, atomically, whether an actor may do a rate-limited action now, such as sending an invitation, and records it when they may. It refuses with `cooldown` while the same action on the same scope was accepted less than `cooldownSeconds` ago, by any actor, and with `limit` once the actor did the action `limit` times within `windowSeconds`. Concurrent calls queue on transaction advisory locks, the scope's then the actor's, so they never both pass, and every comparison uses the database's clock. It needs the `usage_event` table the README defines. ```ts admitUsage( postgres: IPostgres, input: IUsageAdmissionInput, ): Promise ``` **Parameters** - `postgres` `IPostgres`: The database. - `input` `IUsageAdmissionInput`: The actor, the action, the optional scope, the limit and its window, and the optional cooldown. **Returns** `Promise`: A promise that resolves to the outcome, with `retryAt`, an ISO 8601 UTC timestamp: when the scope's cooldown ends, or null without one. **Throws** - `INVALID_USAGE_ADMISSION`: The usage admission needs an actor id of 1 to 256 characters, an action id of 1 to 128, a scope key of 1 to 256 when given, a whole positive limit, and positive seconds, with a cooldown only for a scope. **Example** ```ts import { admitUsage, createPostgres } from 'jg-postgres'; declare const postgres: ReturnType; const admission = await admitUsage(postgres, { actorId: '42', actionId: 'send-invitation', scopeKey: 'invitee@example.com', limit: 20, windowSeconds: 86_400, cooldownSeconds: 60, }); if (admission.outcome !== 'accepted') { // tell the user when to try again: admission.retryAt, or tomorrow for 'limit' } ``` ##### `applyCounterQueries` *Function* Runs counter queries one after another on a transaction's client, sorted by their lock keys, so two transactions changing the same rows lock them in the same order and never deadlock each other. Queries for the same row keep their order. Each must change exactly one row, so an adjustment of a row that doesn't exist throws, and the caller's transaction rolls back. A counter that would leave BIGINT's range fails with SQLSTATE 22003, as isNumericValueOutOfRangeError checks. ```ts applyCounterQueries( client: PoolClient, queries: readonly ICounterQuery[], ): Promise ``` **Parameters** - `client` `PoolClient`: The client of the transaction to run them in. - `queries` `readonly ICounterQuery[]`: The queries, from defineCounterTable's builders. The list isn't changed. **Returns** `Promise`: A promise that resolves once every query ran. **Throws** - `COUNTER_ROW_NOT_FOUND`: The counter query changed no row, because the row to adjust doesn't exist. **Example** ```ts import { applyCounterQueries, createPostgres, defineCounterTable } from 'jg-postgres'; declare const postgres: ReturnType; const balances = defineCounterTable({ table: 'account_balance', keyColumns: ['account_id'], counters: ['credit_count'], }); await postgres.transaction((client) => applyCounterQueries(client, [ balances.buildAdjustment({ account_id: '42' }, { credit_count: -10 }), balances.buildIncrement({ account_id: '7' }, { credit_count: 10 }), ]), ); ``` ##### `buildCursorPage` *Function* Builds a page from rows listed with one more than the page's size, so the extra row tells whether another page follows without counting the table. ```ts buildCursorPage( rows: readonly T[], limit: number, toCursor: (row: T) => string, ): ICursorPage ``` **Parameters** - `rows` `readonly T[]`: The rows, listed with a limit of the page size plus one. - `limit` `number`: The page size. - `toCursor` `(row: T) => string`: Reads the cursor from a row, such as its ID. **Returns** `ICursorPage`: The page's rows, and the last one's cursor when more follow, otherwise null. **Example** ```ts import { buildCursorPage } from 'jg-postgres'; buildCursorPage([{ id: '3' }, { id: '2' }, { id: '1' }], 2, ({ id }) => id); // => { items: [{ id: '3' }, { id: '2' }], nextCursor: '2' } ``` ##### `createAdvisoryLockKey` *Function* Turns a lock's name into the signed 64-bit key PostgreSQL's advisory locks take: the first 8 bytes of the name's SHA-256 hash. The same name always gives the same key, in every process. ```ts createAdvisoryLockKey(name: string): string ``` **Parameters** - `name` `string`: The lock's name, such as `user-account-deletion:42`. **Returns** `string`: The key, as a string, such as `'7480330268761432310'`. **Example** ```ts import { createAdvisoryLockKey } from 'jg-postgres'; createAdvisoryLockKey('moldea'); // => '7480330268761432310' ``` ##### `createOptionalReader` *Function* Creates a reader for optional data, such as counters in a page's header, that must never delay or crowd out essential work. A read is skipped, giving null at once, when the reader already runs maxActiveReads reads, a request waits for a client, or the pool can't give more clients than reservedIdleClients, counting idle ones and the room left to open new ones. A running read gives null once admissionSeconds pass, and is shared by every caller of the same key until it ends. Each read runs in a read-only transaction with short timeouts of its own, and never throws. ```ts createOptionalReader(options: IOptionalReaderOptions): IOptionalReader ``` **Parameters** - `options` `IOptionalReaderOptions`: The database, and optionally the limits and the reads' timeouts. **Returns** `IOptionalReader`: The reader. **Throws** - `INVALID_OPTIONAL_READ_OPTIONS`: The optional reader needs a database, whole positive maxActiveReads, whole reservedIdleClients from 0, positive admissionSeconds, and a valid timeout profile. **Example** ```ts import { createOptionalReader, createPostgres } from 'jg-postgres'; declare const postgres: ReturnType; declare const accountId: string; const counters = createOptionalReader({ postgres }); const unread = counters.read( `unread:${accountId}`, { text: 'SELECT count(*)::int AS count FROM notification WHERE account_id = $1 AND read_at IS NULL;', values: [accountId] }, (row) => (row as { count: number }).count, ); const count = await unread.completion; // a number, or null when the database is busy ``` ##### `createPostgres` *Function* Creates a PostgreSQL pool with the helpers every query, transaction, and lock uses. The pool parses NUMERIC as a number and TIMESTAMPTZ as an ISO 8601 UTC string, keeps BIGINT as a string, and sends errors no caller awaits, such as an idle connection the server closed, to onError instead of crashing the process. ```ts createPostgres(config: IPostgresConfig): IPostgres ``` **Parameters** - `config` `IPostgresConfig`: node-postgres's pool configuration, plus onError. **Returns** `IPostgres`: The pool and its helpers. **Example** ```ts import { createPostgres } from 'jg-postgres'; const postgres = createPostgres({ host: 'localhost', database: 'app', user: 'app', password: 'secret', max: 10, connectionTimeoutMillis: 30_000, statement_timeout: 60_000, lock_timeout: 30_000, idle_in_transaction_session_timeout: 300_000, }); const users = await postgres.query<{ id: string }>('SELECT id FROM user_account LIMIT $1;', [10]); await postgres.close(); ``` ##### `createVerificationChallenges` *Function* Creates the functions that issue and consume verification challenges, such as the code that confirms a new email address. A challenge is bound to an actor, an action, and a target; only the SHA-256 hashes of its token and code are stored, in a `verification_challenge` table the README defines, and every comparison uses the database's clock. `issue` refuses with `limit` once the actor was issued the action's `issueLimit` challenges within `issueWindowSeconds`, over every target, so concurrent issues queue on a transaction advisory lock. Otherwise it supersedes the binding's active challenge, commits the new one, and calls `deliver` with the code. `consume` uses the challenge when the code matches, or counts a failed attempt, and every failure resolves to `false` alike. ```ts createVerificationChallenges( postgres: IPostgres, options: IVerificationChallengesOptions, ): IVerificationChallenges ``` **Parameters** - `postgres` `IPostgres`: The database. - `options` `IVerificationChallengesOptions`: Each action's policy, keyed by its action id, and the code's digits. **Returns** `IVerificationChallenges`: The functions. **Throws** - `INVALID_VERIFICATION_POLICY`: The verification challenges need at least one policy, each for an action id of 1 to 128 characters, with whole validity and window seconds of at least 1, a whole issue limit of at least 1, and a whole failed-attempt limit from 1 to 32767, and a whole code length from 4 to 10. **Example** ```ts import { createPostgres, createVerificationChallenges } from 'jg-postgres'; declare const postgres: ReturnType; declare const sendEmail: (to: string, text: string) => Promise; const challenges = createVerificationChallenges(postgres, { policies: { 'verify-email': { validitySeconds: 600, maxFailedAttempts: 5, issueLimit: 5, issueWindowSeconds: 3_600, }, }, }); const binding = { actorId: '42', actionId: 'verify-email', targetId: 'ada@example.com' }; const issued = await challenges.issue(binding, ({ code, targetId }) => sendEmail(targetId, `Your code is ${code}`), ); if (issued.outcome === 'issued') { // later, with the code the user typed const isVerified = await challenges.consume({ ...binding, challengeToken: issued.challengeToken, code: '123456', }); } ``` ##### `defineCounterTable` *Function* Defines a table of counters, such as an account's daily usage, and gives the functions that build the queries changing its rows. Every name is quoted once, and every value is a parameter. The table needs a unique constraint on exactly the key columns, for the increments' ON CONFLICT, and a default for every counter. Run the queries with `applyCounterQueries`, which orders them so concurrent transactions lock rows in the same order. ```ts defineCounterTable( definition: ICounterTableDefinition, ): ICounterTable ``` **Parameters** - `definition` `ICounterTableDefinition`: The table, its key columns, and its counters. **Returns** `ICounterTable`: The query builders. **Throws** - `INVALID_COUNTER_TABLE`: The counter table needs at least one key column and one counter, every name distinct. - `INVALID_IDENTIFIER`: The identifier is empty, holds a null character, or is longer than 63 bytes. **Example** ```ts import { defineCounterTable } from 'jg-postgres'; const dailyUsage = defineCounterTable({ table: 'daily_usage', keyColumns: ['account_id', 'day'], counters: ['request_count', 'token_count'], }); const increment = dailyUsage.buildIncrement( { account_id: '42', day: '2026-10-19' }, { request_count: 1, token_count: 1_250n }, ); // increment.text: INSERT INTO "daily_usage" ("account_id", "day", "request_count", "token_count") // VALUES ($1, $2, $3, $4) ON CONFLICT ("account_id", "day") DO UPDATE SET ... // increment.values: ['42', '2026-10-19', '1', '1250'] ``` ##### `defineTimeoutProfile` *Function* Defines how long a kind of work may run: a statement on the server, a wait for a lock, and the client's wait for the server's answer, which is the statement timeout plus a grace, so the server's own timeout error arrives first. A slow report and an interactive request each get one. ```ts defineTimeoutProfile( options: { statementTimeoutSeconds: number; lockTimeoutSeconds: number; queryGraceSeconds?: number }, ): ITimeoutProfile ``` **Parameters** - `options` `{ statementTimeoutSeconds: number; lockTimeoutSeconds: number; queryGraceSeconds?: number }`: The statement and lock timeouts, and the client's grace, 15 seconds by default, all in seconds. **Returns** `ITimeoutProfile`: The profile, frozen. **Throws** - `INVALID_TIMEOUT_PROFILE`: The timeout profile needs positive, finite seconds, with the lock timeout no longer than the statement timeout. **Example** ```ts import { defineTimeoutProfile } from 'jg-postgres'; const REPORT_TIMEOUTS = defineTimeoutProfile({ statementTimeoutSeconds: 120, lockTimeoutSeconds: 10 }); REPORT_TIMEOUTS.queryTimeoutSeconds; // 135 ``` ##### `diffTables` *Function* Compares the tables a schema has with the ones an app expects, such as in a health check after a migration. Names compare exactly, as PostgreSQL stores them. ```ts diffTables(actual: readonly string[], expected: readonly string[]): ITableDiff ``` **Parameters** - `actual` `readonly string[]`: The tables that exist, such as listTableSizes' names. - `expected` `readonly string[]`: The tables the app expects. **Returns** `ITableDiff`: The expected tables that are missing, and the tables that aren't expected, each sorted and without duplicates. **Example** ```ts import { diffTables } from 'jg-postgres'; diffTables(['app_user', 'session'], ['app_user', 'invitation']); // => { missing: ['invitation'], unexpected: ['session'] } ``` ##### `isCheckViolationError` *Function* Checks whether an error is a check violation (SQLSTATE 23514), optionally on one constraint. ```ts isCheckViolationError( error: unknown, constraint?: string, ): error is DatabaseError ``` **Parameters** - `error` `unknown`: The error. - `constraint?` `string`: The check constraint's name, when it must match. **Returns** `error is DatabaseError`: Whether the error matches. **Example** ```ts import { isCheckViolationError } from 'jg-postgres'; declare const error: unknown; isCheckViolationError(error, 'ck_order__quantity'); ``` ##### `isDatabaseError` *Function* Checks whether an error is one PostgreSQL returned, with its SQLSTATE code, as node-postgres reports it. ```ts isDatabaseError(error: unknown): error is DatabaseError ``` **Parameters** - `error` `unknown`: The error. **Returns** `error is DatabaseError`: Whether it's a node-postgres DatabaseError. **Example** ```ts import { isDatabaseError } from 'jg-postgres'; isDatabaseError(new Error('Connection terminated')); // => false ``` ##### `isDeadlockDetectedError` *Function* Checks whether an error is a detected deadlock (SQLSTATE 40P01), which a retry of the whole transaction may fix. ```ts isDeadlockDetectedError(error: unknown): error is DatabaseError ``` **Parameters** - `error` `unknown`: The error. **Returns** `error is DatabaseError`: Whether the error matches. **Example** ```ts import { isDeadlockDetectedError } from 'jg-postgres'; declare const error: unknown; isDeadlockDetectedError(error); ``` ##### `isForeignKeyViolationError` *Function* Checks whether an error is a foreign key violation (SQLSTATE 23503), optionally on one constraint. ```ts isForeignKeyViolationError( error: unknown, constraint?: string, ): error is DatabaseError ``` **Parameters** - `error` `unknown`: The error. - `constraint?` `string`: The foreign key's name, when it must match. **Returns** `error is DatabaseError`: Whether the error matches. **Example** ```ts import { isForeignKeyViolationError } from 'jg-postgres'; declare const error: unknown; isForeignKeyViolationError(error, 'fk_order__user_account_id'); ``` ##### `isInvalidTextRepresentationError` *Function* Checks whether an error is an invalid text representation (SQLSTATE 22P02), such as text where a UUID belongs. ```ts isInvalidTextRepresentationError(error: unknown): error is DatabaseError ``` **Parameters** - `error` `unknown`: The error. **Returns** `error is DatabaseError`: Whether the error matches. **Example** ```ts import { isInvalidTextRepresentationError } from 'jg-postgres'; declare const error: unknown; isInvalidTextRepresentationError(error); ``` ##### `isNotNullViolationError` *Function* Checks whether an error is a not-null violation (SQLSTATE 23502). ```ts isNotNullViolationError(error: unknown): error is DatabaseError ``` **Parameters** - `error` `unknown`: The error. **Returns** `error is DatabaseError`: Whether the error matches. **Example** ```ts import { isNotNullViolationError } from 'jg-postgres'; declare const error: unknown; isNotNullViolationError(error); ``` ##### `isNumericValueOutOfRangeError` *Function* Checks whether an error is a numeric value out of range (SQLSTATE 22003). ```ts isNumericValueOutOfRangeError(error: unknown): error is DatabaseError ``` **Parameters** - `error` `unknown`: The error. **Returns** `error is DatabaseError`: Whether the error matches. **Example** ```ts import { isNumericValueOutOfRangeError } from 'jg-postgres'; declare const error: unknown; isNumericValueOutOfRangeError(error); ``` ##### `isQueryCanceledError` *Function* Checks whether an error is a canceled query (SQLSTATE 57014), such as one that ran past statement_timeout. ```ts isQueryCanceledError(error: unknown): error is DatabaseError ``` **Parameters** - `error` `unknown`: The error. **Returns** `error is DatabaseError`: Whether the error matches. **Example** ```ts import { isQueryCanceledError } from 'jg-postgres'; declare const error: unknown; isQueryCanceledError(error); ``` ##### `isRestrictViolationError` *Function* Checks whether an error is a restrict violation (SQLSTATE 23001), optionally on one constraint. ```ts isRestrictViolationError( error: unknown, constraint?: string, ): error is DatabaseError ``` **Parameters** - `error` `unknown`: The error. - `constraint?` `string`: The foreign key's name, when it must match. **Returns** `error is DatabaseError`: Whether the error matches. **Example** ```ts import { isRestrictViolationError } from 'jg-postgres'; declare const error: unknown; isRestrictViolationError(error); ``` ##### `isSerializationFailureError` *Function* Checks whether an error is a serialization failure (SQLSTATE 40001), which a retry of the whole transaction may fix. ```ts isSerializationFailureError(error: unknown): error is DatabaseError ``` **Parameters** - `error` `unknown`: The error. **Returns** `error is DatabaseError`: Whether the error matches. **Example** ```ts import { isSerializationFailureError } from 'jg-postgres'; declare const error: unknown; isSerializationFailureError(error); ``` ##### `isStringDataRightTruncationError` *Function* Checks whether an error is a string data right truncation (SQLSTATE 22001): a value longer than its column allows. ```ts isStringDataRightTruncationError(error: unknown): error is DatabaseError ``` **Parameters** - `error` `unknown`: The error. **Returns** `error is DatabaseError`: Whether the error matches. **Example** ```ts import { isStringDataRightTruncationError } from 'jg-postgres'; declare const error: unknown; isStringDataRightTruncationError(error); ``` ##### `isUniqueViolationError` *Function* Checks whether an error is a unique violation (SQLSTATE 23505), optionally on one constraint. ```ts isUniqueViolationError( error: unknown, constraint?: string, ): error is DatabaseError ``` **Parameters** - `error` `unknown`: The error. - `constraint?` `string`: The unique constraint's or index's name, when it must match. **Returns** `error is DatabaseError`: Whether the error matches. **Example** ```ts import { isUniqueViolationError } from 'jg-postgres'; declare const error: unknown; if (isUniqueViolationError(error, 'uq_user_account__email')) { // the email is taken } ``` ##### `listByCursor` *Function* Lists a page of a table's rows after a cursor, ordered by the cursor column. Every name is quoted, and the order and the cursor comparison use the table's own column, so a selected column can't change them. ```ts listByCursor( executor: IQueryExecutor, options: ICursorListingOptions, ): Promise ``` **Parameters** - `executor` `IQueryExecutor`: The pool, or a transaction's client. - `options` `ICursorListingOptions`: The table, its columns, the cursor column and direction, filters, the previous page's last cursor, and the limit. **Returns** `Promise`: A promise that resolves to the rows. **Throws** - `INVALID_IDENTIFIER`: The identifier is empty, holds a null character, or is longer than 63 bytes. **Example** ```ts import { createPostgres, listByCursor } from 'jg-postgres'; declare const postgres: ReturnType; const errors = await listByCursor<{ id: string; origin: string }>(postgres.pool, { table: 'api_error', columns: ['id', 'origin'], filters: [{ column: 'origin', value: 'api-admin' }], after: '120', limit: 20, }); ``` ##### `listTableSizes` *Function* Lists the ordinary tables in a schema with their total size on disk, indexes and TOAST data included, sorted by name. ```ts listTableSizes( executor: IQueryExecutor, options?: IIntrospectionOptions, ): Promise ``` **Parameters** - `executor` `IQueryExecutor`: The pool, or a client. - `options?` `IIntrospectionOptions`: The schema. Defaults to `{}`. **Returns** `Promise`: A promise that resolves to the tables. **Example** ```ts import { createPostgres, listTableSizes } from 'jg-postgres'; declare const postgres: ReturnType; const tables = await listTableSizes(postgres.pool); // [{ name: 'app_user', totalBytes: 65_536 }, { name: 'session', totalBytes: 16_384 }] ``` ##### `lockOrderedList` *Function* Locks an ordered list for the rest of a transaction, so no move or append changes it meanwhile, such as before appending a row after the list's last sort order. moveUp and moveDown take the same lock. ```ts lockOrderedList(client: PoolClient, list: IOrderedList): Promise ``` **Parameters** - `client` `PoolClient`: The transaction's client. - `list` `IOrderedList`: The table, its sort order column, and the scope that picks one list. **Returns** `Promise`: A promise that resolves once the lock is held. **Example** ```ts import { createPostgres, lockOrderedList } from 'jg-postgres'; declare const postgres: ReturnType; await postgres.transaction(async (client) => { await lockOrderedList(client, { table: 'category', scope: [{ column: 'group_id', value: 1 }] }); await client.query( `INSERT INTO category (group_id, sort_order) SELECT 1, COALESCE(MAX(sort_order), 0) + 10 FROM category WHERE group_id = 1;`, ); }); ``` ##### `lockTransaction` *Function* Takes an advisory lock for the rest of a transaction, waiting while another transaction holds it. PostgreSQL releases it when the transaction commits or rolls back. ```ts lockTransaction(client: PoolClient, key: string): Promise ``` **Parameters** - `client` `PoolClient`: The transaction's client. - `key` `string`: The lock's key, from createAdvisoryLockKey. **Returns** `Promise`: A promise that resolves once the lock is held. **Example** ```ts import { createAdvisoryLockKey, createPostgres, lockTransaction } from 'jg-postgres'; declare const postgres: ReturnType; await postgres.transaction(async (client) => { await lockTransaction(client, createAdvisoryLockKey('invoice-numbers')); // only one transaction at a time gets here }); ``` ##### `moveDown` *Function* Moves a row one place down an ordered list, by swapping its sort order with the closest larger one, in its own transaction under the list's lock. ```ts moveDown( postgres: IPostgres, rowId: IRowId, options: IMoveOptions, ): Promise ``` **Parameters** - `postgres` `IPostgres`: The database, from createPostgres. - `rowId` `IRowId`: The row's identifier. - `options` `IMoveOptions`: The table, its sort order constraint, and, when they differ from the defaults, its ID and sort order columns, and the scope that picks one list. **Returns** `Promise`: A promise that resolves once the move commits. **Throws** - `INVALID_IDENTIFIER`: The identifier is empty, holds a null character, or is longer than 63 bytes. - `MOVE_FAILED`: The move did not update exactly two rows. - `ROW_NOT_FOUND`: The row does not exist in the ordered list. - `ROW_ALREADY_LAST`: The row is already last in the ordered list. - `TRANSACTION_OUTCOME_UNKNOWN`: The connection was lost during COMMIT, so the transaction may or may not have committed. **Example** ```ts import { createPostgres, moveDown } from 'jg-postgres'; declare const postgres: ReturnType; await moveDown(postgres, 42, { table: 'category', sortOrderConstraint: 'uq_category__sort_order' }); ``` ##### `moveUp` *Function* Moves a row one place up an ordered list, by swapping its sort order with the closest smaller one, in its own transaction under the list's lock. ```ts moveUp(postgres: IPostgres, rowId: IRowId, options: IMoveOptions): Promise ``` **Parameters** - `postgres` `IPostgres`: The database, from createPostgres. - `rowId` `IRowId`: The row's identifier. - `options` `IMoveOptions`: The table, its sort order constraint, and, when they differ from the defaults, its ID and sort order columns, and the scope that picks one list. **Returns** `Promise`: A promise that resolves once the move commits. **Throws** - `INVALID_IDENTIFIER`: The identifier is empty, holds a null character, or is longer than 63 bytes. - `MOVE_FAILED`: The move did not update exactly two rows. - `ROW_NOT_FOUND`: The row does not exist in the ordered list. - `ROW_ALREADY_FIRST`: The row is already first in the ordered list. - `TRANSACTION_OUTCOME_UNKNOWN`: The connection was lost during COMMIT, so the transaction may or may not have committed. **Example** ```ts import { createPostgres, moveUp } from 'jg-postgres'; declare const postgres: ReturnType; await moveUp(postgres, 42, { table: 'category', sortOrderConstraint: 'uq_category__sort_order' }); ``` ##### `quoteIdentifier` *Function* Quotes a table, column, or constraint name for SQL that can't take it as a parameter. A name PostgreSQL would cut short, an empty one, or one with a null character throws instead. ```ts quoteIdentifier(name: string): string ``` **Parameters** - `name` `string`: The name, such as `order` or `sort_order`. **Returns** `string`: The quoted name, such as `"order"`. **Throws** - `INVALID_IDENTIFIER`: The identifier is empty, holds a null character, or is longer than 63 bytes. **Example** ```ts import { quoteIdentifier } from 'jg-postgres'; quoteIdentifier('order'); // => '"order"' quoteIdentifier('say "hi"'); // => '"say ""hi"""' ``` ##### `readDatabaseSummary` *Function* Reads a summary of the database the executor is connected to, such as for an admin dashboard: its name, the server's version, its size, its active connections out of the server's maximum, and the ordinary tables in a schema. Without the pg_read_all_stats role, PostgreSQL shows only the role's own connections, so activeConnections undercounts. ```ts readDatabaseSummary( executor: IQueryExecutor, options?: IIntrospectionOptions, ): Promise ``` **Parameters** - `executor` `IQueryExecutor`: The pool, or a client. - `options?` `IIntrospectionOptions`: The schema whose tables are counted. Defaults to `{}`. **Returns** `Promise`: A promise that resolves to the summary. **Example** ```ts import { createPostgres, readDatabaseSummary } from 'jg-postgres'; declare const postgres: ReturnType; const summary = await readDatabaseSummary(postgres.pool); // { name: 'app', serverVersion: '18.1', serverVersionNumber: 180001, sizeBytes: 8_000_000, // activeConnections: 3, maxConnections: 100, tableCount: 12 } ``` ##### `toPoolTimeouts` *Function* Gives a profile's timeouts as createPostgres takes them, in milliseconds, for a pool whose every query follows the profile. ```ts toPoolTimeouts( profile: ITimeoutProfile, ): { statement_timeout: number; lock_timeout: number; query_timeout: number } ``` **Parameters** - `profile` `ITimeoutProfile`: The profile. **Returns** `{ statement_timeout: number; lock_timeout: number; query_timeout: number }`: The pool's `statement_timeout`, `lock_timeout`, and `query_timeout`. **Throws** - `INVALID_TIMEOUT_PROFILE`: The timeout profile needs positive, finite seconds, with the lock timeout no longer than the statement timeout. **Example** ```ts import { createPostgres, defineTimeoutProfile, toPoolTimeouts } from 'jg-postgres'; const STANDARD = defineTimeoutProfile({ statementTimeoutSeconds: 30, lockTimeoutSeconds: 5 }); const postgres = createPostgres({ connectionString: process.env['DATABASE_URL'], ...toPoolTimeouts(STANDARD) }); ``` ##### `withQueryTimeout` *Function* Gives a query the client timeout of a profile, which node-postgres's pool otherwise sets for every query, so a query in a transaction with longer timeouts isn't cut off by the pool's. ```ts withQueryTimeout( query: { text: string; values?: unknown[] }, profile: ITimeoutProfile, ): QueryConfig & { query_timeout: number } ``` **Parameters** - `query` `{ text: string; values?: unknown[] }`: The query's text and values. - `profile` `ITimeoutProfile`: The profile. **Returns** `QueryConfig & { query_timeout: number }`: The query, as node-postgres's query methods take it. **Throws** - `INVALID_TIMEOUT_PROFILE`: The timeout profile needs positive, finite seconds, with the lock timeout no longer than the statement timeout. **Example** ```ts import { createPostgres, defineTimeoutProfile, withQueryTimeout } from 'jg-postgres'; declare const postgres: ReturnType; const REPORT_TIMEOUTS = defineTimeoutProfile({ statementTimeoutSeconds: 120, lockTimeoutSeconds: 10 }); await postgres.transaction( (client) => client.query(withQueryTimeout({ text: 'SELECT build_report($1);', values: [2026] }, REPORT_TIMEOUTS)), { timeouts: REPORT_TIMEOUTS }, ); ``` ##### `withSessionLock` *Function* Runs an operation while holding an advisory lock on one connection, across as many transactions as it needs, or skips it when another session holds the lock. The lock lasts as long as its connection, so the operation can check it's still held. Afterwards the lock is released, and the client is destroyed instead of reused when the release fails or the connection was lost, which releases the lock too. ```ts withSessionLock( postgres: IPostgres, key: string, operation: (lock: ISessionLock) => Promise, ): Promise> ``` **Parameters** - `postgres` `IPostgres`: The database, from createPostgres. - `key` `string`: The lock's key, from createAdvisoryLockKey. - `operation` `(lock: ISessionLock) => Promise`: The work, given the lock. **Returns** `Promise>`: A promise that resolves to `{ acquired: false }` when another session holds the lock, or `{ acquired: true, result }` with what the operation resolved to. **Throws** - `SESSION_LOCK_LOST`: The connection that held the session lock was lost. **Example** ```ts import { createAdvisoryLockKey, createPostgres, withSessionLock } from 'jg-postgres'; declare const postgres: ReturnType; const outcome = await withSessionLock( postgres, createAdvisoryLockKey('user-account-deletion:42'), async (lock) => { lock.assertActive(); return 'deleted'; }, ); if (!outcome.acquired) { // another request is already deleting the account } ``` #### Constants in `jg-postgres` ##### `ERRORS` *Constant* the codes for every error thrown by the package ```ts const ERRORS: { INVALID_IDENTIFIER: "INVALID_IDENTIFIER"; TRANSACTION_OUTCOME_UNKNOWN: "TRANSACTION_OUTCOME_UNKNOWN"; SESSION_LOCK_LOST: "SESSION_LOCK_LOST"; ROW_NOT_FOUND: "ROW_NOT_FOUND"; ROW_ALREADY_FIRST: "ROW_ALREADY_FIRST"; ROW_ALREADY_LAST: "ROW_ALREADY_LAST"; MOVE_FAILED: "MOVE_FAILED"; INVALID_TIMEOUT_PROFILE: "INVALID_TIMEOUT_PROFILE"; INVALID_OPTIONAL_READ_OPTIONS: "INVALID_OPTIONAL_READ_OPTIONS"; INVALID_USAGE_ADMISSION: "INVALID_USAGE_ADMISSION"; INVALID_VERIFICATION_POLICY: "INVALID_VERIFICATION_POLICY"; INVALID_VERIFICATION_INPUT: "INVALID_VERIFICATION_INPUT"; VERIFICATION_DELIVERY_FAILED: "VERIFICATION_DELIVERY_FAILED"; INVALID_COUNTER_TABLE: "INVALID_COUNTER_TABLE"; INVALID_COUNTER_CHANGE: "INVALID_COUNTER_CHANGE"; COUNTER_ROW_NOT_FOUND: "COUNTER_ROW_NOT_FOUND" } ``` ##### `POSTGRES_BIGINT_MAX` *Constant* the largest value a PostgreSQL BIGINT column holds ```ts const POSTGRES_BIGINT_MAX: 9223372036854775807n ``` ##### `POSTGRES_BIGINT_MIN` *Constant* the smallest value a PostgreSQL BIGINT column holds ```ts const POSTGRES_BIGINT_MIN: -9223372036854775808n ``` #### Types in `jg-postgres` ##### `IColumnFilter` *Type* an equality condition on one column, such as { column: 'group_id', value: 1 } ```ts type IColumnFilter = { column: string; value: unknown; }; ``` **Members** - `column: string` (property): the column's name, quoted in the SQL - `value: unknown` (property): the value it must equal, sent as a query parameter ##### `ICounterChanges` *Type* the whole amounts to add to some of the counters ```ts type ICounterChanges = Partial>; ``` ##### `ICounterKey` *Type* a row's key: a value for every key column, none of them empty ```ts type ICounterKey = Record; ``` ##### `ICounterQuery` *Type* a query a counter table builds, with the key that orders it among a transaction's other queries ```ts type ICounterQuery = { text: string; values: unknown[]; lockKey: string; }; ``` **Members** - `text: string` (property): the SQL, with every value as a parameter - `values: unknown[]` (property): the parameters, with bigints as strings - `lockKey: string` (property): the table and the row's key, so queries for the same rows run in the same order everywhere ##### `ICounterTable` *Type* builds the queries that change a counter table's rows ```ts type ICounterTable = { buildIncrement: (key: ICounterKey, increments: ICounterChanges) => ICounterQuery; buildAdjustment: (key: ICounterKey, changes: ICounterChanges) => ICounterQuery; }; ``` **Members** - `buildIncrement: (key: ICounterKey, increments: ICounterChanges) => ICounterQuery` (property): inserts the row with the increments, or adds them to the row that exists - `buildAdjustment: (key: ICounterKey, changes: ICounterChanges) => ICounterQuery` (property): adds the changes, which may be negative, to a row that must exist ##### `ICounterTableDefinition` *Type* a counter table's layout: its name, the columns that identify a row, and the counter columns ```ts type ICounterTableDefinition = { table: string; keyColumns: readonly K[]; counters: readonly C[]; }; ``` **Members** - `table: string` (property): the table's name, quoted in the SQL - `keyColumns: readonly K[]` (property): the columns that identify a row, which a unique constraint must cover exactly - `counters: readonly C[]` (property): the BIGINT counter columns, each with a default, such as 0 ##### `ICursorListingOptions` *Type* what listByCursor lists ```ts type ICursorListingOptions = { table: string; columns: readonly string[]; cursorColumn?: string; sortDirection?: 'ASC' | 'DESC'; filters?: readonly IColumnFilter[]; after?: IRowId | undefined; limit: number; }; ``` **Members** - `table: string` (property): the table's name, quoted in the SQL - `columns: readonly string[]` (property): the columns to select, each quoted in the SQL - `cursorColumn: string` (property): the unique column the pages follow, such as an ascending ID. Defaults to 'id' - `sortDirection: "ASC" | "DESC"` (property): the order rows come in. Defaults to 'DESC', newest first for ascending IDs - `filters: readonly IColumnFilter[]` (property): equality conditions every listed row meets - `after: IRowId` (property): the cursor column's value on the last row of the previous page, or undefined for the first - `limit: number` (property): the most rows to return ##### `ICursorPage` *Type* a page of rows, and the cursor of the next page, or null when this is the last ```ts type ICursorPage = { items: T[]; nextCursor: string | null; }; ``` **Members** - `items: T[]` (property) - `nextCursor: string | null` (property) ##### `IDatabaseSummary` *Type* the database a connection is on: its name, server, size, connections, and tables ```ts type IDatabaseSummary = { name: string; serverVersion: string; serverVersionNumber: number; sizeBytes: number; activeConnections: number; maxConnections: number; tableCount: number; }; ``` **Members** - `name: string` (property): the database's name - `serverVersion: string` (property): the server's version, such as '18.1' - `serverVersionNumber: number` (property): the server's version as a number, such as 180001, to compare versions - `sizeBytes: number` (property): the database's size on disk, in bytes - `activeConnections: number` (property): the connections to this database running a query now. Without pg_read_all_stats, only the role's own connections are counted - `maxConnections: number` (property): the most connections the server accepts, over every database - `tableCount: number` (property): the ordinary tables in the schema ##### `IIntrospectionOptions` *Type* which schema readDatabaseSummary and listTableSizes read the tables of ```ts type IIntrospectionOptions = { schema?: string | undefined; }; ``` **Members** - `schema: string` (property): the schema. Defaults to 'public' ##### `IMoveOptions` *Type* what moveUp and moveDown move within ```ts type IMoveOptions = IOrderedList & { sortOrderConstraint: string; idColumn?: string; }; ``` ##### `IOptionalRead` *Type* a read that may not run: its value once known, and a promise of it; null when it was skipped, ran out of time, or failed ```ts type IOptionalRead = { getSnapshot: () => T | null; completion: Promise; }; ``` **Members** - `getSnapshot: () => T | null` (property): the value, or null until the read finishes, and when it gives none - `completion: Promise` (property): resolves to the value, or null; it never rejects ##### `IOptionalReader` *Type* runs optional reads, such as a counter in a page's header, that never delay or crowd out essential work ```ts type IOptionalReader = { read: (key: string, query: { text: string; values?: unknown[] | undefined; }, parse: (row: unknown) => T) => IOptionalRead; }; ``` **Members** - `read: (key: string, query: { text: string; values?: unknown[] }, parse: (row: unknown) => T) => IOptionalRead` (property): starts a read of the query's first row, or joins the one running for the same key ##### `IOptionalReaderOptions` *Type* how createOptionalReader admits and bounds reads ```ts type IOptionalReaderOptions = { postgres: IPostgres; maxActiveReads?: number | undefined; reservedIdleClients?: number | undefined; admissionSeconds?: number | undefined; timeouts?: ITimeoutProfile | undefined; }; ``` **Members** - `postgres: IPostgres` (property): the database the reads run on - `maxActiveReads: number` (property): the most reads running at once. Defaults to 2 - `reservedIdleClients: number` (property): the clients a read leaves free for essential work: it's skipped unless the pool can give more than this many. Defaults to 2 - `admissionSeconds: number` (property): the seconds a read may take, waiting for a client included, before it gives null. Defaults to 1 - `timeouts: ITimeoutProfile` (property): each read's timeouts. Defaults to a 0.5-second statement timeout, a 0.1-second lock timeout, and a 0.75-second client timeout ##### `IOrderedList` *Type* an ordered list: the rows of a table, or of one group in it, ordered by a column ```ts type IOrderedList = { table: string; sortOrderColumn?: string; scope?: readonly IColumnFilter[]; }; ``` **Members** - `table: string` (property): the table's name - `sortOrderColumn: string` (property): the column the rows are ordered by. Defaults to 'sort_order' - `scope: readonly IColumnFilter[]` (property): equality conditions that pick one ordered list when a table holds several, such as [{ column: 'group_id', value: 1 }] ##### `IPostgres` *Type* a database: its pool, and the helpers that use it ```ts type IPostgres = { readonly pool: Pool; query: (text: string, params?: readonly unknown[]) => Promise; transaction: (operation: (client: PoolClient) => Promise, options?: ITransactionOptions) => Promise; close: () => Promise; }; ``` **Members** - `pool: Pool` (property): the node-postgres pool, for what the helpers don't cover - `query: (text: string, params?: readonly unknown[]) => Promise` (property): runs a query on the pool and returns its rows - `transaction: (operation: (client: PoolClient) => Promise, options?: ITransactionOptions) => Promise` (property): runs an operation in a transaction on one client, committing when it resolves and rolling back when it throws, optionally with timeouts of its own. Never start a transaction inside another: run the inner work on the client instead - `close: () => Promise` (property): ends every connection, once queries in flight finish ##### `IPostgresConfig` *Type* what createPostgres takes: node-postgres's pool configuration, such as the host, the pool size, and connectionTimeoutMillis, statement_timeout, lock_timeout, and idle_in_transaction_session_timeout, plus where errors outside any query go ```ts type IPostgresConfig = Omit & { onError?: ((error: unknown) => void) | undefined; }; ``` ##### `IQueryExecutor` *Type* what runs a query: the pool, or a client checked out from it, such as a transaction's ```ts type IQueryExecutor = Pool | PoolClient; ``` ##### `IRowId` *Type* a row's identifier, as a cursor or a row to move ```ts type IRowId = string | number | bigint; ``` ##### `ISessionLock` *Type* what withSessionLock gives the operation while it holds the lock ```ts type ISessionLock = { readonly client: PoolClient; isActive: () => boolean; assertActive: () => void; destroyClient: () => void; }; ``` **Members** - `client: PoolClient` (property): the client that holds the lock, for work that must stop when the lock does - `isActive: () => boolean` (property): whether the lock's connection is still alive, so the lock is still held - `assertActive: () => void` (property): throws SESSION_LOCK_LOST when the lock's connection was lost - `destroyClient: () => void` (property): marks the client as unsafe to reuse, such as after work on it ended in an unknown state, so it's destroyed instead of returned to the pool ##### `ISessionLockResult` *Type* what withSessionLock resolves to: whether it got the lock, and the operation's result when it did ```ts type ISessionLockResult = { acquired: false; } | { acquired: true; result: T; }; ``` ##### `ITableDiff` *Type* how a schema's tables differ from the ones expected ```ts type ITableDiff = { missing: string[]; unexpected: string[]; }; ``` **Members** - `missing: string[]` (property): the expected tables that don't exist - `unexpected: string[]` (property): the tables that exist but aren't expected ##### `ITableSize` *Type* a table and its size on disk, with its indexes and TOAST data ```ts type ITableSize = { name: string; totalBytes: number; }; ``` **Members** - `name: string` (property): the table's name - `totalBytes: number` (property): its total size, in bytes ##### `ITimeoutProfile` *Type* how long a query may run, in seconds: on the server, waiting for a lock, and on the client, which stops waiting for the server's answer. defineTimeoutProfile builds it ```ts type ITimeoutProfile = Readonly<{ statementTimeoutSeconds: number; lockTimeoutSeconds: number; queryTimeoutSeconds: number; }>; ``` ##### `ITransactionOptions` *Type* how a transaction runs ```ts type ITransactionOptions = { timeouts?: ITimeoutProfile | undefined; }; ``` **Members** - `timeouts: ITimeoutProfile` (property): the statement and lock timeouts for this transaction only, from defineTimeoutProfile. The pool's query_timeout still applies to each query that doesn't set its own, so pass the operation's queries through withQueryTimeout with the same profile. A query the client stops waiting for is rolled back: the client goes back to the pool once ROLLBACK, which waits for the server and has the pool's query_timeout too, confirms the connection, and is destroyed otherwise ##### `IUsageAdmission` *Type* what admitUsage decides, with when to try again: accepted, refused by the scope's cooldown, or refused because the actor reached the limit within the window ```ts type IUsageAdmission = { outcome: 'accepted'; retryAt: string | null; } | { outcome: 'cooldown'; retryAt: string; } | { outcome: 'limit'; retryAt: null; }; ``` ##### `IUsageAdmissionInput` *Type* what admitUsage counts and limits ```ts type IUsageAdmissionInput = { actorId: string; actionId: string; scopeKey?: string | undefined; limit: number; windowSeconds: number; cooldownSeconds?: number | undefined; }; ``` **Members** - `actorId: string` (property): who acts, such as a user's id: 1 to 256 characters - `actionId: string` (property): what they do, such as `send-invitation`: 1 to 128 characters - `scopeKey: string` (property): what they act on, such as the invited email's hash, which the cooldown applies to: 1 to 256 characters - `limit: number` (property): the most times the actor may do the action within the window - `windowSeconds: number` (property): the seconds the limit counts back from now - `cooldownSeconds: number` (property): the seconds after an accepted action before the same action on the same scope is accepted again, by any actor. Only with a scopeKey ##### `IVerificationAttempt` *Type* what consume checks: the binding the challenge was issued for, its token, and the code ```ts type IVerificationAttempt = IVerificationBinding & { challengeToken: string; code: string; }; ``` ##### `IVerificationBinding` *Type* who a challenge is for, what it verifies, and what on ```ts type IVerificationBinding = { actorId: string; actionId: string; targetId: string; }; ``` **Members** - `actorId: string` (property): who verifies, such as a user's id: 1 to 256 characters - `actionId: string` (property): what they verify, such as `verify-email`, which must have a policy: 1 to 128 characters - `targetId: string` (property): what they verify it on, such as the new email address: 1 to 256 characters ##### `IVerificationChallenges` *Type* issues and consumes verification challenges with the policies they were created with ```ts type IVerificationChallenges = { issue: (binding: IVerificationBinding, deliver: IVerificationDeliver) => Promise; consume: (attempt: IVerificationAttempt) => Promise; }; ``` **Members** - `issue: (binding: IVerificationBinding, deliver: IVerificationDeliver) => Promise` (property): issues a challenge for a binding, superseding its active one, and delivers its code once it's committed, unless the actor reached the issue limit - `consume: (attempt: IVerificationAttempt) => Promise` (property): uses a challenge when its code matches, or counts a failed attempt, and resolves to whether it matched ##### `IVerificationChallengesOptions` *Type* what createVerificationChallenges takes ```ts type IVerificationChallengesOptions = { policies: Record; codeLength?: number | undefined; }; ``` **Members** - `policies: Record` (property): each action's policy, keyed by its action id, such as `verify-email` - `codeLength: number` (property): the code's digits: 4 to 10. Defaults to 6 ##### `IVerificationDeliver` *Type* sends a challenge's code to its actor, such as by email ```ts type IVerificationDeliver = (delivery: IVerificationDelivery) => Promise | void; ``` ##### `IVerificationDelivery` *Type* what deliver receives once the challenge is committed, to send the code to the actor ```ts type IVerificationDelivery = IVerificationBinding & { code: string; challengeId: string; expiresAt: string; validitySeconds: number; }; ``` ##### `IVerificationIssue` *Type* What issue resolves to: the challenge's token, which the client keeps and sends back with the code, and when it expires; or, when the actor reached the policy's issue limit, when they may be issued one again. ```ts type IVerificationIssue = { outcome: 'issued'; challengeToken: string; expiresAt: string; } | { outcome: 'limit'; retryAt: string; }; ``` ##### `IVerificationPolicy` *Type* how one action's challenges behave: how long they last, how many wrong codes they take, and how many an actor may be issued within a window ```ts type IVerificationPolicy = { validitySeconds: number; maxFailedAttempts: number; issueLimit: number; issueWindowSeconds: number; }; ``` **Members** - `validitySeconds: number` (property): the whole seconds a challenge stays valid after it's issued, at least 1 - `maxFailedAttempts: number` (property): the wrong codes a challenge takes before it stops accepting any: 1 to 32,767 - `issueLimit: number` (property): the most challenges an actor may be issued for the action within the window, over every target - `issueWindowSeconds: number` (property): the whole seconds the issue limit counts back from now, at least 1 ### `jg-postgres/record-store` - Functions: [`createRecordStore`](#api-record-store-createRecordStore) - Types: [`IRecordNumberKey`](#api-record-store-IRecordNumberKey), [`IRecordStore`](#api-record-store-IRecordStore), [`IRecordStoreOptions`](#api-record-store-IRecordStoreOptions), [`ISchema`](#api-record-store-ISchema) #### Functions in `jg-postgres/record-store` ##### `createRecordStore` *Function* Creates a store for one JSON record, such as a set of counters or a feature's settings, kept in the record_store table the package's README describes, which the app creates. The defaults fill every missing field when it's read, and are the only keys a write keeps. Every read is checked with the schema. A method given a transaction's client runs in it, and never commits it. ```ts createRecordStore>( options: IRecordStoreOptions, ): IRecordStore ``` **Parameters** - `options` `IRecordStoreOptions`: The database, the record's id, its defaults, and its schema. **Returns** `IRecordStore`: The record's store. **Example** ```ts import { createPostgres } from 'jg-postgres'; import { createRecordStore } from 'jg-postgres/record-store'; import { z } from 'zod'; declare const postgres: ReturnType; const counters = createRecordStore({ postgres, id: 'admin:unread-counts', defaults: { notifications: 0, reports: 0 }, schema: z.object({ notifications: z.number(), reports: z.number() }), }); await counters.incrementNumber('notifications', 1); await counters.get(); // => { notifications: 1, reports: 0 } ``` #### Types in `jg-postgres/record-store` ##### `IRecordNumberKey` *Type* the keys of a record whose values are numbers ```ts type IRecordNumberKey> = { [Key in keyof T]: T[Key] extends number ? Key : never; }[keyof T] & string; ``` ##### `IRecordStore` *Type* one record in the record_store table. Each method takes a transaction's client, to run in it without committing it, or runs on the pool ```ts type IRecordStore> = { get: (client?: PoolClient) => Promise; set: (value: T, client?: PoolClient) => Promise; incrementNumber: (field: IRecordNumberKey, delta: number, client?: PoolClient) => Promise; setNumber: (field: IRecordNumberKey, value: number, client?: PoolClient) => Promise; }; ``` **Members** - `get: (client?: PoolClient) => Promise` (property): reads the record, with the defaults filling every missing or null field - `set: (value: T, client?: PoolClient) => Promise` (property): replaces the record, with the defaults filling every undefined or null field - `incrementNumber: (field: IRecordNumberKey, delta: number, client?: PoolClient) => Promise` (property): adds to one numeric field atomically, so concurrent increments all count - `setNumber: (field: IRecordNumberKey, value: number, client?: PoolClient) => Promise` (property): replaces one numeric field, leaving the record's other fields as they are ##### `IRecordStoreOptions` *Type* what createRecordStore keeps ```ts type IRecordStoreOptions> = { postgres: IPostgres; id: string; defaults: T; schema: ISchema; }; ``` **Members** - `postgres: IPostgres` (property): the database whose record_store table holds the record - `id: string` (property): the record's id in the table, such as 'notifications:unread-counts' - `defaults: T` (property): the complete record: what get returns for a missing field, and the only keys a write keeps - `schema: ISchema` (property): checks the record get returns, defaults applied, such as a Zod schema. Its error propagates ##### `ISchema` *Type* validates data and returns it typed, which a Zod schema does ```ts type ISchema = { parse(value: unknown): T; }; ``` **Members** - `parse(value: unknown): T` (method) --- # jg-postgres recipes Patterns for PostgreSQL services: wiring pools, turning constraint errors into app errors, paginating, timeouts for slow work, and verification codes. ## A NestJS module with two databases Create each pool once, provide it under its own token, and close both when the app shuts down. A pool connects lazily, so creating it at import time opens nothing: ```ts import { Module, type OnApplicationShutdown } from '@nestjs/common'; import { createEnvironmentReader } from 'jg-env'; import { createPostgres } from 'jg-postgres'; const { readInteger, readString } = createEnvironmentReader(process.env); // the tokens services inject the databases with, as @Inject(PRIMARY_DATABASE) export const PRIMARY_DATABASE = Symbol('PRIMARY_DATABASE'); export const SECONDARY_DATABASE = Symbol('SECONDARY_DATABASE'); // the timeouts every pool uses const TIMEOUTS = { connectionTimeoutMillis: 30_000, statement_timeout: 60_000, lock_timeout: 30_000, idle_in_transaction_session_timeout: 300_000, }; const primary = createPostgres({ host: readString('PRIMARY_POSTGRES_HOST'), port: readInteger('PRIMARY_POSTGRES_PORT'), database: readString('PRIMARY_POSTGRES_DB'), user: readString('PRIMARY_POSTGRES_USER'), password: readString('PRIMARY_POSTGRES_PASSWORD'), max: 20, ...TIMEOUTS, }); const secondary = createPostgres({ host: readString('SECONDARY_POSTGRES_HOST'), port: readInteger('SECONDARY_POSTGRES_PORT'), database: readString('SECONDARY_POSTGRES_DB'), user: readString('SECONDARY_POSTGRES_USER'), password: readString('SECONDARY_POSTGRES_PASSWORD'), max: 5, ...TIMEOUTS, }); @Module({ providers: [ { provide: PRIMARY_DATABASE, useValue: primary }, { provide: SECONDARY_DATABASE, useValue: secondary }, ], exports: [PRIMARY_DATABASE, SECONDARY_DATABASE], }) export class DatabaseModule implements OnApplicationShutdown { async onApplicationShutdown(): Promise { await Promise.all([primary.close(), secondary.close()]); } } ``` Enable Nest's shutdown hooks, with `app.enableShutdownHooks()`, so the pools close when the process gets `SIGTERM`. ## Turning a constraint error into an app error Let the database enforce uniqueness, and translate its error for the client, rather than checking first and racing another request: ```ts import { Exception } from 'jg-exception'; import { createPostgres, isUniqueViolationError } from 'jg-postgres'; declare const postgres: ReturnType; export const registerEmail = async (userAccountId: string, email: string): Promise => { try { await postgres.query('UPDATE user_account SET email = $1 WHERE id = $2;', [ email, userAccountId, ]); } catch (error) { if (isUniqueViolationError(error, 'uq_user_account__email')) { throw new Exception('The email is already registered.', { code: 'EMAIL_TAKEN' }); } throw error; } }; ``` Naming the constraint keeps another unique column's violation, such as a phone number's, from reading as a taken email. ## A paginated list List one more row than the page holds, so the page knows whether another follows without counting the table: ```ts import { buildCursorPage, createPostgres, listByCursor, type ICursorPage } from 'jg-postgres'; declare const postgres: ReturnType; type IApiError = { id: string; origin: string; event_time: string }; export const listApiErrors = async ( origin: string | undefined, after: string | undefined, pageSize = 20, ): Promise> => { const rows = await listByCursor(postgres.pool, { table: 'api_error', columns: ['id', 'origin', 'event_time'], filters: origin === undefined ? [] : [{ column: 'origin', value: origin }], after, limit: pageSize + 1, }); return buildCursorPage(rows, pageSize, ({ id }) => id); }; ``` The rows come newest first, by an ascending `id`. Pass the page's `nextCursor` as `after` for the next page, and stop when it's `null`. ## A slow transaction with its own timeouts Use this when most queries must fail fast, so a slow one frees its client for other requests, but some work, such as a monthly report, needs longer. Give the pool the short profile, and the slow transaction a profile of its own: ```ts import { createPostgres, defineTimeoutProfile, toPoolTimeouts, withQueryTimeout, } from 'jg-postgres'; // every request's queries: a slow one fails fast and frees its client const STANDARD = defineTimeoutProfile({ statementTimeoutSeconds: 15, lockTimeoutSeconds: 5 }); // the monthly report, which reads a lot and may wait longer for the rows it locks const REPORT = defineTimeoutProfile({ statementTimeoutSeconds: 300, lockTimeoutSeconds: 30 }); const postgres = createPostgres({ connectionString: process.env['DATABASE_URL'], ...toPoolTimeouts(STANDARD), }); export const buildMonthlyReport = (month: string): Promise => postgres.transaction( async (client) => { // without withQueryTimeout, the pool's 30-second client timeout would cut these off await client.query( withQueryTimeout( { text: 'DELETE FROM monthly_report WHERE month = $1;', values: [month] }, REPORT, ), ); await client.query( withQueryTimeout( { text: 'INSERT INTO monthly_report (month, revenue) SELECT $1, sum(amount) FROM payment WHERE to_char(paid_at, $2) = $1;', values: [month, 'YYYY-MM'], }, REPORT, ), ); }, { timeouts: REPORT }, ); ``` The profile's statement and lock timeouts last only as long as the transaction, so the client goes back to the pool with the pool's own. Each query also needs `withQueryTimeout`, because node-postgres gives every query the pool's `query_timeout`, the statement timeout plus a grace of 15 seconds, unless the query sets its own. When the client stops waiting for a query, the transaction rolls back, and the client goes back to the pool only if `ROLLBACK` confirms the connection in time. ## Answering verification requests Map each verification outcome to what the app answers, in one place, so every endpoint that issues or checks a code reports a limit, a wrong code, and a failed delivery the same way. The functions don't depend on a framework, so a controller only returns their result: ```ts import { hasErrorCode } from 'jg-exception'; import { createPostgres, createVerificationChallenges, ERRORS, type IVerificationAttempt, type IVerificationBinding, } from 'jg-postgres'; declare const postgres: ReturnType; declare const sendEmail: (to: string, text: string, idempotencyKey: string) => Promise; // what the app answers: an HTTP status, and a code its client shows a message for type IVerificationResponse = | { status: 200; code: 'CODE_SENT'; challengeToken: string; expiresAt: string } | { status: 200; code: 'EMAIL_VERIFIED' } | { status: 400; code: 'INVALID_CODE' } | { status: 429; code: 'TOO_MANY_CODES'; retryAt: string } | { status: 503; code: 'CODE_NOT_SENT' }; const challenges = createVerificationChallenges(postgres, { policies: { 'verify-email': { validitySeconds: 600, maxFailedAttempts: 5, issueLimit: 5, issueWindowSeconds: 3_600, }, }, }); export const sendEmailCode = async ( binding: IVerificationBinding, ): Promise => { try { const issued = await challenges.issue(binding, ({ code, targetId, challengeId }) => sendEmail(targetId, `Your code is ${code}`, challengeId), ); return issued.outcome === 'issued' ? { status: 200, code: 'CODE_SENT', challengeToken: issued.challengeToken, expiresAt: issued.expiresAt, } : { status: 429, code: 'TOO_MANY_CODES', retryAt: issued.retryAt }; } catch (error) { // the code counts toward the limit, so the user waits as for any other code if (hasErrorCode(error, ERRORS.VERIFICATION_DELIVERY_FAILED)) { return { status: 503, code: 'CODE_NOT_SENT' }; } throw error; } }; export const confirmEmailCode = async ( attempt: IVerificationAttempt, ): Promise => (await challenges.consume(attempt)) ? { status: 200, code: 'EMAIL_VERIFIED' } : { status: 400, code: 'INVALID_CODE' }; ``` `consume` gives one `false` for a wrong, used, expired, or superseded code, and for another user's token, so `INVALID_CODE` tells an attacker nothing about which. Log the delivery failure before answering: its message names the challenge, with the code and the token redacted. --- # jg-redis > A read-through cache that keeps working when Redis fails, and fail-closed one-time claims and single-use values, on node-redis. - **Version:** `0.2.0` - **Runtimes:** Node.js - **Install:** `npm install jg-redis` - **On npm:** [jg-redis](https://www.npmjs.com/package/jg-redis) - **Source:** [packages/jg-redis](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-redis) - **Recipes:** [jg-redis recipes](https://jg-packages.jesusgraterol.dev/packages/jg-redis/recipes/index.md) ## Usage Connect one client per Redis server, and close it at shutdown: ```ts import { connectRedis } from 'jg-redis'; const redis = await connectRedis({ socket: { host: 'cache.internal', port: 6379 }, password: 'secret', onError: (error) => console.error('Redis', error), }); await redis.close(); // waits for pending replies, and never flushes any data ``` The client sends its errors, such as a dropped connection, to `onError`, `console.error` by default, instead of crashing the process. A command sent while it's disconnected is rejected instead of queued and replayed later, and connecting gives up after 10 seconds unless `socket.connectTimeout` says otherwise. `redis.client` is the node-redis client, for every other command. ### A read-through cache ```ts import { connectRedis, createCache } from 'jg-redis'; import { z } from 'zod'; declare const redis: Awaited>; declare const loadProfile: (id: string) => Promise<{ name: string } | null>; const cache = createCache(redis); const profile = await cache.getOrLoad('user:42:profile', { schema: z.object({ name: z.string() }).nullable(), ttlSeconds: 300, load: () => loadProfile('42'), }); await cache.invalidate('user:42:profile'); // after the profile changes ``` - Concurrent misses for a key in the process share one load. - A cached `null` is a hit, not a miss. - A cached value its schema rejects, such as one from an older shape, is deleted and loaded again. - Every value is stored with its TTL in the same command, and a value over 1 MiB isn't stored. - When Redis fails to read, the value is loaded instead; when a write fails, it's skipped. Both go to `onError`, so Redis being down slows the app without breaking it. - `invalidate` keeps a load already in flight in the process from storing its older value. Across processes, an older load can still store its value after the invalidation, until its TTL ends, so keep TTLs short for data that changes. ### One-time claims and single-use values `jg-redis/coordination` is for security: replay protection for one-time codes, and values one request takes, such as a sign-in challenge. It fails closed, throwing `COORDINATION_FAILED` when Redis fails, instead of letting the request through. ```ts import { connectRedis } from 'jg-redis'; import { claimOnce, storeSingleUseValue, takeSingleUseValue } from 'jg-redis/coordination'; import { z } from 'zod'; declare const redis: Awaited>; // true for the first use of the code, false for a replay const isFirstUse = await claimOnce(redis, 'user:42:used-otp', '123456', 90); await storeSingleUseValue(redis, 'user-auth:challenge:9f86d081', { userId: '42' }, 300); const challenge = await takeSingleUseValue( redis, 'user-auth:challenge:9f86d081', z.object({ userId: z.string() }), ); // null once taken or expired ``` `claimOnce` stores only the token's SHA-256 hash. Name a single-use value's key after a hash of the token that finds it, never the token. A value that fails its schema throws `COORDINATION_VALUE_INVALID`, without a cause or the value, which might hold a secret. These keys must never be evicted early, so run the server with `maxmemory-policy noeviction`, or keep them on a separate instance from the cache. Under `noeviction`, a full server rejects writes: the cache skips them, and coordination throws. ### A blocklist `createBlocklist` keeps a list of blocked values, such as IP addresses, in one Redis SET, for a check on every request. Values are compared exactly, so normalize them the same way when adding and checking: ```ts import { createBlocklist } from 'jg-redis'; const ipBlocklist = createBlocklist(redis, { key: 'ip-blocklist' }); await ipBlocklist.add(['203.0.113.7']); // and remove([...]), 1,000 values per command const isBlocked = await ipBlocklist.has('203.0.113.7'); await ipBlocklist.replaceAll(blockedIps); // atomic: readers see the old list or the new one ``` `replaceAll` fills a staging SET, `${key}:replace:`, and renames it over the list, so a large list is never seen half replaced; an empty list deletes the key. Every command failure throws `BLOCKLIST_FAILED`, with the cause, so a guard can fail closed and refuse the request. The list has no expiry, but a flush or an eviction empties it, and then it lets everyone through. Keep the authoritative list in a database, restore it with `replaceAll` at boot and on a schedule, and run the server with `maxmemory-policy noeviction`. On Redis Cluster, give the key a hash tag, such as `{ip-blocklist}`, so the staging key lands in the same slot and the rename works. ### Errors | Code | When | | ---------------------------- | --------------------------------------------------------------------------------- | | `INVALID_TTL` | A time-to-live isn't a whole number of seconds, from 1 | | `COORDINATION_FAILED` | Redis couldn't complete a claim, a store, or a take | | `COORDINATION_VALUE_INVALID` | A single-use value isn't valid JSON or doesn't match its schema | | `BLOCKLIST_FAILED` | Redis couldn't complete a blocklist command | | `BLOCKLIST_VALUE_INVALID` | A blocklist value isn't a string of 1 to 512 characters, or a list isn't an array | | `INVALID_BLOCKLIST_KEY` | A blocklist's key is empty | ## jg-redis API reference Every export of `jg-redis@0.2.0`, 15 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc. ### `jg-redis` - Functions: [`connectRedis`](#api-connectRedis), [`createBlocklist`](#api-createBlocklist), [`createCache`](#api-createCache) - Constants: [`ERRORS`](#api-ERRORS) - Types: [`IBlocklist`](#api-IBlocklist), [`IBlocklistOptions`](#api-IBlocklistOptions), [`ICache`](#api-ICache), [`IGetOrLoadOptions`](#api-IGetOrLoadOptions), [`IRedis`](#api-IRedis), [`IRedisClient`](#api-IRedisClient), [`IRedisOptions`](#api-IRedisOptions), [`ISchema`](#api-ISchema) #### Functions in `jg-redis` ##### `connectRedis` *Function* Connects a Redis client that's safe to share: its errors go to onError instead of crashing the process, a command sent while disconnected is rejected instead of queued and replayed later, and connecting gives up after 10 seconds unless the socket options set another timeout. Closing it waits for pending replies and never flushes any data. ```ts connectRedis(options: IRedisOptions): Promise ``` **Parameters** - `options` `IRedisOptions`: node-redis's client options, plus onError. **Returns** `Promise`: A promise that resolves to the connected client. **Example** ```ts import { connectRedis } from 'jg-redis'; const redis = await connectRedis({ socket: { host: 'localhost', port: 6379 }, password: 'secret', onError: (error) => console.error('Redis', error), }); await redis.client.set('greeting', 'hello', { EX: 60 }); await redis.close(); ``` ##### `createBlocklist` *Function* Creates a list of blocked values, such as IP addresses, in one Redis SET. Values are compared exactly, so normalize them the same way when adding and checking. The list never expires, so a flush or an eviction empties it: keep the authoritative list in a database and restore it with replaceAll, at boot or on a schedule. Every command failure throws BLOCKLIST_FAILED, with the cause, so a caller can fail closed. ```ts createBlocklist(redis: IRedis, options: IBlocklistOptions): IBlocklist ``` **Parameters** - `redis` `IRedis`: The client. - `options` `IBlocklistOptions`: The key of the SET. **Returns** `IBlocklist`: The list. **Throws** - `INVALID_BLOCKLIST_KEY`: The blocklist key must be a non-empty string. **Example** ```ts import { connectRedis, createBlocklist } from 'jg-redis'; declare const redis: Awaited>; const ipBlocklist = createBlocklist(redis, { key: 'ip-blocklist' }); await ipBlocklist.add(['203.0.113.7']); await ipBlocklist.has('203.0.113.7'); // true await ipBlocklist.replaceAll(['198.51.100.1', '198.51.100.2']); // 203.0.113.7 is gone ``` ##### `createCache` *Function* Creates a read-through cache on a Redis client, for values that are slow to load and fine to serve a little stale. A failed read loads the value instead, and a failed or oversized write is skipped, so Redis being down slows the app without breaking it; both failures go to the client's onError. Concurrent misses for one key in this process share one load. ```ts createCache(redis: IRedis): ICache ``` **Parameters** - `redis` `IRedis`: The client, from connectRedis. **Returns** `ICache`: The cache. **Example** ```ts import { connectRedis, createCache } from 'jg-redis'; import { z } from 'zod'; declare const redis: Awaited>; declare const loadProfile: (id: string) => Promise<{ name: string }>; const cache = createCache(redis); const profile = await cache.getOrLoad('user:42:profile', { schema: z.object({ name: z.string() }), ttlSeconds: 300, load: () => loadProfile('42'), }); ``` #### Constants in `jg-redis` ##### `ERRORS` *Constant* the codes for every error thrown by the package ```ts const ERRORS: { INVALID_TTL: "INVALID_TTL"; COORDINATION_FAILED: "COORDINATION_FAILED"; COORDINATION_VALUE_INVALID: "COORDINATION_VALUE_INVALID"; BLOCKLIST_FAILED: "BLOCKLIST_FAILED"; BLOCKLIST_VALUE_INVALID: "BLOCKLIST_VALUE_INVALID"; INVALID_BLOCKLIST_KEY: "INVALID_BLOCKLIST_KEY" } ``` #### Types in `jg-redis` ##### `IBlocklist` *Type* A list of blocked values, such as IP addresses, in one Redis SET. Values are compared exactly, so the caller normalizes them the same way when adding and checking. A value that isn't a string of 1 to 512 characters throws BLOCKLIST_VALUE_INVALID before any command runs, and every command failure throws BLOCKLIST_FAILED, so a caller can fail closed. ```ts type IBlocklist = { has: (value: string) => Promise; add: (values: readonly string[]) => Promise; remove: (values: readonly string[]) => Promise; replaceAll: (values: readonly string[]) => Promise; }; ``` **Members** - `has: (value: string) => Promise` (property): resolves to whether the value is in the list - `add: (values: readonly string[]) => Promise` (property): adds the values, 1,000 at a time - `remove: (values: readonly string[]) => Promise` (property): removes the values, 1,000 at a time - `replaceAll: (values: readonly string[]) => Promise` (property): replaces the whole list at once: readers see the old list or the new one, never part of it. An empty list deletes the key ##### `IBlocklistOptions` *Type* what createBlocklist takes ```ts type IBlocklistOptions = { key: string; }; ``` **Members** - `key: string` (property): the key of the Redis SET that holds the list, such as `ip-blocklist`, or `{ip-blocklist}` on Redis Cluster, so replaceAll's staging key is in the same slot ##### `ICache` *Type* a read-through cache on a Redis client ```ts type ICache = { getOrLoad: (key: string, options: IGetOrLoadOptions) => Promise; invalidate: (key: string) => Promise; }; ``` **Members** - `getOrLoad: (key: string, options: IGetOrLoadOptions) => Promise` (property): returns the cached value, or loads, caches, and returns it on a miss, sharing one load among the process's concurrent misses for the key. When Redis fails, it loads without caching - `invalidate: (key: string) => Promise` (property): deletes a cached value, so the next read loads it again. A load already in flight in this process won't store its older value afterwards ##### `IGetOrLoadOptions` *Type* what getOrLoad reads and, on a miss, loads and stores ```ts type IGetOrLoadOptions = { schema: ISchema; ttlSeconds: number; load: () => Promise; }; ``` **Members** - `schema: ISchema` (property): checks a cached value before it's returned, such as a Zod schema. A value it rejects is deleted and loaded again - `ttlSeconds: number` (property): how long a loaded value stays cached, in whole seconds, from 1 - `load: () => Promise` (property): loads the value on a miss, such as with a database query. Its error propagates, and nothing is cached ##### `IRedis` *Type* a connected Redis client ```ts type IRedis = { readonly client: IRedisClient; close: () => Promise; }; ``` **Members** - `client: IRedisClient` (property): the node-redis client, for the commands the helpers don't cover - `close: () => Promise` (property): closes the client once its pending replies arrive. It never flushes any data ##### `IRedisClient` *Type* a node-redis client, as createClient returns it ```ts type IRedisClient = ReturnType; ``` ##### `IRedisOptions` *Type* what connectRedis takes: node-redis's client options, such as the URL or the socket's host and port, plus where the client's errors go ```ts type IRedisOptions = Omit & { onError?: ((error: unknown) => void) | undefined; }; ``` ##### `ISchema` *Type* validates data and returns it typed, which a Zod schema does ```ts type ISchema = { parse(value: unknown): T; }; ``` **Members** - `parse(value: unknown): T` (method) ### `jg-redis/coordination` - Functions: [`claimOnce`](#api-coordination-claimOnce), [`storeSingleUseValue`](#api-coordination-storeSingleUseValue), [`takeSingleUseValue`](#api-coordination-takeSingleUseValue) #### Functions in `jg-redis/coordination` ##### `claimOnce` *Function* Claims a token once, such as a one-time code that must not be replayed: the first call for a token in its namespace succeeds, and every other call until the time-to-live ends doesn't. The token is stored only as its SHA-256 hash, under `:`. A Redis error throws instead of letting the token through. ```ts claimOnce( redis: IRedis, namespace: string, token: string, ttlSeconds: number, ): Promise ``` **Parameters** - `redis` `IRedis`: The client, from connectRedis. - `namespace` `string`: The key's prefix, such as `user:42:used-otp`. - `token` `string`: The token to claim. - `ttlSeconds` `number`: How long the claim lasts, in whole seconds: at least as long as the token stays valid. **Returns** `Promise`: A promise that resolves to whether this call made the claim. **Throws** - `INVALID_TTL`: The time-to-live must be a whole number of seconds, from 1. - `COORDINATION_FAILED`: Redis could not complete the coordination command. **Example** ```ts import { connectRedis } from 'jg-redis'; import { claimOnce } from 'jg-redis/coordination'; declare const redis: Awaited>; if (!(await claimOnce(redis, 'user:42:used-otp', '123456', 90))) { // the code was already used } ``` ##### `storeSingleUseValue` *Function* Stores a value that one later call takes, such as a sign-in challenge's record. Name the key after a hash of any token it's looked up by, never the token itself. ```ts storeSingleUseValue( redis: IRedis, key: string, value: unknown, ttlSeconds: number, ): Promise ``` **Parameters** - `redis` `IRedis`: The client, from connectRedis. - `key` `string`: The key, such as `user-auth:sign-in-challenge:`. - `value` `unknown`: The value, stored as JSON. - `ttlSeconds` `number`: How long it can be taken, in whole seconds. **Returns** `Promise`: A promise that resolves once it's stored. **Throws** - `INVALID_TTL`: The time-to-live must be a whole number of seconds, from 1. - `COORDINATION_FAILED`: Redis could not complete the coordination command. **Example** ```ts import { connectRedis } from 'jg-redis'; import { storeSingleUseValue } from 'jg-redis/coordination'; declare const redis: Awaited>; await storeSingleUseValue(redis, 'user-auth:challenge:9f86d081', { userId: '42' }, 300); ``` ##### `takeSingleUseValue` *Function* Takes a single-use value: reads and deletes it in one command, so of any concurrent calls, one gets it and the rest get null. A value that fails its schema is already deleted, so it throws COORDINATION_VALUE_INVALID, without a cause or the value, which might hold a secret. ```ts takeSingleUseValue( redis: IRedis, key: string, schema: ISchema, ): Promise ``` **Parameters** - `redis` `IRedis`: The client, from connectRedis. - `key` `string`: The value's key. - `schema` `ISchema`: Checks the value, such as a Zod schema. **Returns** `Promise`: A promise that resolves to the value, or null when it's missing, taken, or expired. **Throws** - `COORDINATION_FAILED`: Redis could not complete the coordination command. - `COORDINATION_VALUE_INVALID`: The single-use value is not valid JSON or does not match its schema. **Example** ```ts import { connectRedis } from 'jg-redis'; import { takeSingleUseValue } from 'jg-redis/coordination'; import { z } from 'zod'; declare const redis: Awaited>; const challenge = await takeSingleUseValue( redis, 'user-auth:challenge:9f86d081', z.object({ userId: z.string() }), ); ``` --- # jg-redis recipes Patterns for services that use Redis: wiring the client into an app, caching a slow query, stopping a one-time code's replay, and keeping an IP blocklist. ## A NestJS module Connect the client once, at startup, provide it under a token, and close it when the app shuts down: ```ts import { Module, type OnApplicationShutdown } from '@nestjs/common'; import { createEnvironmentReader } from 'jg-env'; import { connectRedis, createCache } from 'jg-redis'; const { readInteger, readString } = createEnvironmentReader(process.env); // the tokens services inject the client and the cache with, as @Inject(REDIS) export const REDIS = Symbol('REDIS'); export const CACHE = Symbol('CACHE'); const redis = await connectRedis({ socket: { host: readString('REDIS_HOST'), port: readInteger('REDIS_PORT') }, password: readString('REDIS_PASSWORD'), }); @Module({ providers: [ { provide: REDIS, useValue: redis }, { provide: CACHE, useValue: createCache(redis) }, ], exports: [REDIS, CACHE], }) export class RedisModule implements OnApplicationShutdown { async onApplicationShutdown(): Promise { await redis.close(); } } ``` The module connects when it's first imported, so a server that can't be reached stops the app at startup, which top-level `await` needs an ES module for. ## Caching a slow query Cache what's slow to compute and fine to serve a little stale, and invalidate it when the data changes: ```ts import { connectRedis, createCache } from 'jg-redis'; import { z } from 'zod'; declare const redis: Awaited>; declare const queryLeaderboard: (seasonId: string) => Promise<{ userId: string; points: number }[]>; const cache = createCache(redis); const LeaderboardSchema = z.array(z.object({ userId: z.string(), points: z.number() })); export const getLeaderboard = (seasonId: string) => cache.getOrLoad(`season:${seasonId}:leaderboard`, { schema: LeaderboardSchema, ttlSeconds: 60, load: () => queryLeaderboard(seasonId), }); export const onPointsAwarded = (seasonId: string) => cache.invalidate(`season:${seasonId}:leaderboard`); ``` When a thousand requests miss at once, one query runs and every request gets its result. If Redis is down, each request runs the query itself. ## Stopping a one-time code's replay A one-time code is valid for a short window, so one intercepted code could be used twice inside it. Claim each code after checking it, and reject a second claim: ```ts import { Exception } from 'jg-exception'; import { connectRedis } from 'jg-redis'; import { claimOnce } from 'jg-redis/coordination'; declare const redis: Awaited>; declare const isOtpValid: (secret: string, code: string) => boolean; export const verifyOtp = async (userId: string, secret: string, code: string): Promise => { if (!isOtpValid(secret, code)) { throw new Exception('The code is not valid.', { code: 'OTP_INVALID' }); } // longer than the code stays valid, so it can't come back once the claim expires if (!(await claimOnce(redis, `user:${userId}:used-otp`, code, 120))) { throw new Exception('The code was already used.', { code: 'OTP_REPLAYED' }); } }; ``` When Redis fails, `claimOnce` throws `COORDINATION_FAILED`, so the sign-in fails instead of accepting a code that may have been used. ## An IP blocklist backed by a database Redis answers the check on every request, but a flush or an eviction would empty the list and let every blocked address back in. Keep the list in a table, the source of truth: ```sql CREATE TABLE ip_blocklist ( ip varchar(64) NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT pk_ib__ip PRIMARY KEY (ip), CONSTRAINT chk_ib__ip_non_empty CHECK (ip <> '') ); ``` Write the table first, then Redis, and restore Redis from the table at boot and every few minutes: ```ts import { createPostgres } from 'jg-postgres'; import { connectRedis, createBlocklist } from 'jg-redis'; declare const postgres: ReturnType; declare const redis: Awaited>; const ipBlocklist = createBlocklist(redis, { key: 'ip-blocklist' }); // lowercased, and an IPv4-mapped IPv6 address as plain IPv4, the same as the check const normalizeIp = (ip: string): string => { const lowered = ip.trim().toLowerCase(); return lowered.startsWith('::ffff:') && lowered.includes('.') ? lowered.slice(7) : lowered; }; export const blockIp = async (ip: string): Promise => { const normalized = normalizeIp(ip); await postgres.query('INSERT INTO ip_blocklist (ip) VALUES ($1) ON CONFLICT (ip) DO NOTHING;', [ normalized, ]); await ipBlocklist.add([normalized]); }; export const unblockIp = async (ip: string): Promise => { const normalized = normalizeIp(ip); await postgres.query('DELETE FROM ip_blocklist WHERE ip = $1;', [normalized]); await ipBlocklist.remove([normalized]); }; // at boot, and on a schedule, so a flush, an eviction, or a failed write heals export const syncIpBlocklist = async (): Promise => { const rows = await postgres.query<{ ip: string }>('SELECT ip FROM ip_blocklist;'); await ipBlocklist.replaceAll(rows.map(({ ip }) => ip)); }; ``` When the Redis write fails after the table's, the change reaches Redis with the next sync: a new block waits until then, and an unblocked address stays blocked until then, which errs on the safe side. jg-nestjs's `normalizeIp` normalizes the same way, for the guard that checks the list. --- # jg-react > React hooks for online status, page visibility, Tailwind CSS breakpoints, and scroll thresholds, each safe in a server render. - **Version:** `0.3.0` - **Runtimes:** Browser - **Install:** `npm install jg-react` - **On npm:** [jg-react](https://www.npmjs.com/package/jg-react) - **Source:** [packages/jg-react](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-react) - **Recipes:** [jg-react recipes](https://jg-packages.jesusgraterol.dev/packages/jg-react/recipes/index.md) ## Usage ```ts import { isBreakpointAtLeast, useBreakpoint, useDocumentVisibility, useIsWindowScrolledPast, useOnlineStatus, } from 'jg-react'; export const useLayoutState = () => { const isOnline = useOnlineStatus(); // the browser's guess, from its network interfaces const isVisible = useDocumentVisibility(); // false while the tab is hidden const breakpoint = useBreakpoint(); // 'xs' on a phone, then 'sm' to '2xl' const isScrolled = useIsWindowScrolledPast(8); return { isOnline, isVisible, isDesktop: isBreakpointAtLeast(breakpoint, 'lg'), isScrolled }; }; ``` | Hook | Returns | Re-renders when | In a server render | | --------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------- | | `useOnlineStatus()` | Whether the browser is online | It goes online or offline | `true` | | `useDocumentVisibility()` | Whether the page is visible | Its tab is hidden or shown | `true` | | `useBreakpoint()` | Tailwind CSS's breakpoint: `xs` below `sm`, up to `2xl` | The viewport crosses a breakpoint | `undefined` | | `useIsWindowScrolledPast(pixels)` | Whether the window is scrolled further than `pixels` | The answer changes, not every scroll | `false` | | `useFormChanges(options)` | Whether a form's command differs from the last confirmed one, a submit-time check, and `confirmChanges` | The commands or the baseline change | Compared as usual | | `useResendCooldown(options)` | Whether a resend must wait, the seconds left, and `startCooldown` | Every second while cooling down | `{ isCoolingDown: false, remainingSeconds: 0 }` | | `useCopyFeedback(options?)` | Whether a copy just succeeded, and `copy(write)` | A copy succeeds, and its feedback ends | `isCopied: false` | | `useFileUploadQueue(options)` | The upload queue's items, summary, announcement, and controls | The queue changes | An empty queue | | `useResolvedTheme(preference)` | `light` or `dark`, following the system while the preference is `system` | The system's color scheme changes, for `system` | `dark` for `dark`, otherwise `light` | | `useAppliedTheme(preference, options?)` | The theme it sets as the `light` or `dark` class on `` | As `useResolvedTheme` | As `useResolvedTheme`, without touching the document | Keep a save button disabled until the form holds a real change, and a resend button waiting after each send: ```ts import { useFormChanges, useResendCooldown } from 'jg-react'; const trim = ({ name }: { name: string }) => ({ name: name.trim() }); export const useProfileEditor = (saved: { name: string }, draft: { name: string }) => { // a whitespace-only edit isn't a change, and the baseline advances only once a save succeeds const { hasChanges, confirmChanges } = useFormChanges({ confirmedCommand: saved, currentCommand: draft, normalizeCommand: trim, }); // the deadline survives a reload of the tab, apart from other accounts and actions const resend = useResendCooldown({ durationSeconds: 60, cooldownKey: 'account-a:verify-email' }); return { canSave: hasChanges, confirmChanges, resend }; }; ``` The browser hooks read the browser through `useSyncExternalStore`, so every component sees the same value in the same render, and a server render gets a fixed value instead of failing. The breakpoints are Tailwind CSS's defaults, in `rem`, read through media queries, so they follow the browser's font size as CSS does. Prefer CSS for layout, and `useBreakpoint` for what CSS can't do, such as rendering a different component. ### Copy feedback `useCopyFeedback` shows that a copy worked, for a moment. `copy` takes the function that writes, such as jg-clipboard's `copyText`, and calls it at once, before awaiting anything, so the write stays inside the click that browsers require for clipboard access: ```ts import { useCopyFeedback } from 'jg-react'; const { isCopied, copy } = useCopyFeedback({ durationSeconds: 1.4, onError: showCopyError }); const onClick = () => void copy(() => navigator.clipboard.writeText(inviteLink)); // resolves to whether it worked ``` `isCopied` stays true for `durationSeconds`, `DEFAULT_COPY_FEEDBACK_SECONDS` (1.4) by default, and a new copy restarts it. A copy while another runs is ignored and resolves to `false`. A failed one resolves to `false` and calls the latest `onError`; `copy` never rejects. After unmount, a copy that settles changes nothing and calls nothing. A duration that isn't positive and finite throws `INVALID_COPY_FEEDBACK_DURATION`. ### File uploads `useFileUploadQueue` runs a file upload queue without rendering anything, for a drop zone or a file input in the app's own markup: ```ts import { createFileUploadAcceptValue, useFileUploadQueue } from 'jg-react'; const queue = useFileUploadQueue({ acceptedFileTypes: ['image/png', 'image/jpeg', '.heic'], maxFileBytes: 10 * 1024 * 1024, maxFiles: 4, isPreviewEnabled: true, uploadFile: (file, { signal, reportProgress, uploadId }) => uploadToStorage(file, { signal, onProgress: reportProgress, idempotencyKey: uploadId }), }); // queue.addFiles(input.files), queue.items, queue.summary, queue.announcement, // queue.cancelUpload(id), queue.retryUpload(id), queue.removeFile(id), queue.reset() const accept = createFileUploadAcceptValue(queue.policy.acceptedFileTypes); ``` - **Checks:** each selection is checked file by file, in a fixed order: empty, type, size, duplicate, count, and total size, with `existingFiles` counted toward the limits. A `validateFile` function runs next, such as for an image's dimensions, and the built-in checks run again after it, since the queue may have changed meanwhile. Selections are checked one at a time. - **Uploads:** admitted files upload first in, first out, up to `maxConcurrentUploads` at once, 2 by default, or wait for `startUploads` without `autoUpload`. Progress is coalesced per frame and never goes backwards. At 100%, a file is `processing`: it waits for its result, can't be canceled, and survives removal and reset. - **Cancel and retry:** a canceled upload is aborted through its signal, and its late result is ignored, but its slot frees only when its promise settles. A retry keeps the item's `uploadId` and starts the next `attemptNumber`. - **One file:** with `maxFiles` 1, the default, a new file replaces the current one only once it passes its checks, so a rejected replacement leaves the current file alone. - **Cleanup:** preview object URLs are revoked on remove, replace, reset, and unmount, and an error a callback throws goes to `reportError` without stopping the queue. Shown again after an `` hid it, files whose work stopped are canceled. - **Text:** every message and announcement comes from `DEFAULT_FILE_UPLOAD_MESSAGES`, which `messages` overrides, and `getErrorFeedback` decides what a failed upload shows, `messages.uploadFailed` by default. A policy that isn't valid, such as a `maxFileBytes` that isn't a positive integer, throws `INVALID_FILE_UPLOAD_POLICY`, with a message for each field. `resolveFileUploadPolicy`, `validateFileUploadCandidate`, `isFileUploadItemCancelable`, and `hasDraggedFiles` work on their own too. ### Light and dark themes `createThemeInitScript` builds an inline script that applies the stored preference before the page paints, so a server render never flashes the wrong theme, and `useAppliedTheme` keeps the class on `` in step once React runs: ```tsx import { createThemeInitScript, useAppliedTheme } from 'jg-react'; // jg-storage's createLocalStore('preferences') keeps { theme } under this key const script = createThemeInitScript({ storageKey: 'jg-storage:preferences', field: 'theme' }); export const ThemeScript = ({ nonce }: { nonce: string }) => (