@sdxc/structured-fields 2026.10.2 → 2026.10.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +156 -104
  2. package/package.json +12 -3
package/README.md CHANGED
@@ -2,39 +2,39 @@
2
2
 
3
3
  Parse and serialize RFC 9651 structured HTTP field values.
4
4
 
5
+ [RFC 9651](https://www.rfc-editor.org/rfc/rfc9651) is the grammar behind fields such as
6
+ `Priority`, `Cache-Status`, `RateLimit`, `Idempotency-Key` and `Repr-Digest`: each is a List, a
7
+ Dictionary or an Item of typed bare values. Every call names that top-level type (`"list"`,
8
+ `"dictionary"` or `"item"`), since the field's definition fixes it. Parsing passes the HTTP
9
+ working group's [structured-field-tests](https://github.com/httpwg/structured-field-tests) suite.
10
+
5
11
  ## Installation
6
12
 
7
- ```bash
13
+ ```sh
8
14
  npm add @sdxc/structured-fields
9
15
  ```
10
16
 
11
17
  Results are [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) values, and the
12
- `./schema` builders compose with [`remix`](https://www.npmjs.com/package/remix)'s
13
- `remix/data-schema`. Both install alongside this package.
14
-
15
- [RFC 9651](https://www.rfc-editor.org/rfc/rfc9651) defines one grammar for new HTTP fields:
16
- `Priority`, `Cache-Status`, `RateLimit`, `Idempotency-Key`, `Repr-Digest` and the client hints
17
- are all a List, a Dictionary or an Item of typed bare values. This package implements the RFC's
18
- parsing and serialization algorithms, checked against the HTTP working group's
19
- [structured-field-tests](https://github.com/httpwg/structured-field-tests) suite. Every call
20
- names the field's top-level type (`"list"`, `"dictionary"` or `"item"`), because the RFC fixes
21
- it in the field's definition rather than in the text. Parsing fails the whole field on any
22
- error, as the RFC requires, and never returns a partial value.
18
+ `./schema` builders are `remix/data-schema` schemas from [`remix`](https://www.npmjs.com/package/remix).
23
19
 
24
20
  ## Usage
25
21
 
26
- ### Write A Field
22
+ ### Write a field
27
23
 
28
24
  ```typescript
29
25
  import { setField, stringify } from "@sdxc/structured-fields";
30
26
 
27
+ let headers = new Headers();
31
28
  setField(headers, "RateLimit", { limit: 10, remaining: 0, reset: 7 }, "dictionary");
32
29
  // RateLimit: limit=10, remaining=0, reset=7
33
30
 
34
31
  stringify({ value: 10, params: { w: 60 } }, "item"); // success: "10;w=60"
35
32
  ```
36
33
 
37
- ### Read The Untyped Model
34
+ Members take their shorthand: a bare value for a member without parameters, `params` omitted
35
+ anywhere, and `true` written as the bare key.
36
+
37
+ ### Read the untyped model
38
38
 
39
39
  ```typescript
40
40
  import { parse } from "@sdxc/structured-fields";
@@ -46,7 +46,21 @@ let parsed = parse('ExampleCache; hit; ttl=376, "Origin"; fwd=uri-miss', "list")
46
46
  // ]
47
47
  ```
48
48
 
49
- ### Read A Field Through A Schema
49
+ Any error fails the whole field with a `StructuredFieldParseError`; a failure never carries a
50
+ partial value.
51
+
52
+ ### Keep a type a primitive cannot carry
53
+
54
+ ```typescript
55
+ import { Decimal, DisplayString, Token, stringify } from "@sdxc/structured-fields";
56
+
57
+ stringify([new Token("HIT"), "HIT", new Decimal(1), 1, new DisplayString("fü")], "list");
58
+ // success: 'HIT, "HIT", 1.0, 1, %"f%c3%bc"'
59
+ ```
60
+
61
+ `parse` returns the same wrappers, so a round trip keeps every type.
62
+
63
+ ### Read a field through a schema
50
64
 
51
65
  ```typescript
52
66
  import { isSuccess } from "@sdxc/result";
@@ -55,18 +69,18 @@ import { sf } from "@sdxc/structured-fields/schema";
55
69
  import * as s from "remix/data-schema";
56
70
  import * as checks from "remix/data-schema/checks";
57
71
 
58
- const PRIORITY = s.object({
72
+ let priority = s.object({
59
73
  u: s.optional(sf.value(sf.integer().pipe(checks.min(0), checks.max(7)))),
60
74
  i: s.optional(sf.value(s.boolean())),
61
75
  });
62
76
 
63
- let priority = getField(request.headers, "Priority", "dictionary", PRIORITY);
64
- if (isSuccess(priority) && priority.data) {
65
- priority.data.u; // number | undefined
77
+ let result = getField(request.headers, "Priority", "dictionary", priority);
78
+ if (isSuccess(result) && result.data) {
79
+ result.data.u; // number | undefined
66
80
  }
67
81
  ```
68
82
 
69
- `sf.*` schemas hold no state, so declare a field's schema once at module scope.
83
+ `sf.*` schemas hold no state, so build a field's schema once at module scope.
70
84
 
71
85
  ## API
72
86
 
@@ -74,90 +88,56 @@ if (isSuccess(priority) && priority.data) {
74
88
 
75
89
  #### `parse(text, type, schema?)`
76
90
 
77
- Parses a field value as `type`. Without a schema it returns the model (`SF.ValueOf[type]`) or a
78
- `StructuredFieldParseError`. With a synchronous Standard Schema it validates the model and
79
- returns the schema's output, or a `ValidationError` carrying the issues. Several field lines
80
- joined with `,` (what `Headers#get` returns) are valid input.
81
-
82
- ```typescript
83
- parse("u=1, i", "dictionary"); // success: { u: { value: 1, params: {} }, i: { value: true, params: {} } }
84
- parse("1, 2,", "list"); // failure: StructuredFieldParseError, position 5
85
- ```
91
+ Parses a field value as `type` into `SF.ValueOf[type]`, or fails with a
92
+ `StructuredFieldParseError`. With a synchronous Standard Schema, it answers the schema's output
93
+ or a `ValidationError`. Field lines joined with `,` (what `Headers#get` returns) parse as one
94
+ value.
86
95
 
87
96
  #### `stringify(value, type)`
88
97
 
89
- Serializes to the canonical text, or fails with a `StructuredFieldStringifyError` whose `path`
90
- names the offending value. An empty List or Dictionary succeeds with `""`. Members may be
91
- written in their shorthand forms: a bare value for a member without parameters, `params`
92
- omitted anywhere.
93
-
94
- - A `number` that is an integer writes as an Integer (at most 15 digits), any other `number`
95
- as a Decimal rounded half-to-even to three fractional digits
96
- - A `string` writes as a quoted sf-string, which carries printable ASCII only
97
- - A member or parameter whose value is `true` writes as the bare key
98
- - A `Date` writes as whole seconds; one with milliseconds fails
99
-
100
- ```typescript
101
- stringify([1, 1.5, new Decimal(1)], "list"); // success: "1, 1.5, 1.0"
102
- stringify({ i: true, u: 3 }, "dictionary"); // success: "i, u=3"
103
- stringify("fü", "item"); // failure: use new DisplayString("fü")
104
- ```
98
+ Serializes `SF.InputOf[type]` to canonical text, or fails with a
99
+ `StructuredFieldStringifyError`. An empty List or Dictionary succeeds with `""`. An integral
100
+ `number` writes as an Integer (at most 15 digits), any other as a Decimal rounded half-to-even to
101
+ three digits; a `string` writes as a quoted ASCII sf-string; a `Date` writes as whole seconds.
105
102
 
106
103
  #### `getField(headers, name, type, schema?)`
107
104
 
108
- Reads a field from `Headers` through `parse`. An absent field succeeds with `null`.
105
+ `parse` over `headers.get(name)`. An absent field succeeds with `null`.
109
106
 
110
107
  #### `setField(headers, name, value, type)`
111
108
 
112
- Writes a field onto `Headers` through `stringify`, replacing any earlier value. An empty List or
113
- Dictionary deletes the field. A failure leaves `headers` untouched.
109
+ `stringify` onto `headers.set(name, …)`, replacing any earlier value. An empty List or
110
+ Dictionary deletes the field, and a failure leaves `headers` untouched.
114
111
 
115
112
  #### `Token`, `Decimal`, `DisplayString`
116
113
 
117
- Wrappers, each holding a readonly `value`, for the bare item types a JavaScript primitive cannot
118
- tell apart: `new Token("HIT")` writes `HIT` where `"HIT"` writes `"HIT"`, `new Decimal(1)`
119
- writes `1.0`, and `new DisplayString("fü")` writes `%"f%c3%bc"`. `parse` returns them, so a
120
- round trip is lossless. Byte Sequences are `Uint8Array`, Dates are `Date`, Booleans are
121
- `boolean`.
114
+ Wrappers holding a readonly `value` for the types a primitive cannot tell apart: an sf-token
115
+ (`HIT` against the string `"HIT"`), a Decimal (`1.0` against the Integer `1`), and a Unicode
116
+ Display String (`%"f%c3%bc"`). Byte Sequences are `Uint8Array`, Dates `Date`, Booleans `boolean`.
122
117
 
123
118
  #### `StructuredFieldParseError`
124
119
 
125
- `position` is the offset into the field value where parsing stopped. RFC 9651 has recipients
126
- ignore an invalid field as a whole, so treat this failure as an absent field.
120
+ `position` is the offset where parsing stopped. RFC 9651 has a recipient ignore an invalid field,
121
+ so treat this failure as an absent field.
127
122
 
128
123
  #### `StructuredFieldStringifyError`
129
124
 
130
- `path` lists the keys and indices leading to the value with no representation, such as
125
+ `path` lists the keys and indices to the value with no representation, such as
131
126
  `[0, "params", "q"]`.
132
127
 
133
128
  #### `ValidationError`
134
129
 
135
- `remix/data-schema`'s error, re-exported so a caller branches on both failures from one import.
136
- `issues` holds the Standard Schema issues.
137
-
138
- #### `SF` (types)
139
-
140
- | Type | Meaning |
141
- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
142
- | `SF.BareItem` | `number \| Decimal \| string \| Token \| Uint8Array \| boolean \| Date \| DisplayString` |
143
- | `SF.Parameters` | `Record<string, BareItem>` on a null-prototype object, in field order |
144
- | `SF.Item` | `{ value, params }` |
145
- | `SF.InnerList` | `{ items: Item[], params }` |
146
- | `SF.Member`, `SF.List`, `SF.Dictionary` | A List or Dictionary member, the List, and the Dictionary (null prototype) |
147
- | `SF.FieldType` | `"list" \| "dictionary" \| "item"` |
148
- | `SF.ValueOf`, `SF.InputOf` | What `parse` returns and what `stringify` accepts, per field type |
149
- | `SF.ItemInput`, `SF.InnerListInput`, `SF.MemberInput` | The shorthand member forms `stringify` accepts |
150
- | `SF.SyncSchema<Output>` | A Standard Schema whose `validate` answers synchronously |
130
+ `remix/data-schema`'s error, re-exported so both failures branch from one import; `issues` holds
131
+ the Standard Schema issues.
151
132
 
152
133
  ### `@sdxc/structured-fields/schema`
153
134
 
154
135
  #### `sf`
155
136
 
156
- Schema builders made with `remix/data-schema`'s `createSchema`, so they compose with
157
- `s.object`, `s.array`, `s.union`, `s.optional`, `.pipe()` and `.refine()`. Issues point at
158
- their place in the field, such as `["u", "value"]` or `[0, "items", 1, "value"]`, and carry an
159
- error-map code (`sf.integer`, `sf.token.allowed`, …) for localized messages.
160
- `StructuredFieldSchemas` is its type.
137
+ Schema builders over the model that compose with `s.object`, `s.array`, `s.union`, `s.optional`,
138
+ `.pipe()` and `.refine()`. Issue paths point into the field (`["u", "value"]`,
139
+ `[0, "items", 1, "value"]`) and carry an error-map code (`sf.integer`, `sf.token.allowed`, …)
140
+ for localized messages.
161
141
 
162
142
  | Builder | Accepts | Outputs |
163
143
  | ----------------------------- | --------------------------------- | ---------------------------------- |
@@ -171,11 +151,25 @@ error-map code (`sf.integer`, `sf.token.allowed`, …) for localized messages.
171
151
  | `sf.value(value)` | An Item, parameters ignored | the bare value's output |
172
152
  | `sf.innerList(item, params?)` | An Inner List | `{ items, params }` |
173
153
 
174
- A plain sf-string and a Boolean need no helper: `s.string()` and `s.boolean()`. Parameters and
175
- Dictionaries are `s.object(...)`, with `s.optional` for keys a field allows but does not
176
- require; the object schema's `unknownKeys` option decides what happens to the rest.
154
+ An sf-string is `s.string()` and a Boolean `s.boolean()`. Parameters and Dictionaries are
155
+ `s.object(...)`, whose `unknownKeys` option decides what happens to keys the schema omits.
177
156
 
178
- ## Pattern: Read RFC 9211 Cache-Status
157
+ ### Types
158
+
159
+ | Type | Meaning |
160
+ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
161
+ | `SF.BareItem` | `number \| Decimal \| string \| Token \| Uint8Array \| boolean \| Date \| DisplayString` |
162
+ | `SF.Parameters` | `Record<string, BareItem>` on a null-prototype object, in field order |
163
+ | `SF.Item` | `{ value, params }` |
164
+ | `SF.InnerList` | `{ items: Item[], params }` |
165
+ | `SF.Member`, `SF.List`, `SF.Dictionary` | A List or Dictionary member, the List, and the Dictionary (null prototype) |
166
+ | `SF.FieldType` | `"list" \| "dictionary" \| "item"` |
167
+ | `SF.ValueOf`, `SF.InputOf` | What `parse` returns and what `stringify` accepts, per field type |
168
+ | `SF.ItemInput`, `SF.InnerListInput`, `SF.MemberInput` | The shorthand member forms `stringify` accepts |
169
+ | `SF.SyncSchema<Output>` | A Standard Schema whose `validate` answers synchronously |
170
+ | `StructuredFieldSchemas` | The type of `sf`, from `./schema` |
171
+
172
+ ## Pattern: Read Cache-Status Hops
179
173
 
180
174
  A List of cache hops, each a Token or String naming the cache, with typed parameters.
181
175
 
@@ -184,49 +178,83 @@ import { getField } from "@sdxc/structured-fields";
184
178
  import { sf } from "@sdxc/structured-fields/schema";
185
179
  import * as s from "remix/data-schema";
186
180
 
187
- const HOP = sf.item(
181
+ let hop = sf.item(
188
182
  s.union([sf.token(), s.string()]),
189
183
  s.object({
190
184
  hit: s.optional(s.boolean()),
191
- fwd: s.optional(
192
- sf.token([
193
- "bypass",
194
- "method",
195
- "uri-miss",
196
- "vary-miss",
197
- "miss",
198
- "request",
199
- "stale",
200
- "partial",
201
- ]),
202
- ),
185
+ fwd: s.optional(sf.token(["bypass", "method", "uri-miss", "vary-miss", "miss", "stale"])),
203
186
  ttl: s.optional(sf.integer()),
204
- stored: s.optional(s.boolean()),
205
187
  }),
206
188
  );
207
189
 
208
- let hops = getField(response.headers, "Cache-Status", "list", s.array(HOP));
190
+ let hops = getField(response.headers, "Cache-Status", "list", s.array(hop));
209
191
  ```
210
192
 
211
193
  ## Pattern: An Idempotency-Key Item
212
194
 
213
- The field is an Item whose value is an sf-string. Reading unwraps it; writing quotes and escapes
214
- it.
195
+ The field is an Item holding an sf-string: reading unwraps it, writing quotes and escapes it.
215
196
 
216
197
  ```typescript
217
- import { getField, stringify } from "@sdxc/structured-fields";
198
+ import { getField, setField } from "@sdxc/structured-fields";
218
199
  import { sf } from "@sdxc/structured-fields/schema";
219
200
  import * as s from "remix/data-schema";
220
201
 
221
202
  let key = getField(request.headers, "Idempotency-Key", "item", sf.value(s.string()));
222
- let header = stringify("8e03978e-40d5-43e8-bc93-6894a57f9324", "item");
223
- // success: "\"8e03978e-40d5-43e8-bc93-6894a57f9324\""
203
+
204
+ let headers = new Headers();
205
+ setField(headers, "Idempotency-Key", "8e03978e-40d5-43e8-bc93-6894a57f9324", "item");
206
+ // Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
207
+ ```
208
+
209
+ ## Pattern: Digests As Byte Sequences
210
+
211
+ `Repr-Digest` is a Dictionary of Byte Sequences; a `Uint8Array` writes as `:base64:` and
212
+ `sf.bytes()` reads it back.
213
+
214
+ ```typescript
215
+ import { getField, setField } from "@sdxc/structured-fields";
216
+ import { sf } from "@sdxc/structured-fields/schema";
217
+ import * as s from "remix/data-schema";
218
+
219
+ let body = new TextEncoder().encode("hello");
220
+ let digest = new Uint8Array(await crypto.subtle.digest("SHA-256", body));
221
+ setField(response.headers, "Repr-Digest", { "sha-256": digest }, "dictionary");
222
+
223
+ let digests = getField(
224
+ response.headers,
225
+ "Repr-Digest",
226
+ "dictionary",
227
+ s.object({ "sha-256": s.optional(sf.value(sf.bytes())) }),
228
+ );
229
+ ```
230
+
231
+ ## Pattern: Inner Lists With Parameters
232
+
233
+ A Dictionary member can be a parenthesized list carrying its own parameters, as in a signature's
234
+ covered components.
235
+
236
+ ```typescript
237
+ import { parse, stringify } from "@sdxc/structured-fields";
238
+ import { sf } from "@sdxc/structured-fields/schema";
239
+ import * as s from "remix/data-schema";
240
+
241
+ let text = stringify(
242
+ { sig1: { items: ["@method", "@path"], params: { created: 1_700_000_000 } } },
243
+ "dictionary",
244
+ );
245
+ // success: 'sig1=("@method" "@path");created=1700000000'
246
+
247
+ let input = s.object({
248
+ sig1: sf.innerList(sf.value(s.string()), s.object({ created: sf.integer() })),
249
+ });
250
+ if (text.status === "success") parse(text.data, "dictionary", input);
251
+ // success: { sig1: { items: ["@method", "@path"], params: { created: 1700000000 } } }
224
252
  ```
225
253
 
226
254
  ## Pattern: Write Only The Members You Have
227
255
 
228
- `setField` deletes the field when the Dictionary ends up empty, so build the object from the
229
- values that exist and write it unconditionally.
256
+ `setField` deletes the field when the Dictionary is empty, so build it from the values that exist
257
+ and write it unconditionally.
230
258
 
231
259
  ```typescript
232
260
  import { setField } from "@sdxc/structured-fields";
@@ -235,7 +263,31 @@ let quota: Record<string, number> = {};
235
263
  if (limit !== null) quota.limit = limit;
236
264
  if (remaining !== null) quota.remaining = remaining;
237
265
 
238
- let written = setField(headers, "RateLimit", quota, "dictionary");
266
+ setField(headers, "RateLimit", quota, "dictionary");
267
+ ```
268
+
269
+ ## Pattern: Branch On Why A Field Failed
270
+
271
+ An unparseable field is ignored as if absent; a field that parses but breaks the schema is the
272
+ client's mistake to report.
273
+
274
+ ```typescript
275
+ import { isFailure } from "@sdxc/result";
276
+ import { StructuredFieldParseError, getField } from "@sdxc/structured-fields";
277
+ import { sf } from "@sdxc/structured-fields/schema";
278
+ import * as s from "remix/data-schema";
279
+
280
+ let schema = s.object({ u: s.optional(sf.value(sf.integer())) });
281
+ let result = getField(request.headers, "Priority", "dictionary", schema);
282
+
283
+ let priority = { u: 3 };
284
+ if (isFailure(result)) {
285
+ if (!(result.error instanceof StructuredFieldParseError)) {
286
+ return Response.json({ issues: result.error.issues }, { status: 400 });
287
+ }
288
+ } else if (result.data?.u !== undefined) {
289
+ priority.u = result.data.u;
290
+ }
239
291
  ```
240
292
 
241
293
  ## Versioning
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/structured-fields",
3
- "version": "2026.10.2",
3
+ "version": "2026.10.13",
4
4
  "description": "Parse and serialize RFC 9651 structured HTTP field values",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,11 +9,11 @@
9
9
  "./schema": "./dist/schema.js"
10
10
  },
11
11
  "dependencies": {
12
- "@sdxc/result": "2026.10.2",
12
+ "@sdxc/result": "2026.10.13",
13
13
  "@standard-schema/spec": "^1.1.0",
14
14
  "remix": "3.0.0"
15
15
  },
16
- "gitHead": "7582e46f150b052ba145945661ac9937f5f26f27",
16
+ "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },
@@ -21,5 +21,14 @@
21
21
  "type": "git",
22
22
  "url": "git+https://github.com/sergiodxa/monorepo.git",
23
23
  "directory": "packages/structured-fields"
24
+ },
25
+ "homepage": "https://sdxc.sergiodxa.com/api/structured-fields",
26
+ "author": {
27
+ "name": "Sergio Xalambrí",
28
+ "url": "https://sergiodxa.com"
29
+ },
30
+ "funding": {
31
+ "type": "github",
32
+ "url": "https://github.com/sponsors/sergiodxa"
24
33
  }
25
34
  }