@sdxc/structured-fields 0.0.0-pre.1
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.
- package/LICENSE.md +21 -0
- package/README.md +269 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +71 -0
- package/dist/lib/base64.d.ts +24 -0
- package/dist/lib/base64.js +44 -0
- package/dist/lib/errors.d.ts +35 -0
- package/dist/lib/errors.js +41 -0
- package/dist/lib/parse.d.ts +19 -0
- package/dist/lib/parse.js +475 -0
- package/dist/lib/stringify.d.ts +20 -0
- package/dist/lib/stringify.js +352 -0
- package/dist/lib/types.d.ts +84 -0
- package/dist/lib/types.js +0 -0
- package/dist/lib/values.d.ts +53 -0
- package/dist/lib/values.js +65 -0
- package/dist/schema.d.ts +56 -0
- package/dist/schema.js +181 -0
- package/package.json +25 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergio Xalambrí
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# @sdxc/structured-fields
|
|
2
|
+
|
|
3
|
+
Parse and serialize RFC 9651 structured HTTP field values.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm add @sdxc/structured-fields
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
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.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### Write A Field
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import { setField, stringify } from "@sdxc/structured-fields";
|
|
30
|
+
|
|
31
|
+
setField(headers, "RateLimit", { limit: 10, remaining: 0, reset: 7 }, "dictionary");
|
|
32
|
+
// RateLimit: limit=10, remaining=0, reset=7
|
|
33
|
+
|
|
34
|
+
stringify({ value: 10, params: { w: 60 } }, "item"); // success: "10;w=60"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Read The Untyped Model
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { parse } from "@sdxc/structured-fields";
|
|
41
|
+
|
|
42
|
+
let parsed = parse('ExampleCache; hit; ttl=376, "Origin"; fwd=uri-miss', "list");
|
|
43
|
+
// success: [
|
|
44
|
+
// { value: Token("ExampleCache"), params: { hit: true, ttl: 376 } },
|
|
45
|
+
// { value: "Origin", params: { fwd: Token("uri-miss") } },
|
|
46
|
+
// ]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Read A Field Through A Schema
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import { isSuccess } from "@sdxc/result";
|
|
53
|
+
import { getField } from "@sdxc/structured-fields";
|
|
54
|
+
import { sf } from "@sdxc/structured-fields/schema";
|
|
55
|
+
import * as s from "remix/data-schema";
|
|
56
|
+
import * as checks from "remix/data-schema/checks";
|
|
57
|
+
|
|
58
|
+
const PRIORITY = s.object({
|
|
59
|
+
u: s.optional(sf.value(sf.integer().pipe(checks.min(0), checks.max(7)))),
|
|
60
|
+
i: s.optional(sf.value(s.boolean())),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let priority = getField(request.headers, "Priority", "dictionary", PRIORITY);
|
|
64
|
+
if (isSuccess(priority) && priority.data) {
|
|
65
|
+
priority.data.u; // number | undefined
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`sf.*` schemas hold no state, so declare a field's schema once at module scope.
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
### `@sdxc/structured-fields`
|
|
74
|
+
|
|
75
|
+
#### `parse(text, type, schema?)`
|
|
76
|
+
|
|
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
|
+
```
|
|
86
|
+
|
|
87
|
+
#### `stringify(value, type)`
|
|
88
|
+
|
|
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
|
+
```
|
|
105
|
+
|
|
106
|
+
#### `getField(headers, name, type, schema?)`
|
|
107
|
+
|
|
108
|
+
Reads a field from `Headers` through `parse`. An absent field succeeds with `null`.
|
|
109
|
+
|
|
110
|
+
#### `setField(headers, name, value, type)`
|
|
111
|
+
|
|
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.
|
|
114
|
+
|
|
115
|
+
#### `Token`, `Decimal`, `DisplayString`
|
|
116
|
+
|
|
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`.
|
|
122
|
+
|
|
123
|
+
#### `StructuredFieldParseError`
|
|
124
|
+
|
|
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.
|
|
127
|
+
|
|
128
|
+
#### `StructuredFieldStringifyError`
|
|
129
|
+
|
|
130
|
+
`path` lists the keys and indices leading to the value with no representation, such as
|
|
131
|
+
`[0, "params", "q"]`.
|
|
132
|
+
|
|
133
|
+
#### `ValidationError`
|
|
134
|
+
|
|
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 |
|
|
151
|
+
|
|
152
|
+
### `@sdxc/structured-fields/schema`
|
|
153
|
+
|
|
154
|
+
#### `sf`
|
|
155
|
+
|
|
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.
|
|
161
|
+
|
|
162
|
+
| Builder | Accepts | Outputs |
|
|
163
|
+
| ----------------------------- | --------------------------------- | ---------------------------------- |
|
|
164
|
+
| `sf.integer()` | An Integer within 15 digits | `number` |
|
|
165
|
+
| `sf.decimal()` | A Decimal or an Integer | `number` |
|
|
166
|
+
| `sf.token(allowed?)` | A Token, optionally one of a list | its text, narrowed to the literals |
|
|
167
|
+
| `sf.displayString()` | A Display String | `string` |
|
|
168
|
+
| `sf.bytes()` | A Byte Sequence | `Uint8Array` |
|
|
169
|
+
| `sf.date()` | A Date | `Date` |
|
|
170
|
+
| `sf.item(value, params?)` | An Item | `{ value, params }` |
|
|
171
|
+
| `sf.value(value)` | An Item, parameters ignored | the bare value's output |
|
|
172
|
+
| `sf.innerList(item, params?)` | An Inner List | `{ items, params }` |
|
|
173
|
+
|
|
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.
|
|
177
|
+
|
|
178
|
+
## Pattern: Read RFC 9211 Cache-Status
|
|
179
|
+
|
|
180
|
+
A List of cache hops, each a Token or String naming the cache, with typed parameters.
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import { getField } from "@sdxc/structured-fields";
|
|
184
|
+
import { sf } from "@sdxc/structured-fields/schema";
|
|
185
|
+
import * as s from "remix/data-schema";
|
|
186
|
+
|
|
187
|
+
const HOP = sf.item(
|
|
188
|
+
s.union([sf.token(), s.string()]),
|
|
189
|
+
s.object({
|
|
190
|
+
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
|
+
),
|
|
203
|
+
ttl: s.optional(sf.integer()),
|
|
204
|
+
stored: s.optional(s.boolean()),
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
let hops = getField(response.headers, "Cache-Status", "list", s.array(HOP));
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Pattern: An Idempotency-Key Item
|
|
212
|
+
|
|
213
|
+
The field is an Item whose value is an sf-string. Reading unwraps it; writing quotes and escapes
|
|
214
|
+
it.
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
import { getField, stringify } from "@sdxc/structured-fields";
|
|
218
|
+
import { sf } from "@sdxc/structured-fields/schema";
|
|
219
|
+
import * as s from "remix/data-schema";
|
|
220
|
+
|
|
221
|
+
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\""
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Pattern: Write Only The Members You Have
|
|
227
|
+
|
|
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.
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
import { setField } from "@sdxc/structured-fields";
|
|
233
|
+
|
|
234
|
+
let quota: Record<string, number> = {};
|
|
235
|
+
if (limit !== null) quota.limit = limit;
|
|
236
|
+
if (remaining !== null) quota.remaining = remaining;
|
|
237
|
+
|
|
238
|
+
let written = setField(headers, "RateLimit", quota, "dictionary");
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Versioning
|
|
242
|
+
|
|
243
|
+
Releases are dated rather than semantic. A version is the UTC date it was published,
|
|
244
|
+
written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one
|
|
245
|
+
release goes out per day.
|
|
246
|
+
|
|
247
|
+
Those numbers say when, not what: a later date means a later release and carries no
|
|
248
|
+
compatibility promise. Any release may change or remove an export.
|
|
249
|
+
|
|
250
|
+
Depend on one exact date, and move it when you are ready to take the change:
|
|
251
|
+
|
|
252
|
+
```json
|
|
253
|
+
{
|
|
254
|
+
"dependencies": {
|
|
255
|
+
"@sdxc/structured-fields": "2026.9.4"
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
A caret or tilde range reads the date as major, minor and patch, so it accepts every
|
|
261
|
+
later release in the same year. An exact version keeps the upgrade yours to schedule.
|
|
262
|
+
|
|
263
|
+
## License
|
|
264
|
+
|
|
265
|
+
MIT
|
|
266
|
+
|
|
267
|
+
## Author
|
|
268
|
+
|
|
269
|
+
[Sergio Xalambrí](https://sergiodxa.com)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses and serializes RFC 9651 Structured Field Values: the grammar new HTTP fields such
|
|
3
|
+
* as `Priority`, `Cache-Status` and `Idempotency-Key` are written against, with a value
|
|
4
|
+
* model that round-trips, typed parsing through a schema, and `Headers` helpers.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
11
|
+
import { ValidationError } from "remix/data-schema";
|
|
12
|
+
import type { SF } from "./lib/types.js";
|
|
13
|
+
import { StructuredFieldParseError, StructuredFieldStringifyError } from "./lib/errors.js";
|
|
14
|
+
export type { SF } from "./lib/types.js";
|
|
15
|
+
export { StructuredFieldParseError, StructuredFieldStringifyError } from "./lib/errors.js";
|
|
16
|
+
export { Decimal, DisplayString, Token } from "./lib/values.js";
|
|
17
|
+
export { ValidationError };
|
|
18
|
+
/**
|
|
19
|
+
* Parses a field value into the untyped model. Any error fails the whole field, as RFC 9651
|
|
20
|
+
* requires, so a failure never carries a partial value.
|
|
21
|
+
*
|
|
22
|
+
* @param text - The field value; several field lines joined with `,` (as `Headers#get` does)
|
|
23
|
+
* @param type - The top-level type the field's definition fixes
|
|
24
|
+
* @returns The model, or where the text stops being valid
|
|
25
|
+
* @example parse("u=1, i", "dictionary")
|
|
26
|
+
*/
|
|
27
|
+
export declare function parse<Type extends SF.FieldType>(text: string, type: Type): Result<SF.ValueOf[Type], StructuredFieldParseError>;
|
|
28
|
+
/**
|
|
29
|
+
* Parses a field value, then validates the model against a schema, so the caller receives
|
|
30
|
+
* the schema's typed output or a failure naming why the field does not fit it.
|
|
31
|
+
*
|
|
32
|
+
* @param text - The field value
|
|
33
|
+
* @param type - The top-level type the field's definition fixes
|
|
34
|
+
* @param schema - A synchronous schema over the model, such as one built with `sf.*`
|
|
35
|
+
* @returns The schema's output, or a parse or validation failure
|
|
36
|
+
*/
|
|
37
|
+
export declare function parse<Type extends SF.FieldType, Schema extends SF.SyncSchema>(text: string, type: Type, schema: Schema): Result<StandardSchemaV1.InferOutput<Schema>, StructuredFieldParseError | ValidationError>;
|
|
38
|
+
/**
|
|
39
|
+
* Serializes a value to its canonical field text. An empty List or Dictionary succeeds with
|
|
40
|
+
* `""`, which RFC 9651 reads as "do not send the field".
|
|
41
|
+
*
|
|
42
|
+
* @param value - The model, or any member written as its bare value
|
|
43
|
+
* @param type - The top-level type the field's definition fixes; a Dictionary keyed
|
|
44
|
+
* `value` and `params` looks like an Item, so the value alone cannot say which to write
|
|
45
|
+
* @returns The text, or the path to the value with no RFC 9651 representation
|
|
46
|
+
* @example stringify({ value: 10, params: { w: 60 } }, "item") // "10;w=60"
|
|
47
|
+
*/
|
|
48
|
+
export declare function stringify<Type extends SF.FieldType>(value: SF.InputOf[Type], type: Type): Result<string, StructuredFieldStringifyError>;
|
|
49
|
+
/**
|
|
50
|
+
* Reads a field from `Headers`. Repeated field lines arrive joined with `, `, which is how
|
|
51
|
+
* RFC 9651 combines them.
|
|
52
|
+
*
|
|
53
|
+
* @param headers - The request or response headers
|
|
54
|
+
* @param name - The field name
|
|
55
|
+
* @param type - The top-level type the field's definition fixes
|
|
56
|
+
* @returns The model, `null` when the field is absent, or why it is invalid
|
|
57
|
+
*/
|
|
58
|
+
export declare function getField<Type extends SF.FieldType>(headers: Headers, name: string, type: Type): Result<SF.ValueOf[Type] | null, StructuredFieldParseError>;
|
|
59
|
+
/**
|
|
60
|
+
* Reads a field from `Headers` and validates it against a schema.
|
|
61
|
+
*
|
|
62
|
+
* @param headers - The request or response headers
|
|
63
|
+
* @param name - The field name
|
|
64
|
+
* @param type - The top-level type the field's definition fixes
|
|
65
|
+
* @param schema - A synchronous schema over the model
|
|
66
|
+
* @returns The schema's output, `null` when the field is absent, or why it does not fit
|
|
67
|
+
*/
|
|
68
|
+
export declare function getField<Type extends SF.FieldType, Schema extends SF.SyncSchema>(headers: Headers, name: string, type: Type, schema: Schema): Result<StandardSchemaV1.InferOutput<Schema> | null, StructuredFieldParseError | ValidationError>;
|
|
69
|
+
/**
|
|
70
|
+
* Writes a field onto `Headers`, replacing any earlier value. An empty List or Dictionary
|
|
71
|
+
* deletes the field instead, since RFC 9651 treats an empty one as absent.
|
|
72
|
+
*
|
|
73
|
+
* @param headers - The headers to write
|
|
74
|
+
* @param name - The field name
|
|
75
|
+
* @param value - The model, or any member written as its bare value
|
|
76
|
+
* @param type - The top-level type the field's definition fixes
|
|
77
|
+
* @returns Success once written, or why the value has no representation, leaving
|
|
78
|
+
* `headers` untouched
|
|
79
|
+
*/
|
|
80
|
+
export declare function setField<Type extends SF.FieldType>(headers: Headers, name: string, value: SF.InputOf[Type], type: Type): Result<void, StructuredFieldStringifyError>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
2
|
+
import { ValidationError } from "remix/data-schema";
|
|
3
|
+
import { StructuredFieldParseError, StructuredFieldStringifyError } from "./lib/errors.js";
|
|
4
|
+
import { parseField } from "./lib/parse.js";
|
|
5
|
+
import { stringifyField } from "./lib/stringify.js";
|
|
6
|
+
export { StructuredFieldParseError, StructuredFieldStringifyError } from "./lib/errors.js";
|
|
7
|
+
export { Decimal, DisplayString, Token } from "./lib/values.js";
|
|
8
|
+
export { ValidationError };
|
|
9
|
+
export function parse(text, type, schema) {
|
|
10
|
+
let parsed = parseField(text, type);
|
|
11
|
+
if (isFailure(parsed) || schema === undefined)
|
|
12
|
+
return parsed;
|
|
13
|
+
return validateModel(parsed.data, schema);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Runs a schema over a parsed model. A schema that answers with a Promise despite its type
|
|
17
|
+
* fails validation, keeping every parse synchronous.
|
|
18
|
+
*
|
|
19
|
+
* @param model - The parsed field
|
|
20
|
+
* @param schema - The caller's schema
|
|
21
|
+
* @returns The schema's output, or its issues
|
|
22
|
+
*/
|
|
23
|
+
function validateModel(model, schema) {
|
|
24
|
+
let result = schema["~standard"].validate(model);
|
|
25
|
+
if (result instanceof Promise) {
|
|
26
|
+
return failure(new ValidationError([{ message: "Schema must validate synchronously" }]));
|
|
27
|
+
}
|
|
28
|
+
if (result.issues)
|
|
29
|
+
return failure(new ValidationError(result.issues));
|
|
30
|
+
return success(result.value);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Serializes a value to its canonical field text. An empty List or Dictionary succeeds with
|
|
34
|
+
* `""`, which RFC 9651 reads as "do not send the field".
|
|
35
|
+
*
|
|
36
|
+
* @param value - The model, or any member written as its bare value
|
|
37
|
+
* @param type - The top-level type the field's definition fixes; a Dictionary keyed
|
|
38
|
+
* `value` and `params` looks like an Item, so the value alone cannot say which to write
|
|
39
|
+
* @returns The text, or the path to the value with no RFC 9651 representation
|
|
40
|
+
* @example stringify({ value: 10, params: { w: 60 } }, "item") // "10;w=60"
|
|
41
|
+
*/
|
|
42
|
+
export function stringify(value, type) {
|
|
43
|
+
return stringifyField(value, type);
|
|
44
|
+
}
|
|
45
|
+
export function getField(headers, name, type, schema) {
|
|
46
|
+
let text = headers.get(name);
|
|
47
|
+
if (text === null)
|
|
48
|
+
return success(null);
|
|
49
|
+
return schema === undefined ? parse(text, type) : parse(text, type, schema);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Writes a field onto `Headers`, replacing any earlier value. An empty List or Dictionary
|
|
53
|
+
* deletes the field instead, since RFC 9651 treats an empty one as absent.
|
|
54
|
+
*
|
|
55
|
+
* @param headers - The headers to write
|
|
56
|
+
* @param name - The field name
|
|
57
|
+
* @param value - The model, or any member written as its bare value
|
|
58
|
+
* @param type - The top-level type the field's definition fixes
|
|
59
|
+
* @returns Success once written, or why the value has no representation, leaving
|
|
60
|
+
* `headers` untouched
|
|
61
|
+
*/
|
|
62
|
+
export function setField(headers, name, value, type) {
|
|
63
|
+
let text = stringifyField(value, type);
|
|
64
|
+
if (isFailure(text))
|
|
65
|
+
return text;
|
|
66
|
+
if (text.data === "")
|
|
67
|
+
headers.delete(name);
|
|
68
|
+
else
|
|
69
|
+
headers.set(name, text.data);
|
|
70
|
+
return success(undefined);
|
|
71
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base64 for Byte Sequences over the platform's `atob` and `btoa`, which every runtime the
|
|
3
|
+
* package targets provides. Decoding follows the forgiving base64 algorithm, so missing
|
|
4
|
+
* padding and nonzero pad bits are accepted, as RFC 9651 section 4.2.7 asks of parsers.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Encodes bytes as padded standard base64, the canonical form RFC 9651 serializes.
|
|
11
|
+
*
|
|
12
|
+
* @param bytes - The Byte Sequence
|
|
13
|
+
* @returns Its base64 text
|
|
14
|
+
*/
|
|
15
|
+
export declare function encodeBase64(bytes: Uint8Array): string;
|
|
16
|
+
/**
|
|
17
|
+
* Decodes standard base64 text whose characters the caller already restricted to the
|
|
18
|
+
* base64 alphabet and `=`.
|
|
19
|
+
*
|
|
20
|
+
* @param text - The text between a Byte Sequence's colons
|
|
21
|
+
* @returns The bytes, or `null` when padding sits anywhere but the end or the length
|
|
22
|
+
* leaves a lone trailing character
|
|
23
|
+
*/
|
|
24
|
+
export declare function decodeBase64(text: string): Uint8Array | null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base64 for Byte Sequences over the platform's `atob` and `btoa`, which every runtime the
|
|
3
|
+
* package targets provides. Decoding follows the forgiving base64 algorithm, so missing
|
|
4
|
+
* padding and nonzero pad bits are accepted, as RFC 9651 section 4.2.7 asks of parsers.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** Bytes converted per `String.fromCharCode` call, well under engines' argument limits. */
|
|
10
|
+
const CHUNK_SIZE = 0x8000;
|
|
11
|
+
/**
|
|
12
|
+
* Encodes bytes as padded standard base64, the canonical form RFC 9651 serializes.
|
|
13
|
+
*
|
|
14
|
+
* @param bytes - The Byte Sequence
|
|
15
|
+
* @returns Its base64 text
|
|
16
|
+
*/
|
|
17
|
+
export function encodeBase64(bytes) {
|
|
18
|
+
let binary = "";
|
|
19
|
+
for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) {
|
|
20
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + CHUNK_SIZE));
|
|
21
|
+
}
|
|
22
|
+
return btoa(binary);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Decodes standard base64 text whose characters the caller already restricted to the
|
|
26
|
+
* base64 alphabet and `=`.
|
|
27
|
+
*
|
|
28
|
+
* @param text - The text between a Byte Sequence's colons
|
|
29
|
+
* @returns The bytes, or `null` when padding sits anywhere but the end or the length
|
|
30
|
+
* leaves a lone trailing character
|
|
31
|
+
*/
|
|
32
|
+
export function decodeBase64(text) {
|
|
33
|
+
let binary;
|
|
34
|
+
try {
|
|
35
|
+
binary = atob(text);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
let bytes = new Uint8Array(binary.length);
|
|
41
|
+
for (let index = 0; index < binary.length; index++)
|
|
42
|
+
bytes[index] = binary.charCodeAt(index);
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two failures the package reports, kept apart from the entry point so the parser
|
|
3
|
+
* and the serializer construct them without importing each other.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* The text is not a valid Structured Field of the requested type. RFC 9651 has the
|
|
10
|
+
* recipient ignore such a field as a whole, so a failure never carries a partial value.
|
|
11
|
+
*/
|
|
12
|
+
export declare class StructuredFieldParseError extends Error {
|
|
13
|
+
name: "StructuredFieldParseError";
|
|
14
|
+
/** Offset into the field value where parsing stopped, counting from 0. */
|
|
15
|
+
readonly position: number;
|
|
16
|
+
/**
|
|
17
|
+
* @param message - Human readable description of the failure
|
|
18
|
+
* @param position - Offset into the field value where parsing stopped
|
|
19
|
+
*/
|
|
20
|
+
constructor(message: string, position: number);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A value has no RFC 9651 representation: a number out of range, a character outside a
|
|
24
|
+
* type's set, a key that breaks the key grammar, or a Date carrying milliseconds.
|
|
25
|
+
*/
|
|
26
|
+
export declare class StructuredFieldStringifyError extends Error {
|
|
27
|
+
name: "StructuredFieldStringifyError";
|
|
28
|
+
/** Where in the input the offending value sits, empty for the value itself. */
|
|
29
|
+
readonly path: Array<string | number>;
|
|
30
|
+
/**
|
|
31
|
+
* @param message - Human readable description of the failure
|
|
32
|
+
* @param path - Keys and indices leading to the offending value
|
|
33
|
+
*/
|
|
34
|
+
constructor(message: string, path: Array<string | number>);
|
|
35
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two failures the package reports, kept apart from the entry point so the parser
|
|
3
|
+
* and the serializer construct them without importing each other.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* The text is not a valid Structured Field of the requested type. RFC 9651 has the
|
|
10
|
+
* recipient ignore such a field as a whole, so a failure never carries a partial value.
|
|
11
|
+
*/
|
|
12
|
+
export class StructuredFieldParseError extends Error {
|
|
13
|
+
name = "StructuredFieldParseError";
|
|
14
|
+
/** Offset into the field value where parsing stopped, counting from 0. */
|
|
15
|
+
position;
|
|
16
|
+
/**
|
|
17
|
+
* @param message - Human readable description of the failure
|
|
18
|
+
* @param position - Offset into the field value where parsing stopped
|
|
19
|
+
*/
|
|
20
|
+
constructor(message, position) {
|
|
21
|
+
super(`${message} at position ${position}`);
|
|
22
|
+
this.position = position;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A value has no RFC 9651 representation: a number out of range, a character outside a
|
|
27
|
+
* type's set, a key that breaks the key grammar, or a Date carrying milliseconds.
|
|
28
|
+
*/
|
|
29
|
+
export class StructuredFieldStringifyError extends Error {
|
|
30
|
+
name = "StructuredFieldStringifyError";
|
|
31
|
+
/** Where in the input the offending value sits, empty for the value itself. */
|
|
32
|
+
path;
|
|
33
|
+
/**
|
|
34
|
+
* @param message - Human readable description of the failure
|
|
35
|
+
* @param path - Keys and indices leading to the offending value
|
|
36
|
+
*/
|
|
37
|
+
constructor(message, path) {
|
|
38
|
+
super(path.length === 0 ? message : `${message} at ${path.join(".")}`);
|
|
39
|
+
this.path = path;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RFC 9651 section 4.2 parsing algorithms, step by step: the three top-level types,
|
|
3
|
+
* inner lists, parameters, keys and the eight bare item types. Any failure fails the whole
|
|
4
|
+
* field, so each step either returns its value or the `FAILED` marker with the reason set.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { SF } from "./types.js";
|
|
11
|
+
import { StructuredFieldParseError } from "./errors.js";
|
|
12
|
+
/**
|
|
13
|
+
* Parses a field value as its definition's top-level type (RFC 9651 section 4.2).
|
|
14
|
+
*
|
|
15
|
+
* @param text - The field value, several field lines joined with `,`
|
|
16
|
+
* @param type - The top-level type the field's definition fixes
|
|
17
|
+
* @returns The model, or where the text stops being valid
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseField<Type extends SF.FieldType>(text: string, type: Type): Result<SF.ValueOf[Type], StructuredFieldParseError>;
|