UNPKG

@appsemble/node-utils

Version:

NodeJS utilities used by Appsemble internally.

117 lines 4.08 kB
import { createHash } from 'node:crypto'; import sortKeys from 'sort-keys'; import { AppsembleError } from './AppsembleError.js'; import { throwKoaError } from './koa.js'; // Server-managed metadata keys that are echoed in resource responses but are not // part of the resource's identity for ETag purposes. Stripped only at the top // level; nested user data may legitimately contain keys with the same names. const ignoredEtagFields = new Set(['$author', '$editor', '$etag', '$group', '$seed', '$ephemeral']); function canonicalize(value) { if (value instanceof Date) { return value.toJSON(); } if (Array.isArray(value)) { return value.map(canonicalize); } if (value && typeof value === 'object') { return sortKeys(Object.fromEntries(Object.entries(value).map(([key, entry]) => [ key, canonicalize(entry), ])), { deep: true }); } return value; } export function createResourceEtag(resource) { const stripped = {}; for (const [key, value] of Object.entries(resource)) { if (!ignoredEtagFields.has(key)) { stripped[key] = value; } } const canonicalResource = JSON.stringify(canonicalize(stripped)); const hash = createHash('sha256').update(canonicalResource, 'utf8').digest('base64url'); return `"${hash}"`; } export function addResourceEtag(resource) { return { ...resource, $etag: createResourceEtag(resource), }; } // RFC 7232 §3.1: If-Match uses the strong-comparison function, so weak // validators (prefixed with `W/`) must not match. Tokens are split with a // quoted-string-aware parser instead of a naive `,` split. function parseIfMatchValue(ifMatch) { if (ifMatch === '*') { return ['*']; } const tokens = []; let i = 0; while (i < ifMatch.length) { while (i < ifMatch.length && (ifMatch[i] === ' ' || ifMatch[i] === ',' || ifMatch[i] === '\t')) { i += 1; } if (i >= ifMatch.length) { break; } // Skip any leading weak prefix; strong comparison rejects it. let weak = false; if (ifMatch[i] === 'W' && ifMatch[i + 1] === '/') { weak = true; i += 2; } if (ifMatch[i] !== '"') { // Malformed token; skip until next comma. while (i < ifMatch.length && ifMatch[i] !== ',') { i += 1; } continue; } const start = i; i += 1; while (i < ifMatch.length && ifMatch[i] !== '"') { i += 1; } if (i >= ifMatch.length) { break; } i += 1; if (!weak) { tokens.push(ifMatch.slice(start, i)); } } return tokens; } export function matchesResourceIfMatch(ifMatch, currentEtag) { if (!ifMatch) { return true; } const values = new Set((Array.isArray(ifMatch) ? ifMatch : [ifMatch]).flatMap(parseIfMatchValue)); return values.has('*') || values.has(currentEtag); } export function setResourceEtagHeader(ctx, resource) { if (!resource) { return; } const etag = typeof resource.$etag === 'string' ? resource.$etag : createResourceEtag(resource); ctx.set('ETag', etag); } export class ResourcePreconditionFailedError extends AppsembleError { constructor(resourceType, resourceId) { super('This resource has changed since it was loaded. Fetch the latest version and try again.'); this.error = 'Precondition Failed'; this.statusCode = 412; this.name = 'ResourcePreconditionFailedError'; this.data = { code: 'RESOURCE_PRECONDITION_FAILED', resourceId, resourceType, }; } } export function throwResourcePreconditionFailedKoaError(ctx, resourceType, resourceId) { const error = new ResourcePreconditionFailedError(resourceType, resourceId); throwKoaError(ctx, error.statusCode, error.message, error.data); } //# sourceMappingURL=resourceEtag.js.map