sdxc

[ HTTP & responses ]

@sdxc/structured-fields

Parse and serialize RFC 9651 structured HTTP field values

npm add @sdxc/structured-fields remix
pnpm add @sdxc/structured-fields remix
yarn add @sdxc/structured-fields remix
bun add @sdxc/structured-fields remix
Installs with
@sdxc/result
Depends on
@standard-schema/spec

Parse and serialize RFC 9651 structured HTTP field values.

RFC 9651 is the grammar behind fields such as Priority, Cache-Status, RateLimit, Idempotency-Key and Repr-Digest: each is a List, a Dictionary or an Item of typed bare values. Every call names that top-level type ("list", "dictionary" or "item"), since the field's definition fixes it. Parsing passes the HTTP working group's structured-field-tests suite.

Installation

npm add @sdxc/structured-fields

Results are @sdxc/result values, and the ./schema builders are remix/data-schema schemas from remix.

Usage

Write a field

import { setField, stringify } from "@sdxc/structured-fields";

let headers = new Headers();
setField(headers, "RateLimit", { limit: 10, remaining: 0, reset: 7 }, "dictionary");
// RateLimit: limit=10, remaining=0, reset=7

stringify({ value: 10, params: { w: 60 } }, "item"); // success: "10;w=60"

Members take their shorthand: a bare value for a member without parameters, params omitted anywhere, and true written as the bare key.

Read the untyped model

import { parse } from "@sdxc/structured-fields";

let parsed = parse('ExampleCache; hit; ttl=376, "Origin"; fwd=uri-miss', "list");
// success: [
//   { value: Token("ExampleCache"), params: { hit: true, ttl: 376 } },
//   { value: "Origin", params: { fwd: Token("uri-miss") } },
// ]

Any error fails the whole field with a StructuredFieldParseError; a failure never carries a partial value.

Keep a type a primitive cannot carry

import { Decimal, DisplayString, Token, stringify } from "@sdxc/structured-fields";

stringify([new Token("HIT"), "HIT", new Decimal(1), 1, new DisplayString("fü")], "list");
// success: 'HIT, "HIT", 1.0, 1, %"f%c3%bc"'

parse returns the same wrappers, so a round trip keeps every type.

Read a field through a schema

import { isSuccess } from "@sdxc/result";
import { getField } from "@sdxc/structured-fields";
import { sf } from "@sdxc/structured-fields/schema";
import * as s from "remix/data-schema";
import * as checks from "remix/data-schema/checks";

let priority = s.object({
	u: s.optional(sf.value(sf.integer().pipe(checks.min(0), checks.max(7)))),
	i: s.optional(sf.value(s.boolean())),
});

let result = getField(request.headers, "Priority", "dictionary", priority);
if (isSuccess(result) && result.data) {
	result.data.u; // number | undefined
}

sf.* schemas hold no state, so build a field's schema once at module scope.

API

@sdxc/structured-fields

parse(text, type, schema?)

Parses a field value as type into SF.ValueOf[type], or fails with a StructuredFieldParseError. With a synchronous Standard Schema, it answers the schema's output or a ValidationError. Field lines joined with , (what Headers#get returns) parse as one value.

stringify(value, type)

Serializes SF.InputOf[type] to canonical text, or fails with a StructuredFieldStringifyError. An empty List or Dictionary succeeds with "". An integral number writes as an Integer (at most 15 digits), any other as a Decimal rounded half-to-even to three digits; a string writes as a quoted ASCII sf-string; a Date writes as whole seconds.

getField(headers, name, type, schema?)

parse over headers.get(name). An absent field succeeds with null.

setField(headers, name, value, type)

stringify onto headers.set(name, …), replacing any earlier value. An empty List or Dictionary deletes the field, and a failure leaves headers untouched.

Token, Decimal, DisplayString

Wrappers holding a readonly value for the types a primitive cannot tell apart: an sf-token (HIT against the string "HIT"), a Decimal (1.0 against the Integer 1), and a Unicode Display String (%"f%c3%bc"). Byte Sequences are Uint8Array, Dates Date, Booleans boolean.

StructuredFieldParseError

position is the offset where parsing stopped. RFC 9651 has a recipient ignore an invalid field, so treat this failure as an absent field.

StructuredFieldStringifyError

path lists the keys and indices to the value with no representation, such as [0, "params", "q"].

ValidationError

remix/data-schema's error, re-exported so both failures branch from one import; issues holds the Standard Schema issues.

@sdxc/structured-fields/schema

sf

Schema builders over the model that compose with s.object, s.array, s.union, s.optional, .pipe() and .refine(). Issue paths point into the field (["u", "value"], [0, "items", 1, "value"]) and carry an error-map code (sf.integer, sf.token.allowed, …) for localized messages.

BuilderAcceptsOutputs
sf.integer()An Integer within 15 digitsnumber
sf.decimal()A Decimal or an Integernumber
sf.token(allowed?)A Token, optionally one of a listits text, narrowed to the literals
sf.displayString()A Display Stringstring
sf.bytes()A Byte SequenceUint8Array
sf.date()A DateDate
sf.item(value, params?)An Item{ value, params }
sf.value(value)An Item, parameters ignoredthe bare value's output
sf.innerList(item, params?)An Inner List{ items, params }

An sf-string is s.string() and a Boolean s.boolean(). Parameters and Dictionaries are s.object(...), whose unknownKeys option decides what happens to keys the schema omits.

Types

TypeMeaning
SF.BareItemnumber | Decimal | string | Token | Uint8Array | boolean | Date | DisplayString
SF.ParametersRecord<string, BareItem> on a null-prototype object, in field order
SF.Item{ value, params }
SF.InnerList{ items: Item[], params }
SF.Member, SF.List, SF.DictionaryA List or Dictionary member, the List, and the Dictionary (null prototype)
SF.FieldType"list" | "dictionary" | "item"
SF.ValueOf, SF.InputOfWhat parse returns and what stringify accepts, per field type
SF.ItemInput, SF.InnerListInput, SF.MemberInputThe shorthand member forms stringify accepts
SF.SyncSchema<Output>A Standard Schema whose validate answers synchronously
StructuredFieldSchemasThe type of sf, from ./schema

Pattern: Read Cache-Status Hops

A List of cache hops, each a Token or String naming the cache, with typed parameters.

import { getField } from "@sdxc/structured-fields";
import { sf } from "@sdxc/structured-fields/schema";
import * as s from "remix/data-schema";

let hop = sf.item(
	s.union([sf.token(), s.string()]),
	s.object({
		hit: s.optional(s.boolean()),
		fwd: s.optional(sf.token(["bypass", "method", "uri-miss", "vary-miss", "miss", "stale"])),
		ttl: s.optional(sf.integer()),
	}),
);

let hops = getField(response.headers, "Cache-Status", "list", s.array(hop));

Pattern: An Idempotency-Key Item

The field is an Item holding an sf-string: reading unwraps it, writing quotes and escapes it.

import { getField, setField } from "@sdxc/structured-fields";
import { sf } from "@sdxc/structured-fields/schema";
import * as s from "remix/data-schema";

let key = getField(request.headers, "Idempotency-Key", "item", sf.value(s.string()));

let headers = new Headers();
setField(headers, "Idempotency-Key", "8e03978e-40d5-43e8-bc93-6894a57f9324", "item");
// Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"

Pattern: Digests As Byte Sequences

Repr-Digest is a Dictionary of Byte Sequences; a Uint8Array writes as :base64: and sf.bytes() reads it back.

import { getField, setField } from "@sdxc/structured-fields";
import { sf } from "@sdxc/structured-fields/schema";
import * as s from "remix/data-schema";

let body = new TextEncoder().encode("hello");
let digest = new Uint8Array(await crypto.subtle.digest("SHA-256", body));
setField(response.headers, "Repr-Digest", { "sha-256": digest }, "dictionary");

let digests = getField(
	response.headers,
	"Repr-Digest",
	"dictionary",
	s.object({ "sha-256": s.optional(sf.value(sf.bytes())) }),
);

Pattern: Inner Lists With Parameters

A Dictionary member can be a parenthesized list carrying its own parameters, as in a signature's covered components.

import { parse, stringify } from "@sdxc/structured-fields";
import { sf } from "@sdxc/structured-fields/schema";
import * as s from "remix/data-schema";

let text = stringify(
	{ sig1: { items: ["@method", "@path"], params: { created: 1_700_000_000 } } },
	"dictionary",
);
// success: 'sig1=("@method" "@path");created=1700000000'

let input = s.object({
	sig1: sf.innerList(sf.value(s.string()), s.object({ created: sf.integer() })),
});
if (text.status === "success") parse(text.data, "dictionary", input);
// success: { sig1: { items: ["@method", "@path"], params: { created: 1700000000 } } }

Pattern: Write Only The Members You Have

setField deletes the field when the Dictionary is empty, so build it from the values that exist and write it unconditionally.

import { setField } from "@sdxc/structured-fields";

let quota: Record<string, number> = {};
if (limit !== null) quota.limit = limit;
if (remaining !== null) quota.remaining = remaining;

setField(headers, "RateLimit", quota, "dictionary");

Pattern: Branch On Why A Field Failed

An unparseable field is ignored as if absent; a field that parses but breaks the schema is the client's mistake to report.

import { isFailure } from "@sdxc/result";
import { StructuredFieldParseError, getField } from "@sdxc/structured-fields";
import { sf } from "@sdxc/structured-fields/schema";
import * as s from "remix/data-schema";

let schema = s.object({ u: s.optional(sf.value(sf.integer())) });
let result = getField(request.headers, "Priority", "dictionary", schema);

let priority = { u: 3 };
if (isFailure(result)) {
	if (!(result.error instanceof StructuredFieldParseError)) {
		return Response.json({ issues: result.error.issues }, { status: 400 });
	}
} else if (result.data?.u !== undefined) {
	priority.u = result.data.u;
}

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.