@appsemble/node-utils
Version:
NodeJS utilities used by Appsemble internally.
117 lines • 4.08 kB
JavaScript
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