From a9a0fd8dc6b6d107b392b2edde9de4ba7193542a Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Mon, 19 Jan 2026 21:36:28 +0100 Subject: [PATCH 01/11] feat(http/unstable): Structured Field Values --- http/deno.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/http/deno.json b/http/deno.json index 23655e278d84..77930809ed44 100644 --- a/http/deno.json +++ b/http/deno.json @@ -13,7 +13,8 @@ "./server-sent-event-stream": "./server_sent_event_stream.ts", "./status": "./status.ts", "./unstable-signed-cookie": "./unstable_signed_cookie.ts", + "./unstable-structured-fields": "./unstable_structured_fields.ts", "./user-agent": "./user_agent.ts", "./unstable-route": "./unstable_route.ts" } -} +} \ No newline at end of file From f9779a97fc86e86d9a45b793384f9a4b95443a6b Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Mon, 19 Jan 2026 21:36:34 +0100 Subject: [PATCH 02/11] feat(http/unstable): Structured Field Values --- http/testdata/structured_fields/binary.json | 100 ++ http/testdata/structured_fields/boolean.json | 69 + http/testdata/structured_fields/date.json | 50 + .../structured_fields/dictionary.json | 163 ++ .../structured_fields/display-string.json | 123 ++ http/testdata/structured_fields/examples.json | 208 +++ http/testdata/structured_fields/item.json | 34 + http/testdata/structured_fields/list.json | 74 + http/testdata/structured_fields/number.json | 249 +++ .../structured_fields/param-dict.json | 113 ++ .../structured_fields/param-list.json | 108 ++ .../structured_fields/param-listlist.json | 36 + http/testdata/structured_fields/string.json | 87 + http/testdata/structured_fields/token.json | 38 + http/unstable_structured_fields.ts | 1404 +++++++++++++++++ http/unstable_structured_fields_test.ts | 1154 ++++++++++++++ 16 files changed, 4010 insertions(+) create mode 100644 http/testdata/structured_fields/binary.json create mode 100644 http/testdata/structured_fields/boolean.json create mode 100644 http/testdata/structured_fields/date.json create mode 100644 http/testdata/structured_fields/dictionary.json create mode 100644 http/testdata/structured_fields/display-string.json create mode 100644 http/testdata/structured_fields/examples.json create mode 100644 http/testdata/structured_fields/item.json create mode 100644 http/testdata/structured_fields/list.json create mode 100644 http/testdata/structured_fields/number.json create mode 100644 http/testdata/structured_fields/param-dict.json create mode 100644 http/testdata/structured_fields/param-list.json create mode 100644 http/testdata/structured_fields/param-listlist.json create mode 100644 http/testdata/structured_fields/string.json create mode 100644 http/testdata/structured_fields/token.json create mode 100644 http/unstable_structured_fields.ts create mode 100644 http/unstable_structured_fields_test.ts diff --git a/http/testdata/structured_fields/binary.json b/http/testdata/structured_fields/binary.json new file mode 100644 index 000000000000..e1d412fc375f --- /dev/null +++ b/http/testdata/structured_fields/binary.json @@ -0,0 +1,100 @@ +[ + { + "name": "basic binary", + "raw": [":aGVsbG8=:"], + "header_type": "item", + "expected": [ + {"__type": "binary", "value": "NBSWY3DP"}, + []] + }, + { + "name": "empty binary", + "raw": ["::"], + "header_type": "item", + "expected": [ + {"__type": "binary", "value": ""}, + []] + }, + { + "name": "padding at beginning", + "raw": [":=aGVsbG8=:"], + "header_type": "item", + "must_fail": true + }, + { + "name": "padding in middle", + "raw": [":a=GVsbG8=:"], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad padding", + "raw": [":aGVsbG8:"], + "header_type": "item", + "expected": [ + {"__type": "binary", "value": "NBSWY3DP"}, + []], + "can_fail": true, + "canonical": [":aGVsbG8=:"] + }, + { + "name": "bad padding dot", + "raw": [":aGVsbG8.:"], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad end delimiter", + "raw": [":aGVsbG8="], + "header_type": "item", + "must_fail": true + }, + { + "name": "extra whitespace", + "raw": [":aGVsb G8=:"], + "header_type": "item", + "must_fail": true + }, + { + "name": "all whitespace", + "raw": [": :"], + "header_type": "item", + "must_fail": true + }, + { + "name": "extra chars", + "raw": [":aGVsbG!8=:"], + "header_type": "item", + "must_fail": true + }, + { + "name": "suffix chars", + "raw": [":aGVsbG8=!:"], + "header_type": "item", + "must_fail": true + }, + { + "name": "non-zero pad bits", + "raw": [":iZ==:"], + "header_type": "item", + "expected": [ + {"__type": "binary", "value": "RE======"}, + []], + "can_fail": true, + "canonical": [":iQ==:"] + }, + { + "name": "non-ASCII binary", + "raw": [":/+Ah:"], + "header_type": "item", + "expected": [ + {"__type": "binary", "value": "77QCC==="}, + []] + }, + { + "name": "base64url binary", + "raw": [":_-Ah:"], + "header_type": "item", + "must_fail": true + } +] diff --git a/http/testdata/structured_fields/boolean.json b/http/testdata/structured_fields/boolean.json new file mode 100644 index 000000000000..2ec379f19e08 --- /dev/null +++ b/http/testdata/structured_fields/boolean.json @@ -0,0 +1,69 @@ +[ + { + "name": "basic true boolean", + "raw": ["?1"], + "header_type": "item", + "expected": [true, []] + }, + { + "name": "basic false boolean", + "raw": ["?0"], + "header_type": "item", + "expected": [false, []] + }, + { + "name": "unknown boolean", + "raw": ["?Q"], + "header_type": "item", + "must_fail": true + }, + { + "name": "whitespace boolean", + "raw": ["? 1"], + "header_type": "item", + "must_fail": true + }, + { + "name": "negative zero boolean", + "raw": ["?-0"], + "header_type": "item", + "must_fail": true + }, + { + "name": "T boolean", + "raw": ["?T"], + "header_type": "item", + "must_fail": true + }, + { + "name": "F boolean", + "raw": ["?F"], + "header_type": "item", + "must_fail": true + }, + { + "name": "t boolean", + "raw": ["?t"], + "header_type": "item", + "must_fail": true + }, + { + "name": "f boolean", + "raw": ["?f"], + "header_type": "item", + "must_fail": true + }, + { + "name": "spelled-out True boolean", + "raw": ["?True"], + "header_type": "item", + "must_fail": true + }, + { + "name": "spelled-out False boolean", + "raw": ["?False"], + "header_type": "item", + "must_fail": true + } +] + diff --git a/http/testdata/structured_fields/date.json b/http/testdata/structured_fields/date.json new file mode 100644 index 000000000000..15da76a605e5 --- /dev/null +++ b/http/testdata/structured_fields/date.json @@ -0,0 +1,50 @@ +[ + { + "name": "date - 1970-01-01 00:00:00", + "raw": ["@0"], + "header_type": "item", + "expected": [{"__type": "date", "value": 0}, []] + }, + { + "name": "date - 2022-08-04 01:57:13", + "raw": ["@1659578233"], + "header_type": "item", + "expected": [{"__type": "date", "value": 1659578233}, []] + }, + { + "name": "date - 1917-05-30 22:02:47", + "raw": ["@-1659578233"], + "header_type": "item", + "expected": [{"__type": "date", "value": -1659578233}, []] + }, + { + "name": "date - 2^31", + "raw": ["@2147483648"], + "header_type": "item", + "expected": [{"__type": "date", "value": 2147483648}, []] + }, + { + "name": "date - 2^32", + "raw": ["@4294967296"], + "header_type": "item", + "expected": [{"__type": "date", "value": 4294967296}, []] + }, + { + "name": "large date - 9999-12-31 00:00:00", + "raw": ["@253402214400"], + "header_type": "item", + "expected": [{"__type": "date", "value": 253402214400}, []] + }, + { + "name": "small date - 0001-01-01 00:00:00", + "raw": ["@-62135596800"], + "header_type": "item", + "expected": [{"__type": "date", "value": -62135596800}, []] + }, + { + "name": "date - decimal", + "raw": ["@1659578233.12"], + "header_type": "item", + "must_fail": true + } +] diff --git a/http/testdata/structured_fields/dictionary.json b/http/testdata/structured_fields/dictionary.json new file mode 100644 index 000000000000..92d37c03577d --- /dev/null +++ b/http/testdata/structured_fields/dictionary.json @@ -0,0 +1,163 @@ +[ + { + "name": "basic dictionary", + "raw": ["en=\"Applepie\", da=:w4ZibGV0w6ZydGUK:"], + "header_type": "dictionary", + "expected": [["en", ["Applepie", []]], ["da", [ + {"__type": "binary", "value": "YODGE3DFOTB2M4TUMUFA===="}, + []] + ]] + }, + { + "name": "empty dictionary", + "raw": [""], + "header_type": "dictionary", + "expected": [], + "canonical": [] + }, + { + "name": "single item dictionary", + "raw": ["a=1"], + "header_type": "dictionary", + "expected": [["a", [1, []]]] + }, + { + "name": "list item dictionary", + "raw": ["a=(1 2)"], + "header_type": "dictionary", + "expected": [["a", [[[1, []], [2, []]], []]]] + }, + { + "name": "single list item dictionary", + "raw": ["a=(1)"], + "header_type": "dictionary", + "expected": [["a", [[[1, []]], []]]] + }, + { + "name": "empty list item dictionary", + "raw": ["a=()"], + "header_type": "dictionary", + "expected": [["a", [[], []]]] + }, + { + "name": "no whitespace dictionary", + "raw": ["a=1,b=2"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [2, []]]], + "canonical": ["a=1, b=2"] + }, + { + "name": "extra whitespace dictionary", + "raw": ["a=1 , b=2"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [2, []]]], + "canonical": ["a=1, b=2"] + }, + { + "name": "tab separated dictionary", + "raw": ["a=1\t,\tb=2"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [2, []]]], + "canonical": ["a=1, b=2"] + }, + { + "name": "leading whitespace dictionary", + "raw": [" a=1 , b=2"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [2, []]]], + "canonical": ["a=1, b=2"] + }, + { + "name": "whitespace before = dictionary", + "raw": ["a =1, b=2"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "whitespace after = dictionary", + "raw": ["a=1, b= 2"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "two lines dictionary", + "raw": ["a=1", "b=2"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [2, []]]], + "canonical": ["a=1, b=2"] + }, + { + "name": "missing value dictionary", + "raw": ["a=1, b, c=3"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [true, []]], ["c", [3, []]]] + }, + { + "name": "all missing value dictionary", + "raw": ["a, b, c"], + "header_type": "dictionary", + "expected": [["a", [true, []]], ["b", [true, []]], ["c", [true, []]]] + }, + { + "name": "start missing value dictionary", + "raw": ["a, b=2"], + "header_type": "dictionary", + "expected": [["a", [true, []]], ["b", [2, []]]] + }, + { + "name": "end missing value dictionary", + "raw": ["a=1, b"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [true, []]]] + }, + { + "name": "missing value with params dictionary", + "raw": ["a=1, b;foo=9, c=3"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [true, [["foo", 9]]]], ["c", [3, []]]] + }, + { + "name": "explicit true value with params dictionary", + "raw": ["a=1, b=?1;foo=9, c=3"], + "header_type": "dictionary", + "expected": [["a", [1, []]], ["b", [true, [["foo", 9]]]], ["c", [3, []]]], + "canonical": ["a=1, b;foo=9, c=3"] + }, + { + "name": "trailing comma dictionary", + "raw": ["a=1, b=2,"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "empty item dictionary", + "raw": ["a=1,,b=2,"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "duplicate key dictionary", + "raw": ["a=1,b=2,a=3"], + "header_type": "dictionary", + "expected": [["a", [3, []]], ["b", [2, []]]], + "canonical": ["a=3, b=2"] + }, + { + "name": "numeric key dictionary", + "raw": ["a=1,1b=2,a=1"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "uppercase key dictionary", + "raw": ["a=1,B=2,a=1"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "bad key dictionary", + "raw": ["a=1,b!=2,a=1"], + "header_type": "dictionary", + "must_fail": true + } +] diff --git a/http/testdata/structured_fields/display-string.json b/http/testdata/structured_fields/display-string.json new file mode 100644 index 000000000000..29f674e029b2 --- /dev/null +++ b/http/testdata/structured_fields/display-string.json @@ -0,0 +1,123 @@ +[ + { + "name": "basic display string (ascii content)", + "raw": ["%\"foo bar\""], + "header_type": "item", + "expected": [{"__type": "displaystring", "value": "foo bar"}, []] + }, + { + "name": "all printable ascii", + "raw": ["%\" !%22#$%25&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\""], + "header_type": "item", + "expected": [{"__type": "displaystring", "value": " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"}, []] + }, + { + "name": "non-ascii display string (uppercase escaping)", + "raw": ["%\"f%C3%BC%C3%BC\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "non-ascii display string (lowercase escaping)", + "raw": ["%\"f%c3%bc%c3%bc\""], + "header_type": "item", + "expected": [{"__type": "displaystring", "value": "füü"}, []] + }, + { + "name": "non-ascii display string (unescaped)", + "raw": ["%\"füü\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "tab in display string", + "raw": ["%\"\t\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "newline in display string", + "raw": ["%\"\n\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "single quoted display string", + "raw": ["%'foo'"], + "header_type": "item", + "must_fail": true + }, + { + "name": "unquoted display string", + "raw": ["%foo"], + "header_type": "item", + "must_fail": true + }, + { + "name": "display string missing initial quote", + "raw": ["%foo\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "unbalanced display string", + "raw": ["%\"foo"], + "header_type": "item", + "must_fail": true + }, + { + "name": "display string quoting", + "raw": ["%\"foo %22bar%22 \\ baz\""], + "header_type": "item", + "expected": [{"__type": "displaystring", "value": "foo \"bar\" \\ baz"}, []] + }, + { + "name": "bad display string escaping", + "raw": ["%\"foo %a"], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad display string utf-8 (invalid 2-byte seq)", + "raw": ["%\"%c3%28\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad display string utf-8 (invalid sequence id)", + "raw": ["%\"%a0%a1\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad display string utf-8 (invalid hex)", + "raw": ["%\"%g0%1w\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad display string utf-8 (invalid 3-byte seq)", + "raw": ["%\"%e2%28%a1\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "bad display string utf-8 (invalid 4-byte seq)", + "raw": ["%\"%f0%28%8c%28\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "BOM in display string", + "raw": ["%\"BOM: %ef%bb%bf\""], + "header_type": "item", + "expected": [{"__type": "displaystring", "value": "BOM: \uFEFF"}, []] + }, + { + "name": "two lines display string", + "raw": ["%\"foo", "bar\""], + "header_type": "item", + "can_fail": true, + "expected": [{"__type": "displaystring", "value": "foo, bar"}, []] + } +] diff --git a/http/testdata/structured_fields/examples.json b/http/testdata/structured_fields/examples.json new file mode 100644 index 000000000000..eb7dc349fddd --- /dev/null +++ b/http/testdata/structured_fields/examples.json @@ -0,0 +1,208 @@ +[ + { + "name": "Foo-Example", + "raw": ["2; foourl=\"https://foo.example.com/\""], + "header_type": "item", + "expected": [2, [["foourl", "https://foo.example.com/"]]], + "canonical": ["2;foourl=\"https://foo.example.com/\""] + }, + { + "name": "Example-StrListHeader", + "raw": ["\"foo\", \"bar\", \"It was the best of times.\""], + "header_type": "list", + "expected": [ + ["foo", []], + ["bar", []], + ["It was the best of times.", []] + ] + }, + { + "name": "Example-Hdr (list on one line)", + "raw": ["foo, bar"], + "header_type": "list", + "expected": [ + [{"__type":"token", "value":"foo"}, []], + [{"__type":"token", "value":"bar"}, []] + ] + }, + { + "name": "Example-Hdr (list on two lines)", + "raw": ["foo", "bar"], + "header_type": "list", + "expected": [ + [{"__type":"token", "value":"foo"}, []], + [{"__type":"token", "value":"bar"}, []] + ], + "canonical": ["foo, bar"] + }, + { + "name": "Example-StrListListHeader", + "raw": ["(\"foo\" \"bar\"), (\"baz\"), (\"bat\" \"one\"), ()"], + "header_type": "list", + "expected": [ + [[ + ["foo", []], + ["bar", []] + ], []], + [[ + ["baz", []] + ], []], + [[ + ["bat", []], + ["one", []] + ], []], + [[], []] + ] + }, + { + "name": "Example-ListListParam", + "raw": ["(\"foo\"; a=1;b=2);lvl=5, (\"bar\" \"baz\");lvl=1"], + "header_type": "list", + "expected": [ + [[ + ["foo", [["a", 1], ["b", 2]]] + ], [["lvl", 5]]], + [[ + ["bar", []], ["baz", []] + ], [["lvl", 1]]] + ], + "canonical": ["(\"foo\";a=1;b=2);lvl=5, (\"bar\" \"baz\");lvl=1"] + }, + + { + "name": "Example-ParamListHeader", + "raw": ["abc;a=1;b=2; cde_456, (ghi;jk=4 l);q=\"9\";r=w"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "abc"}, [["a", 1], ["b", 2], ["cde_456", true]]], + [ + [ + [{"__type": "token", "value": "ghi"}, [["jk", 4]]], + [{"__type": "token", "value": "l"}, []] + ], + [["q", "9"], ["r", {"__type": "token", "value": "w"}]] + ] + ], + "canonical": ["abc;a=1;b=2;cde_456, (ghi;jk=4 l);q=\"9\";r=w"] + }, + { + "name": "Example-IntHeader", + "raw": ["1; a; b=?0"], + "header_type": "item", + "expected": [1, [["a", true], ["b", false]]], + "canonical": ["1;a;b=?0"] + }, + { + "name": "Example-DictHeader", + "raw": ["en=\"Applepie\", da=:w4ZibGV0w6ZydGU=:"], + "header_type": "dictionary", + "expected": [ + ["en", ["Applepie", []]], + ["da", [{"__type": "binary", "value": "YODGE3DFOTB2M4TUMU======"}, []]] + ] + }, + { + "name": "Example-DictHeader (boolean values)", + "raw": ["a=?0, b, c; foo=bar"], + "header_type": "dictionary", + "expected": [ + ["a", [false, []]], + ["b", [true, []]], + ["c", [true, [["foo", {"__type": "token", "value": "bar"}]]]] + ], + "canonical": ["a=?0, b, c;foo=bar"] + }, + { + "name": "Example-DictListHeader", + "raw": ["rating=1.5, feelings=(joy sadness)"], + "header_type": "dictionary", + "expected": [ + ["rating", [1.5, []]], + ["feelings", [[ + [{"__type": "token", "value": "joy"}, []], + [{"__type": "token", "value": "sadness"}, []] + ], []]] + ] + }, + { + "name": "Example-MixDict", + "raw": ["a=(1 2), b=3, c=4;aa=bb, d=(5 6);valid"], + "header_type": "dictionary", + "expected": [ + ["a", [[ + [1, []], + [2, []] + ], []]], + ["b", [3, []]], + ["c", [4, [["aa", {"__type": "token", "value": "bb"}]]]], + ["d", [[ + [5, []], + [6, []] + ], [["valid", true]]]] + ], + "canonical": ["a=(1 2), b=3, c=4;aa=bb, d=(5 6);valid"] + }, + { + "name": "Example-Hdr (dictionary on one line)", + "raw": ["foo=1, bar=2"], + "header_type": "dictionary", + "expected": [ + ["foo", [1, []]], + ["bar", [2, []]] + ] + }, + { + "name": "Example-Hdr (dictionary on two lines)", + "raw": ["foo=1", "bar=2"], + "header_type": "dictionary", + "expected": [ + ["foo", [1, []]], + ["bar", [2, []]] + ], + "canonical": ["foo=1, bar=2"] + }, + + { + "name": "Example-IntItemHeader", + "raw": ["5"], + "header_type": "item", + "expected": [5, []] + }, + { + "name": "Example-IntItemHeader (params)", + "raw": ["5; foo=bar"], + "header_type": "item", + "expected": [5, [["foo", {"__type": "token", "value": "bar"}]]], + "canonical": ["5;foo=bar"] + }, + { + "name": "Example-IntegerHeader", + "raw": ["42"], + "header_type": "item", + "expected": [42, []] + }, + { + "name": "Example-FloatHeader", + "raw": ["4.5"], + "header_type": "item", + "expected": [4.5, []] + }, + { + "name": "Example-StringHeader", + "raw": ["\"hello world\""], + "header_type": "item", + "expected": ["hello world", []] + }, + { + "name": "Example-BinaryHdr", + "raw": [":cHJldGVuZCB0aGlzIGlzIGJpbmFyeSBjb250ZW50Lg==:"], + "header_type": "item", + "expected": [{"__type": "binary", "value": "OBZGK5DFNZSCA5DINFZSA2LTEBRGS3TBOJ4SAY3PNZ2GK3TUFY======"}, []] + }, + { + "name": "Example-BoolHdr", + "raw": ["?1"], + "header_type": "item", + "expected": [true, []] + } +] diff --git a/http/testdata/structured_fields/item.json b/http/testdata/structured_fields/item.json new file mode 100644 index 000000000000..e86cea0e9a6b --- /dev/null +++ b/http/testdata/structured_fields/item.json @@ -0,0 +1,34 @@ +[ + { + "name": "empty item", + "raw": [""], + "header_type": "item", + "must_fail": true + }, + { + "name": "leading space", + "raw": [" \t 1"], + "header_type": "item", + "must_fail": true + }, + { + "name": "trailing space", + "raw": ["1 \t "], + "header_type": "item", + "must_fail": true + }, + { + "name": "leading and trailing space", + "raw": [" 1 "], + "header_type": "item", + "expected": [1, []], + "canonical": ["1"] + }, + { + "name": "leading and trailing whitespace", + "raw": [" 1 "], + "header_type": "item", + "expected": [1, []], + "canonical": ["1"] + } +] diff --git a/http/testdata/structured_fields/list.json b/http/testdata/structured_fields/list.json new file mode 100644 index 000000000000..c1d047d0fc8e --- /dev/null +++ b/http/testdata/structured_fields/list.json @@ -0,0 +1,74 @@ +[ + { + "name": "basic list", + "raw": ["1, 42"], + "header_type": "list", + "expected": [[1, []], [42, []]] + }, + { + "name": "empty list", + "raw": [""], + "header_type": "list", + "expected": [], + "canonical": [] + }, + { + "name": "leading SP list", + "raw": [" 42, 43"], + "canonical": ["42, 43"], + "header_type": "list", + "expected": [[42, []], [43, []]] + }, + { + "name": "single item list", + "raw": ["42"], + "header_type": "list", + "expected": [[42, []]] + }, + { + "name": "no whitespace list", + "raw": ["1,42"], + "header_type": "list", + "expected": [[1, []], [42, []]], + "canonical": ["1, 42"] + }, + { + "name": "extra whitespace list", + "raw": ["1 , 42"], + "header_type": "list", + "expected": [[1, []], [42, []]], + "canonical": ["1, 42"] + }, + { + "name": "tab separated list", + "raw": ["1\t,\t42"], + "header_type": "list", + "expected": [[1, []], [42, []]], + "canonical": ["1, 42"] + }, + { + "name": "two line list", + "raw": ["1", "42"], + "header_type": "list", + "expected": [[1, []], [42, []]], + "canonical": ["1, 42"] + }, + { + "name": "trailing comma list", + "raw": ["1, 42,"], + "header_type": "list", + "must_fail": true + }, + { + "name": "empty item list", + "raw": ["1,,42"], + "header_type": "list", + "must_fail": true + }, + { + "name": "empty item list (multiple field lines)", + "raw": ["1","","42"], + "header_type": "list", + "must_fail": true + } +] diff --git a/http/testdata/structured_fields/number.json b/http/testdata/structured_fields/number.json new file mode 100644 index 000000000000..43ae9601b983 --- /dev/null +++ b/http/testdata/structured_fields/number.json @@ -0,0 +1,249 @@ +[ + { + "name": "basic integer", + "raw": ["42"], + "header_type": "item", + "expected": [42, []] + }, + { + "name": "zero integer", + "raw": ["0"], + "header_type": "item", + "expected": [0, []] + }, + { + "name": "negative zero", + "raw": ["-0"], + "header_type": "item", + "expected": [0, []], + "canonical": ["0"] + }, + { + "name": "double negative zero", + "raw": ["--0"], + "header_type": "item", + "must_fail": true + }, + { + "name": "negative integer", + "raw": ["-42"], + "header_type": "item", + "expected": [-42, []] + }, + { + "name": "leading 0 integer", + "raw": ["042"], + "header_type": "item", + "expected": [42, []], + "canonical": ["42"] + }, + { + "name": "leading 0 negative integer", + "raw": ["-042"], + "header_type": "item", + "expected": [-42, []], + "canonical": ["-42"] + }, + { + "name": "leading 0 zero", + "raw": ["00"], + "header_type": "item", + "expected": [0, []], + "canonical": ["0"] + }, + { + "name": "comma", + "raw": ["2,3"], + "header_type": "item", + "must_fail": true + }, + { + "name": "negative non-DIGIT first character", + "raw": ["-a23"], + "header_type": "item", + "must_fail": true + }, + { + "name": "sign out of place", + "raw": ["4-2"], + "header_type": "item", + "must_fail": true + }, + { + "name": "whitespace after sign", + "raw": ["- 42"], + "header_type": "item", + "must_fail": true + }, + { + "name": "long integer", + "raw": ["123456789012345"], + "header_type": "item", + "expected": [123456789012345, []] + }, + { + "name": "long integer followed by comma", + "raw": [ + "123456789012345, 1" + ], + "header_type": "list", + "expected": [ + [ + 123456789012345, + [] + ], + [ + 1, + [] + ] + ] + }, + { + "name": "long negative integer", + "raw": ["-123456789012345"], + "header_type": "item", + "expected": [-123456789012345, []] + }, + { + "name": "too long integer", + "raw": ["1234567890123456"], + "header_type": "item", + "must_fail": true + }, + { + "name": "too long integer followed by comma", + "raw": [ + "1234567890123456, 1" + ], + "header_type": "list", + "must_fail": true + }, + { + "name": "negative too long integer", + "raw": ["-1234567890123456"], + "header_type": "item", + "must_fail": true + }, + { + "name": "simple decimal", + "raw": ["1.23"], + "header_type": "item", + "expected": [1.23, []] + }, + { + "name": "negative decimal", + "raw": ["-1.23"], + "header_type": "item", + "expected": [-1.23, []] + }, + { + "name": "decimal, whitespace after decimal", + "raw": ["1. 23"], + "header_type": "item", + "must_fail": true + }, + { + "name": "decimal, whitespace before decimal", + "raw": ["1 .23"], + "header_type": "item", + "must_fail": true + }, + { + "name": "negative decimal, whitespace after sign", + "raw": ["- 1.23"], + "header_type": "item", + "must_fail": true + }, + { + "name": "decimal, followed by comma", + "raw": [ + "123456789012.123, 1.1" + ], + "header_type": "list", + "expected": [ + [ + 123456789012.123, + [] + ], + [ + 1.1, + [] + ] + ] + }, + { + "name": "tricky precision decimal", + "raw": ["123456789012.1"], + "header_type": "item", + "expected": [123456789012.1, []] + }, + { + "name": "double decimal decimal", + "raw": ["1.5.4"], + "header_type": "item", + "must_fail": true + }, + { + "name": "adjacent double decimal decimal", + "raw": ["1..4"], + "header_type": "item", + "must_fail": true + }, + { + "name": "decimal with three fractional digits", + "raw": ["1.123"], + "header_type": "item", + "expected": [1.123, []] + }, + { + "name": "negative decimal with three fractional digits", + "raw": ["-1.123"], + "header_type": "item", + "expected": [-1.123, []] + }, + { + "name": "decimal with four fractional digits", + "raw": ["1.1234"], + "header_type": "item", + "must_fail": true + }, + { + "name": "negative decimal with four fractional digits", + "raw": ["-1.1234"], + "header_type": "item", + "must_fail": true + }, + { + "name": "decimal with thirteen integer digits", + "raw": ["1234567890123.0"], + "header_type": "item", + "must_fail": true + }, + { + "name": "negative decimal with thirteen integer digits", + "raw": ["-1234567890123.0"], + "header_type": "item", + "must_fail": true + }, + { + "name": "decimal with 1 significant digit and 1 insignificant digit", + "raw": ["1.20"], + "header_type": "item", + "expected": [1.2, []], + "canonical": ["1.2"] + }, + { + "name": "decimal with 1 significant digit and 2 insignificant digits", + "raw": ["1.200"], + "header_type": "item", + "expected": [1.2, []], + "canonical": ["1.2"] + }, + { + "name": "decimal with 2 significant digits and 1 insignificant digit", + "raw": ["1.230"], + "header_type": "item", + "expected": [1.23, []], + "canonical": ["1.23"] + } +] diff --git a/http/testdata/structured_fields/param-dict.json b/http/testdata/structured_fields/param-dict.json new file mode 100644 index 000000000000..5d03c8dcaa3f --- /dev/null +++ b/http/testdata/structured_fields/param-dict.json @@ -0,0 +1,113 @@ +[ + { + "name": "basic parameterised dict", + "raw": ["abc=123;a=1;b=2, def=456, ghi=789;q=9;r=\"+w\""], + "header_type": "dictionary", + "expected": [ + ["abc", [123, [["a", 1], ["b", 2]]]], + ["def", [456, []]], + ["ghi", [789, [["q", 9], ["r", "+w"]]]] + ] + }, + { + "name": "single item parameterised dict", + "raw": ["a=b; q=1.0"], + "header_type": "dictionary", + "expected": [ + ["a", [{"__type": "token", "value": "b"}, [["q", 1.0]]]] + ], + "canonical": ["a=b;q=1.0"] + }, + { + "name": "list item parameterised dictionary", + "raw": ["a=(1 2); q=1.0"], + "header_type": "dictionary", + "expected": [["a", [[[1, []], [2, []]], [["q", 1.0]]]]], + "canonical": ["a=(1 2);q=1.0"] + }, + { + "name": "missing parameter value parameterised dict", + "raw": ["a=3;c;d=5"], + "header_type": "dictionary", + "expected": [ + ["a", [3, [["c", true], ["d", 5]]]] + ] + }, + { + "name": "terminal missing parameter value parameterised dict", + "raw": ["a=3;c=5;d"], + "header_type": "dictionary", + "expected": [ + ["a", [3, [["c", 5], ["d", true]]]] + ] + }, + { + "name": "no whitespace parameterised dict", + "raw": ["a=b;c=1,d=e;f=2"], + "header_type": "dictionary", + "expected": [ + ["a", [{"__type": "token", "value": "b"}, [["c", 1]]]], + ["d", [{"__type": "token", "value": "e"}, [["f", 2]]]] + ], + "canonical": ["a=b;c=1, d=e;f=2"] + }, + { + "name": "whitespace before = parameterised dict", + "raw": ["a=b;q =0.5"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "whitespace after = parameterised dict", + "raw": ["a=b;q= 0.5"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "whitespace before ; parameterised dict", + "raw": ["a=b ;q=0.5"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "whitespace after ; parameterised dict", + "raw": ["a=b; q=0.5"], + "header_type": "dictionary", + "expected": [ + ["a", [{"__type": "token", "value": "b"}, [["q", 0.5]]]] + ], + "canonical": ["a=b;q=0.5"] + }, + { + "name": "extra whitespace parameterised dict", + "raw": ["a=b; c=1 , d=e; f=2; g=3"], + "header_type": "dictionary", + "expected": [ + ["a", [{"__type": "token", "value": "b"}, [["c", 1]]]], + ["d", [{"__type": "token", "value": "e"}, [["f", 2], ["g", 3]]]] + ], + "canonical": ["a=b;c=1, d=e;f=2;g=3"] + }, + { + "name": "two lines parameterised list", + "raw": ["a=b;c=1", "d=e;f=2"], + "header_type": "dictionary", + "expected": [ + ["a", [{"__type": "token", "value": "b"}, [["c", 1]]]], + ["d", [{"__type": "token", "value": "e"}, [["f", 2]]]] + ], + "canonical": ["a=b;c=1, d=e;f=2"] + }, + { + "name": "trailing comma parameterised list", + "raw": ["a=b; q=1.0,"], + "header_type": "dictionary", + "must_fail": true + }, + { + "name": "empty item parameterised list", + "raw": ["a=b; q=1.0,,c=d"], + "header_type": "dictionary", + "must_fail": true + } +] diff --git a/http/testdata/structured_fields/param-list.json b/http/testdata/structured_fields/param-list.json new file mode 100644 index 000000000000..5c00be6a265e --- /dev/null +++ b/http/testdata/structured_fields/param-list.json @@ -0,0 +1,108 @@ +[ + { + "name": "basic parameterised list", + "raw": ["abc_123;a=1;b=2; cdef_456, ghi;q=9;r=\"+w\""], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "abc_123"}, [["a", 1], ["b", 2], ["cdef_456", true]]], + [{"__type": "token", "value": "ghi"}, [["q", 9], ["r", "+w"]]] + ], + "canonical": ["abc_123;a=1;b=2;cdef_456, ghi;q=9;r=\"+w\""] + }, + { + "name": "single item parameterised list", + "raw": ["text/html;q=1.0"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, [["q", 1.0]]] + ] + }, + { + "name": "missing parameter value parameterised list", + "raw": ["text/html;a;q=1.0"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, [["a", true], ["q", 1.0]]] + ] + }, + { + "name": "missing terminal parameter value parameterised list", + "raw": ["text/html;q=1.0;a"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, [["q", 1.0], ["a", true]]] + ] + }, + { + "name": "no whitespace parameterised list", + "raw": ["text/html,text/plain;q=0.5"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, []], + [{"__type": "token", "value": "text/plain"}, [["q", 0.5]]] + ], + "canonical": ["text/html, text/plain;q=0.5"] + }, + { + "name": "whitespace before = parameterised list", + "raw": ["text/html, text/plain;q =0.5"], + "header_type": "list", + "must_fail": true + }, + { + "name": "whitespace after = parameterised list", + "raw": ["text/html, text/plain;q= 0.5"], + "header_type": "list", + "must_fail": true + }, + { + "name": "whitespace before ; parameterised list", + "raw": ["text/html, text/plain ;q=0.5"], + "header_type": "list", + "must_fail": true + }, + { + "name": "whitespace after ; parameterised list", + "raw": ["text/html, text/plain; q=0.5"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, []], + [{"__type": "token", "value": "text/plain"}, [["q", 0.5]]] + ], + "canonical": ["text/html, text/plain;q=0.5"] + }, + { + "name": "extra whitespace parameterised list", + "raw": ["text/html , text/plain; q=0.5; charset=utf-8"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, []], + [{"__type": "token", "value": "text/plain"}, + [["q", 0.5], ["charset", {"__type": "token", "value": "utf-8"} + ]]] + ], + "canonical": ["text/html, text/plain;q=0.5;charset=utf-8"] + }, + { + "name": "two lines parameterised list", + "raw": ["text/html", "text/plain;q=0.5"], + "header_type": "list", + "expected": [ + [{"__type": "token", "value": "text/html"}, []], + [{"__type": "token", "value": "text/plain"}, [["q", 0.5]]] + ], + "canonical": ["text/html, text/plain;q=0.5"] + }, + { + "name": "trailing comma parameterised list", + "raw": ["text/html,text/plain;q=0.5,"], + "header_type": "list", + "must_fail": true + }, + { + "name": "empty item parameterised list", + "raw": ["text/html,,text/plain;q=0.5,"], + "header_type": "list", + "must_fail": true + } +] diff --git a/http/testdata/structured_fields/param-listlist.json b/http/testdata/structured_fields/param-listlist.json new file mode 100644 index 000000000000..937fa4a1b2d8 --- /dev/null +++ b/http/testdata/structured_fields/param-listlist.json @@ -0,0 +1,36 @@ +[ + { + "name": "parameterised inner list", + "raw": ["(abc_123);a=1;b=2, cdef_456"], + "header_type": "list", + "expected": [ + [ + [[{"__type": "token", "value": "abc_123"}, []]], + [["a", 1], ["b", 2]] + ], + [{"__type": "token", "value": "cdef_456"}, []] + ] + }, + { + "name": "parameterised inner list item", + "raw": ["(abc_123;a=1;b=2;cdef_456)"], + "header_type": "list", + "expected": [ + [ + [[{"__type": "token", "value": "abc_123"}, [["a", 1], ["b", 2], ["cdef_456", true]]]], + [] + ] + ] + }, + { + "name": "parameterised inner list with parameterised item", + "raw": ["(abc_123;a=1;b=2);cdef_456"], + "header_type": "list", + "expected": [ + [ + [[{"__type": "token", "value": "abc_123"}, [["a", 1], ["b", 2]]]], + [["cdef_456", true]] + ] + ] + } +] diff --git a/http/testdata/structured_fields/string.json b/http/testdata/structured_fields/string.json new file mode 100644 index 000000000000..e768ed43a535 --- /dev/null +++ b/http/testdata/structured_fields/string.json @@ -0,0 +1,87 @@ +[ + { + "name": "basic string", + "raw": ["\"foo bar\""], + "header_type": "item", + "expected": ["foo bar", []] + }, + { + "name": "empty string", + "raw": ["\"\""], + "header_type": "item", + "expected": ["", []] + }, + { + "name": "long string", + "raw": ["\"foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo \""], + "header_type": "item", + "expected": ["foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo foo ", []] + }, + { + "name": "whitespace string", + "raw": ["\" \""], + "header_type": "item", + "expected": [" ", []] + }, + { + "name": "non-ascii string", + "raw": ["\"füü\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "tab in string", + "raw": ["\"\t\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "newline in string", + "raw": ["\" \n \""], + "header_type": "item", + "must_fail": true + }, + { + "name": "single quoted string", + "raw": ["'foo'"], + "header_type": "item", + "must_fail": true + }, + { + "name": "unbalanced string", + "raw": ["\"foo"], + "header_type": "item", + "must_fail": true + }, + { + "name": "string quoting", + "raw": ["\"foo \\\"bar\\\" \\\\ baz\""], + "header_type": "item", + "expected": ["foo \"bar\" \\ baz", []] + }, + { + "name": "bad string quoting", + "raw": ["\"foo \\,\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "ending string quote", + "raw": ["\"foo \\\""], + "header_type": "item", + "must_fail": true + }, + { + "name": "abruptly ending string quote", + "raw": ["\"foo \\"], + "header_type": "item", + "must_fail": true + }, + { + "name": "two lines string", + "raw": ["\"foo", "bar\""], + "header_type": "item", + "can_fail": true, + "expected": ["foo, bar", []] + } +] diff --git a/http/testdata/structured_fields/token.json b/http/testdata/structured_fields/token.json new file mode 100644 index 000000000000..001327ba6728 --- /dev/null +++ b/http/testdata/structured_fields/token.json @@ -0,0 +1,38 @@ +[ + { + "name": "basic token - item", + "raw": ["a_b-c.d3:f%00/*"], + "header_type": "item", + "expected": [{"__type": "token", "value": "a_b-c.d3:f%00/*"}, []] + }, + { + "name": "token with capitals - item", + "raw": ["fooBar"], + "header_type": "item", + "expected": [{"__type": "token", "value": "fooBar"}, []] + }, + { + "name": "token starting with capitals - item", + "raw": ["FooBar"], + "header_type": "item", + "expected": [{"__type": "token", "value": "FooBar"}, []] + }, + { + "name": "basic token - list", + "raw": ["a_b-c3/*"], + "header_type": "list", + "expected": [[{"__type": "token", "value": "a_b-c3/*"}, []]] + }, + { + "name": "token with capitals - list", + "raw": ["fooBar"], + "header_type": "list", + "expected": [[{"__type": "token", "value": "fooBar"}, []]] + }, + { + "name": "token starting with capitals - list", + "raw": ["FooBar"], + "header_type": "list", + "expected": [[{"__type": "token", "value": "FooBar"}, []]] + } +] diff --git a/http/unstable_structured_fields.ts b/http/unstable_structured_fields.ts new file mode 100644 index 000000000000..a24939048aa8 --- /dev/null +++ b/http/unstable_structured_fields.ts @@ -0,0 +1,1404 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** + * Utilities for parsing and serializing + * {@link https://www.rfc-editor.org/rfc/rfc9651 | RFC 9651} Structured Field Values for HTTP. + * + * Structured Fields provide a standardized way to define HTTP header and trailer + * field values using common data types (Lists, Dictionaries, Items) with strict + * parsing and serialization rules. + * + * @example Parsing a Dictionary (e.g., UCP-Agent header) + * ```ts + * import { parseDictionary } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const header = 'profile="https://example.com/profile.json"'; + * const dict = parseDictionary(header); + * + * assertEquals(dict.get("profile")?.value, { + * type: "string", + * value: "https://example.com/profile.json", + * }); + * ``` + * + * @example Serializing a Dictionary + * ```ts + * import { serializeDictionary, sfString } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const dict = new Map([ + * ["profile", { value: sfString("https://example.com/profile.json"), parameters: new Map() }], + * ]); + * + * assertEquals( + * serializeDictionary(dict), + * 'profile="https://example.com/profile.json"' + * ); + * ``` + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @module + */ + +import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; + +const UTF8_DECODER = /*@__PURE__*/ new TextDecoder("utf-8", { fatal: true }); + +// ============================================================================= +// Type Definitions (RFC 9651 Section 3) +// ============================================================================= + +/** + * A Bare Item value in a Structured Field. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.3} + */ +export type SfBareItem = + | { type: "integer"; value: number } + | { type: "decimal"; value: number } + | { type: "string"; value: string } + | { type: "token"; value: string } + | { type: "binary"; value: Uint8Array } + | { type: "boolean"; value: boolean } + | { type: "date"; value: Date } + | { type: "displaystring"; value: string }; + +/** + * Parameters attached to an Item or Inner List. + * + * Returned parameters are immutable. When building parameters for serialization, + * you can pass a mutable `Map` as it is assignable to `ReadonlyMap`. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.1.2} + */ +export type SfParameters = ReadonlyMap; + +/** + * An Item in a Structured Field, consisting of a Bare Item and Parameters. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.3} + */ +export interface SfItem { + /** The bare item value. */ + value: SfBareItem; + /** Parameters associated with this item. */ + parameters: SfParameters; +} + +/** + * An Inner List in a Structured Field. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.1.1} + */ +export interface SfInnerList { + /** The items in the inner list. */ + items: SfItem[]; + /** Parameters associated with the inner list. */ + parameters: SfParameters; +} + +/** + * A List Structured Field value. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.1} + */ +export type SfList = Array; + +/** + * A Dictionary Structured Field value. + * + * Returned dictionaries are immutable. When building dictionaries for serialization, + * you can pass a mutable `Map` as it is assignable to `ReadonlyMap`. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.2} + */ +export type SfDictionary = ReadonlyMap; + +// ============================================================================= +// Convenience Builders +// ============================================================================= + +/** An integer Bare Item. */ +export type SfIntegerItem = Extract; + +/** + * Creates an integer Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The integer value (-999999999999999 to 999999999999999). + * @returns A Bare Item of type integer. + * + * @example Usage + * ```ts + * import { sfInteger } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(sfInteger(42), { type: "integer", value: 42 }); + * ``` + */ +export function sfInteger(value: number): SfIntegerItem { + return { type: "integer", value }; +} + +/** A decimal Bare Item. */ +export type SfDecimalItem = Extract; + +/** + * Creates a decimal Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The decimal value. + * @returns A Bare Item of type decimal. + * + * @example Usage + * ```ts + * import { sfDecimal } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(sfDecimal(3.14), { type: "decimal", value: 3.14 }); + * ``` + */ +export function sfDecimal(value: number): SfDecimalItem { + return { type: "decimal", value }; +} + +/** A string Bare Item. */ +export type SfStringItem = Extract; + +/** + * Creates a string Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The string value (ASCII printable characters only). + * @returns A Bare Item of type string. + * + * @example Usage + * ```ts + * import { sfString } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(sfString("hello"), { type: "string", value: "hello" }); + * ``` + */ +export function sfString(value: string): SfStringItem { + return { type: "string", value }; +} + +/** A token Bare Item. */ +export type SfTokenItem = Extract; + +/** + * Creates a token Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The token value. + * @returns A Bare Item of type token. + * + * @example Usage + * ```ts + * import { sfToken } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(sfToken("foo"), { type: "token", value: "foo" }); + * ``` + */ +export function sfToken(value: string): SfTokenItem { + return { type: "token", value }; +} + +/** A binary Bare Item. */ +export type SfBinaryItem = Extract; + +/** + * Creates a binary Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The binary value as a Uint8Array. + * @returns A Bare Item of type binary. + * + * @example Usage + * ```ts + * import { sfBinary } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const result = sfBinary(new Uint8Array([1, 2, 3])); + * assertEquals(result.type, "binary"); + * ``` + */ +export function sfBinary(value: Uint8Array): SfBinaryItem { + return { type: "binary", value }; +} + +/** A boolean Bare Item. */ +export type SfBooleanItem = Extract; + +/** + * Creates a boolean Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The boolean value. + * @returns A Bare Item of type boolean. + * + * @example Usage + * ```ts + * import { sfBoolean } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(sfBoolean(true), { type: "boolean", value: true }); + * ``` + */ +export function sfBoolean(value: boolean): SfBooleanItem { + return { type: "boolean", value }; +} + +/** A date Bare Item. */ +export type SfDateItem = Extract; + +/** + * Creates a date Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The date value. + * @returns A Bare Item of type date. + * + * @example Usage + * ```ts + * import { sfDate } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const d = new Date("2022-08-04T00:00:00Z"); + * assertEquals(sfDate(d), { type: "date", value: d }); + * ``` + */ +export function sfDate(value: Date): SfDateItem { + return { type: "date", value }; +} + +/** A display string Bare Item. */ +export type SfDisplayStringItem = Extract< + SfBareItem, + { type: "displaystring" } +>; + +/** + * Creates a display string Bare Item. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The display string value (can contain Unicode). + * @returns A Bare Item of type displaystring. + * + * @example Usage + * ```ts + * import { sfDisplayString } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(sfDisplayString("héllo"), { type: "displaystring", value: "héllo" }); + * ``` + */ +export function sfDisplayString(value: string): SfDisplayStringItem { + return { type: "displaystring", value }; +} + +// ============================================================================= +// Type Guards +// ============================================================================= + +/** + * Checks if a list member is an Item (not an Inner List). + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param member The list member to check. + * @returns `true` if the member is an Item, `false` if it's an Inner List. + * + * @example Usage + * ```ts + * import { parseList, isItem } from "@std/http/unstable-structured-fields"; + * import { assert } from "@std/assert"; + * + * const list = parseList("1, (2 3)"); + * assert(isItem(list[0]!)); // true - integer item + * assert(!isItem(list[1]!)); // false - inner list + * ``` + */ +export function isItem(member: SfItem | SfInnerList): member is SfItem { + return !("items" in member); +} + +/** + * Checks if a list member is an Inner List. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param member The list member to check. + * @returns `true` if the member is an Inner List, `false` if it's an Item. + * + * @example Usage + * ```ts + * import { parseList, isInnerList } from "@std/http/unstable-structured-fields"; + * import { assert } from "@std/assert"; + * + * const list = parseList("1, (2 3)"); + * assert(!isInnerList(list[0]!)); // false - integer item + * assert(isInnerList(list[1]!)); // true - inner list + * ``` + */ +export function isInnerList( + member: SfItem | SfInnerList, +): member is SfInnerList { + return "items" in member; +} + +/** + * Checks if a Bare Item is of a specific type. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param item The bare item to check. + * @param type The type to check for. + * @returns `true` if the item is of the specified type. + * + * @example Usage + * ```ts + * import { parseItem, isBareItemType } from "@std/http/unstable-structured-fields"; + * import { assert, assertEquals } from "@std/assert"; + * + * const item = parseItem("42"); + * if (isBareItemType(item.value, "integer")) { + * assertEquals(item.value.value, 42); // TypeScript knows value is number + * } + * ``` + */ +export function isBareItemType( + item: SfBareItem, + type: T, +): item is Extract { + return item.type === type; +} + +// ============================================================================= +// Parsing (RFC 9651 Section 4.2) +// ============================================================================= + +/** Parser state holding input string and current position. */ +interface ParserState { + input: string; + pos: number; +} + +// Character code constants for ASCII ranges +const CHAR_CODE_0 = 48; // '0' +const CHAR_CODE_9 = 57; // '9' +const CHAR_CODE_UPPER_A = 65; // 'A' +const CHAR_CODE_UPPER_Z = 90; // 'Z' +const CHAR_CODE_LOWER_A = 97; // 'a' +const CHAR_CODE_LOWER_Z = 122; // 'z' + +/** Check if character is alphabetic (A-Z or a-z) */ +function isAlpha(c: string): boolean { + const code = c.charCodeAt(0); + return (code >= CHAR_CODE_UPPER_A && code <= CHAR_CODE_UPPER_Z) || + (code >= CHAR_CODE_LOWER_A && code <= CHAR_CODE_LOWER_Z); +} + +/** Check if character is a digit (0-9) */ +function isDigit(c: string): boolean { + const code = c.charCodeAt(0); + return code >= CHAR_CODE_0 && code <= CHAR_CODE_9; +} + +/** Check if character is lowercase alphabetic (a-z) */ +function isLcalpha(c: string): boolean { + const code = c.charCodeAt(0); + return code >= CHAR_CODE_LOWER_A && code <= CHAR_CODE_LOWER_Z; +} + +/** Check if character is valid at the start of a key (lcalpha or "*") */ +function isKeyStart(c: string): boolean { + return isLcalpha(c) || c === "*"; +} + +/** Check if character is valid within a key (lcalpha / DIGIT / "_" / "-" / "." / "*") */ +function isKeyChar(c: string): boolean { + return isLcalpha(c) || isDigit(c) || c === "_" || c === "-" || c === "." || + c === "*"; +} + +// Pre-computed lookup table for tchar (RFC 9110 token characters) +// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / +// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA +const TCHAR_LOOKUP: boolean[] = /*@__PURE__*/ (() => { + const table: boolean[] = new Array(128).fill(false); + const tchars = "!#$%&'*+-.^_`|~0123456789" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + for (const c of tchars) { + table[c.charCodeAt(0)] = true; + } + return table; +})(); + +// Pre-computed lookup table for base64 characters (A-Z, a-z, 0-9, +, /, =) +const BASE64_LOOKUP: boolean[] = /*@__PURE__*/ (() => { + const table: boolean[] = new Array(128).fill(false); + for (let i = CHAR_CODE_UPPER_A; i <= CHAR_CODE_UPPER_Z; i++) table[i] = true; + for (let i = CHAR_CODE_LOWER_A; i <= CHAR_CODE_LOWER_Z; i++) table[i] = true; + for (let i = CHAR_CODE_0; i <= CHAR_CODE_9; i++) table[i] = true; + table[43] = true; // + + table[47] = true; // / + table[61] = true; // = + return table; +})(); + +/** Check if character is a valid base64 character */ +function isBase64Char(c: string): boolean { + const code = c.charCodeAt(0); + return code < 128 && BASE64_LOOKUP[code]!; +} + +/** Check if character is a lowercase hex digit (0-9, a-f) */ +function isLcHexDigit(c: string): boolean { + const code = c.charCodeAt(0); + return (code >= CHAR_CODE_0 && code <= CHAR_CODE_9) || + (code >= CHAR_CODE_LOWER_A && code <= 102); // 'f' = 102 +} + +/** RFC 9110 tchar: token characters */ +function isTchar(c: string): boolean { + const code = c.charCodeAt(0); + return code < 128 && TCHAR_LOOKUP[code]!; +} + +/** Check if at end of input */ +function isEof(state: ParserState): boolean { + return state.pos >= state.input.length; +} + +/** Peek current character */ +function peek(state: ParserState): string { + return state.input[state.pos] ?? ""; +} + +/** Consume current character and advance */ +function consume(state: ParserState): string { + return state.input[state.pos++] ?? ""; +} + +/** Skip optional whitespace (SP or HTAB) */ +function skipOWS(state: ParserState): void { + while (!isEof(state) && (peek(state) === " " || peek(state) === "\t")) { + state.pos++; + } +} + +/** Skip required whitespace (at least one SP) */ +function skipSP(state: ParserState): void { + while (!isEof(state) && peek(state) === " ") { + state.pos++; + } +} + +/** + * Parses a List Structured Field value. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param input The string to parse. + * @returns The parsed List. + * @throws {SyntaxError} If the input is invalid. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-4.2.1} + * + * @example Usage + * ```ts + * import { parseList } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const list = parseList("1, 42"); + * assertEquals(list.length, 2); + * ``` + */ +export function parseList(input: string): SfList { + const state: ParserState = { input, pos: 0 }; + skipSP(state); + const list = parseListInternal(state); + skipSP(state); + if (!isEof(state)) { + throw new SyntaxError( + `Invalid structured field: unexpected character at position ${state.pos}`, + ); + } + return list; +} + +function parseListInternal(state: ParserState): SfList { + const members: SfList = []; + + while (!isEof(state)) { + const member = parseItemOrInnerList(state); + members.push(member); + skipOWS(state); + if (isEof(state)) { + return members; + } + if (peek(state) !== ",") { + throw new SyntaxError( + `Invalid structured field: expected ',' at position ${state.pos}`, + ); + } + consume(state); // consume ',' + skipOWS(state); + if (isEof(state)) { + throw new SyntaxError( + "Invalid structured field: trailing comma in list", + ); + } + } + + return members; +} + +/** + * Parses a Dictionary Structured Field value. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param input The string to parse. + * @returns The parsed Dictionary. + * @throws {SyntaxError} If the input is invalid. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-4.2.2} + * + * @example Usage + * ```ts + * import { parseDictionary } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const dict = parseDictionary('profile="https://example.com"'); + * assertEquals(dict.has("profile"), true); + * ``` + */ +export function parseDictionary(input: string): SfDictionary { + const state: ParserState = { input, pos: 0 }; + skipSP(state); + const dict = parseDictionaryInternal(state); + skipSP(state); + if (!isEof(state)) { + throw new SyntaxError( + `Invalid structured field: unexpected character at position ${state.pos}`, + ); + } + return dict; +} + +function parseDictionaryInternal(state: ParserState): SfDictionary { + const dict: Map = new Map(); + + while (!isEof(state)) { + const key = parseKey(state); + let member: SfItem | SfInnerList; + + if (peek(state) === "=") { + consume(state); // consume '=' + member = parseItemOrInnerList(state); + } else { + // Bare key means boolean true with parameters + const parameters = parseParameters(state); + member = { + value: { type: "boolean", value: true }, + parameters, + }; + } + + dict.set(key, member); + skipOWS(state); + if (isEof(state)) { + return dict; + } + if (peek(state) !== ",") { + throw new SyntaxError( + `Invalid structured field: expected ',' at position ${state.pos}`, + ); + } + consume(state); // consume ',' + skipOWS(state); + if (isEof(state)) { + throw new SyntaxError( + "Invalid structured field: trailing comma in dictionary", + ); + } + } + + return dict; +} + +/** + * Parses an Item Structured Field value. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param input The string to parse. + * @returns The parsed Item. + * @throws {SyntaxError} If the input is invalid. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-4.2.3} + * + * @example Usage + * ```ts + * import { parseItem } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const item = parseItem("42"); + * assertEquals(item.value, { type: "integer", value: 42 }); + * ``` + */ +export function parseItem(input: string): SfItem { + const state: ParserState = { input, pos: 0 }; + skipSP(state); + const item = parseItemInternal(state); + skipSP(state); + if (!isEof(state)) { + throw new SyntaxError( + `Invalid structured field: unexpected character at position ${state.pos}`, + ); + } + return item; +} + +function parseItemOrInnerList(state: ParserState): SfItem | SfInnerList { + if (peek(state) === "(") { + return parseInnerList(state); + } + return parseItemInternal(state); +} + +function parseInnerList(state: ParserState): SfInnerList { + if (consume(state) !== "(") { + throw new SyntaxError( + `Invalid structured field: expected '(' at position ${state.pos - 1}`, + ); + } + + const items: SfItem[] = []; + + while (!isEof(state)) { + skipSP(state); + if (peek(state) === ")") { + consume(state); + const parameters = parseParameters(state); + return { items, parameters }; + } + const item = parseItemInternal(state); + items.push(item); + + const c = peek(state); + if (c !== " " && c !== ")") { + throw new SyntaxError( + `Invalid structured field: expected SP or ')' at position ${state.pos}`, + ); + } + } + + throw new SyntaxError( + "Invalid structured field: unterminated inner list", + ); +} + +function parseItemInternal(state: ParserState): SfItem { + const value = parseBareItem(state); + const parameters = parseParameters(state); + return { value, parameters }; +} + +function parseBareItem(state: ParserState): SfBareItem { + const c = peek(state); + + if (c === "-" || isDigit(c)) { + return parseIntegerOrDecimal(state); + } + if (c === '"') { + return parseString(state); + } + if (c === ":") { + return parseBinary(state); + } + if (c === "?") { + return parseBoolean(state); + } + if (c === "@") { + return parseDate(state); + } + if (c === "%") { + return parseDisplayString(state); + } + if (isAlpha(c) || c === "*") { + return parseToken(state); + } + + throw new SyntaxError( + `Invalid structured field: unexpected character '${c}' at position ${state.pos}`, + ); +} + +function parseIntegerOrDecimal(state: ParserState): SfBareItem { + let sign = 1; + if (peek(state) === "-") { + consume(state); + sign = -1; + } + + if (!isDigit(peek(state))) { + throw new SyntaxError( + `Invalid structured field: expected digit at position ${state.pos}`, + ); + } + + let integerPart = ""; + while (isDigit(peek(state))) { + integerPart += consume(state); + if (integerPart.length > 15) { + throw new SyntaxError( + "Invalid structured field: integer too long", + ); + } + } + + if (peek(state) === ".") { + consume(state); // consume '.' + if (integerPart.length > 12) { + throw new SyntaxError( + "Invalid structured field: decimal integer part too long", + ); + } + + let fractionalPart = ""; + while (isDigit(peek(state))) { + fractionalPart += consume(state); + if (fractionalPart.length > 3) { + throw new SyntaxError( + "Invalid structured field: decimal fractional part too long", + ); + } + } + + if (fractionalPart.length === 0) { + throw new SyntaxError( + "Invalid structured field: decimal requires fractional digits", + ); + } + + const value = sign * parseFloat(`${integerPart}.${fractionalPart}`); + return { type: "decimal", value }; + } + + const value = sign * parseInt(integerPart, 10); + + // Check range: -999999999999999 to 999999999999999 + if (value < -999_999_999_999_999 || value > 999_999_999_999_999) { + throw new SyntaxError( + "Invalid structured field: integer out of range", + ); + } + + return { type: "integer", value }; +} + +function parseString(state: ParserState): SfBareItem { + if (consume(state) !== '"') { + throw new SyntaxError( + `Invalid structured field: expected '"' at position ${state.pos - 1}`, + ); + } + + const { input } = state; + const startPos = state.pos; + + // Fast path: find first special character (\ or ") + let firstSpecial = startPos; + while (firstSpecial < input.length) { + const c = input[firstSpecial]!; + if (c === '"' || c === "\\") break; + // Validate printable ASCII + const code = c.charCodeAt(0); + if (code < 0x20 || code > 0x7e) { + throw new SyntaxError( + `Invalid structured field: invalid character in string at position ${firstSpecial}`, + ); + } + firstSpecial++; + } + + // If we hit end of string without finding closing quote + if (firstSpecial >= input.length) { + throw new SyntaxError("Invalid structured field: unterminated string"); + } + + // Fast path: no escapes, just a closing quote + if (input[firstSpecial] === '"') { + state.pos = firstSpecial + 1; + return { type: "string", value: input.slice(startPos, firstSpecial) }; + } + + // Slow path: has escapes, need to process character by character + let value = input.slice(startPos, firstSpecial); + state.pos = firstSpecial; + + while (!isEof(state)) { + const c = consume(state); + + if (c === "\\") { + const escaped = consume(state); + if (escaped !== '"' && escaped !== "\\") { + throw new SyntaxError( + `Invalid structured field: invalid escape sequence at position ${ + state.pos - 1 + }`, + ); + } + value += escaped; + } else if (c === '"') { + return { type: "string", value }; + } else { + // Must be printable ASCII (0x20-0x7E) excluding 0x22 (") and 0x5C (\) + const code = c.charCodeAt(0); + if (code < 0x20 || code > 0x7e) { + throw new SyntaxError( + `Invalid structured field: invalid character in string at position ${ + state.pos - 1 + }`, + ); + } + value += c; + } + } + + throw new SyntaxError( + "Invalid structured field: unterminated string", + ); +} + +function parseToken(state: ParserState): SfBareItem { + const first = peek(state); + if (!isAlpha(first) && first !== "*") { + throw new SyntaxError( + `Invalid structured field: invalid token start at position ${state.pos}`, + ); + } + + let value = consume(state); + while (!isEof(state)) { + const c = peek(state); + if (isTchar(c) || c === ":" || c === "/") { + value += consume(state); + } else { + break; + } + } + + return { type: "token", value }; +} + +function parseBinary(state: ParserState): SfBareItem { + if (consume(state) !== ":") { + throw new SyntaxError( + `Invalid structured field: expected ':' at position ${state.pos - 1}`, + ); + } + + const { input } = state; + const startPos = state.pos; + + // Find the closing colon while validating base64 characters + while (state.pos < input.length && input[state.pos] !== ":") { + if (!isBase64Char(input[state.pos]!)) { + throw new SyntaxError( + `Invalid structured field: invalid base64 character at position ${state.pos}`, + ); + } + state.pos++; + } + + if (state.pos >= input.length) { + throw new SyntaxError( + "Invalid structured field: unterminated binary", + ); + } + + const base64 = input.slice(startPos, state.pos); + state.pos++; // consume closing ':' + + try { + const value = decodeBase64(base64); + return { type: "binary", value }; + } catch { + throw new SyntaxError( + "Invalid structured field: invalid base64 encoding", + ); + } +} + +function parseBoolean(state: ParserState): SfBareItem { + if (consume(state) !== "?") { + throw new SyntaxError( + `Invalid structured field: expected '?' at position ${state.pos - 1}`, + ); + } + + const c = consume(state); + if (c === "1") { + return { type: "boolean", value: true }; + } + if (c === "0") { + return { type: "boolean", value: false }; + } + + throw new SyntaxError( + `Invalid structured field: expected '0' or '1' at position ${ + state.pos - 1 + }`, + ); +} + +function parseDate(state: ParserState): SfBareItem { + if (consume(state) !== "@") { + throw new SyntaxError( + `Invalid structured field: expected '@' at position ${state.pos - 1}`, + ); + } + + const intItem = parseIntegerOrDecimal(state); + if (intItem.type !== "integer") { + throw new SyntaxError( + "Invalid structured field: date must be an integer", + ); + } + + const value = new Date(intItem.value * 1000); + return { type: "date", value }; +} + +function parseDisplayString(state: ParserState): SfBareItem { + if (consume(state) !== "%") { + throw new SyntaxError( + `Invalid structured field: expected '%' at position ${state.pos - 1}`, + ); + } + if (consume(state) !== '"') { + throw new SyntaxError( + `Invalid structured field: expected '"' at position ${state.pos - 1}`, + ); + } + + const bytes: number[] = []; + while (!isEof(state)) { + const c = consume(state); + + if (c === '"') { + // Decode UTF-8 bytes to string + try { + const value = UTF8_DECODER.decode(new Uint8Array(bytes)); + return { type: "displaystring", value }; + } catch { + throw new SyntaxError( + "Invalid structured field: invalid UTF-8 in display string", + ); + } + } else if (c === "%") { + // Percent-encoded byte + const hex1 = consume(state); + const hex2 = consume(state); + if (!isLcHexDigit(hex1) || !isLcHexDigit(hex2)) { + throw new SyntaxError( + `Invalid structured field: invalid percent encoding at position ${ + state.pos - 2 + }`, + ); + } + bytes.push(parseInt(hex1 + hex2, 16)); + } else { + // Must be allowed unescaped character per RFC 9651: + // unescaped = %x20-21 / %x23-24 / %x26-5B / %x5D-7E + // (space, !, #, $, &-[, ]-~) + // Note: " (0x22) and % (0x25) must be percent-encoded + // Note: Per conformance tests, \ (0x5C) is also allowed + const code = c.charCodeAt(0); + const isAllowed = code === 0x20 || code === 0x21 || // space, ! + code === 0x23 || code === 0x24 || // #, $ + (code >= 0x26 && code <= 0x5b) || // &-[ + (code >= 0x5c && code <= 0x7e); // \-~ (includes \ per conformance tests) + if (!isAllowed) { + throw new SyntaxError( + `Invalid structured field: invalid character in display string at position ${ + state.pos - 1 + }`, + ); + } + bytes.push(code); + } + } + + throw new SyntaxError( + "Invalid structured field: unterminated display string", + ); +} + +function parseKey(state: ParserState): string { + const first = peek(state); + if (!isKeyStart(first)) { + throw new SyntaxError( + `Invalid structured field: invalid key start at position ${state.pos}`, + ); + } + + let key = consume(state); + while (!isEof(state) && isKeyChar(peek(state))) { + key += consume(state); + } + + return key; +} + +function parseParameters(state: ParserState): SfParameters { + const parameters: Map = new Map(); + + while (peek(state) === ";") { + consume(state); // consume ';' + skipSP(state); + const key = parseKey(state); + + let value: SfBareItem; + if (peek(state) === "=") { + consume(state); // consume '=' + value = parseBareItem(state); + } else { + value = { type: "boolean", value: true }; + } + + parameters.set(key, value); + } + + return parameters; +} + +// ============================================================================= +// Serialization (RFC 9651 Section 4.1) +// ============================================================================= + +/** + * Serializes a List to a string. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param list The List to serialize. + * @returns The serialized string. + * @throws {TypeError} If the list contains invalid values. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-4.1.1} + * + * @example Usage + * ```ts + * import { serializeList, sfInteger } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const list = [ + * { value: sfInteger(1), parameters: new Map() }, + * { value: sfInteger(42), parameters: new Map() }, + * ]; + * + * assertEquals(serializeList(list), "1, 42"); + * ``` + */ +export function serializeList(list: SfList): string { + const parts: string[] = []; + + for (const member of list) { + if ("items" in member) { + parts.push(serializeInnerList(member)); + } else { + parts.push(serializeItemInternal(member)); + } + } + + return parts.join(", "); +} + +/** + * Serializes a Dictionary to a string. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param dict The Dictionary to serialize. + * @returns The serialized string. + * @throws {TypeError} If the dictionary contains invalid values. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-4.1.2} + * + * @example Usage + * ```ts + * import { serializeDictionary, sfString } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const dict = new Map([ + * ["key", { value: sfString("value"), parameters: new Map() }], + * ]); + * + * assertEquals(serializeDictionary(dict), 'key="value"'); + * ``` + */ +export function serializeDictionary(dict: SfDictionary): string { + const parts: string[] = []; + + for (const [key, member] of dict) { + validateKey(key); + + if ("items" in member) { + // Inner list + parts.push(`${key}=${serializeInnerList(member)}`); + } else { + // Item + if (member.value.type === "boolean" && member.value.value === true) { + // Omit =?1 for true boolean + parts.push(key + serializeParameters(member.parameters)); + } else { + parts.push(`${key}=${serializeItemInternal(member)}`); + } + } + } + + return parts.join(", "); +} + +/** + * Serializes an Item to a string. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param item The Item to serialize. + * @returns The serialized string. + * @throws {TypeError} If the item contains invalid values. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-4.1.3} + * + * @example Usage + * ```ts + * import { serializeItem, sfInteger } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const item = { value: sfInteger(42), parameters: new Map() }; + * + * assertEquals(serializeItem(item), "42"); + * ``` + */ +export function serializeItem(item: SfItem): string { + return serializeItemInternal(item); +} + +function serializeInnerList(innerList: SfInnerList): string { + const items = innerList.items.map(serializeItemInternal).join(" "); + return `(${items})${serializeParameters(innerList.parameters)}`; +} + +function serializeItemInternal(item: SfItem): string { + return serializeBareItem(item.value) + serializeParameters(item.parameters); +} + +function serializeBareItem(bareItem: SfBareItem): string { + switch (bareItem.type) { + case "integer": + return serializeInteger(bareItem.value); + case "decimal": + return serializeDecimal(bareItem.value); + case "string": + return serializeString(bareItem.value); + case "token": + return serializeToken(bareItem.value); + case "binary": + return serializeBinary(bareItem.value); + case "boolean": + return serializeBoolean(bareItem.value); + case "date": + return serializeDate(bareItem.value); + case "displaystring": + return serializeDisplayString(bareItem.value); + } +} + +function serializeInteger(value: number): string { + if (!Number.isInteger(value)) { + throw new TypeError("Integer must be a whole number"); + } + if (value < -999_999_999_999_999 || value > 999_999_999_999_999) { + throw new TypeError("Integer out of range"); + } + return String(value); +} + +function serializeDecimal(value: number): string { + if (!Number.isFinite(value)) { + throw new TypeError("Decimal must be finite"); + } + + // Round to 3 decimal places + const rounded = Math.round(value * 1000) / 1000; + + // Check integer part (max 12 digits) + const intPart = Math.trunc(Math.abs(rounded)); + if (intPart > 999_999_999_999) { + throw new TypeError("Decimal integer part too large"); + } + + // Format with 3 fractional digits + const str = rounded.toFixed(3); + + // Remove trailing zeros but keep at least one digit after decimal + let end = str.length; + while (end > 0 && str[end - 1] === "0") { + end--; + } + // Keep at least one digit after decimal point + const dotIndex = str.indexOf("."); + if (end <= dotIndex + 1) { + end = dotIndex + 2; + } + + return str.slice(0, end); +} + +function serializeString(value: string): string { + // Validate ASCII printable and check if escaping needed + let needsEscape = false; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 0x20 || code > 0x7e) { + throw new TypeError(`Invalid character in string at position ${i}`); + } + if (code === 0x22 || code === 0x5c) { // " or \ + needsEscape = true; + } + } + + // Fast path: no escaping needed + if (!needsEscape) { + return `"${value}"`; + } + + // Slow path: escape \ and " + const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `"${escaped}"`; +} + +function serializeToken(value: string): string { + if (value.length === 0) { + throw new TypeError("Token cannot be empty"); + } + + const first = value[0]!; + if (!isAlpha(first) && first !== "*") { + throw new TypeError("Token must start with ALPHA or '*'"); + } + + for (let i = 1; i < value.length; i++) { + const c = value[i]!; + if (!isTchar(c) && c !== ":" && c !== "/") { + throw new TypeError(`Invalid character in token at position ${i}`); + } + } + + return value; +} + +function serializeBinary(value: Uint8Array): string { + return `:${encodeBase64(value)}:`; +} + +function serializeBoolean(value: boolean): string { + return value ? "?1" : "?0"; +} + +function serializeDate(value: Date): string { + const timestamp = Math.floor(value.getTime() / 1000); + if (!Number.isFinite(timestamp)) { + throw new TypeError("Invalid date"); + } + return `@${timestamp}`; +} + +function serializeDisplayString(value: string): string { + const encoder = new TextEncoder(); + const bytes = encoder.encode(value); + + let result = '%"'; + for (const byte of bytes) { + if (byte === 0x25) { + // % -> %25 + result += "%25"; + } else if (byte === 0x22) { + // " -> %22 + result += "%22"; + } else if (byte >= 0x20 && byte <= 0x7e) { + // Printable ASCII + result += String.fromCharCode(byte); + } else { + // Percent-encode non-ASCII + result += "%" + byte.toString(16).padStart(2, "0"); + } + } + result += '"'; + + return result; +} + +function serializeParameters(parameters: SfParameters): string { + let result = ""; + + for (const [key, value] of parameters) { + validateKey(key); + result += ";"; + result += key; + + if (value.type !== "boolean" || value.value !== true) { + result += "="; + result += serializeBareItem(value); + } + } + + return result; +} + +function validateKey(key: string): void { + if (key.length === 0) { + throw new TypeError("Key cannot be empty"); + } + + if (!isKeyStart(key[0]!)) { + throw new TypeError("Key must start with lowercase letter or '*'"); + } + + for (let i = 1; i < key.length; i++) { + if (!isKeyChar(key[i]!)) { + throw new TypeError(`Invalid character in key at position ${i}`); + } + } +} diff --git a/http/unstable_structured_fields_test.ts b/http/unstable_structured_fields_test.ts new file mode 100644 index 000000000000..cc8218c56579 --- /dev/null +++ b/http/unstable_structured_fields_test.ts @@ -0,0 +1,1154 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +import { assertEquals, assertThrows } from "@std/assert"; +import { + isBareItemType, + isInnerList, + isItem, + parseDictionary, + parseItem, + parseList, + serializeDictionary, + serializeItem, + serializeList, + type SfBareItem, + sfBinary, + sfBoolean, + sfDate, + sfDecimal, + type SfDictionary, + sfDisplayString, + type SfInnerList, + sfInteger, + type SfItem, + type SfList, + sfString, + sfToken, +} from "./unstable_structured_fields.ts"; + +// ============================================================================= +// Parsing Tests (edge cases NOT covered by conformance tests) +// Note: Basic parsing is covered by HTTPWG conformance tests below. +// These tests verify specific error messages and edge cases. +// ============================================================================= + +Deno.test({ + name: "parseList() rejects trailing garbage", + fn() { + assertThrows( + () => parseList("1, 2 garbage"), + SyntaxError, + "expected ','", + ); + }, +}); + +Deno.test({ + name: "parseList() rejects unterminated inner list", + fn() { + assertThrows( + () => parseList("(1 2"), + SyntaxError, + "expected SP or ')'", + ); + }, +}); + +Deno.test({ + name: "parseList() rejects invalid inner list separator", + fn() { + assertThrows( + () => parseList("(1,2)"), + SyntaxError, + "expected SP or ')'", + ); + }, +}); + +Deno.test({ + name: "parseList() parses whitespace-only as empty list", + fn() { + const list = parseList(" "); + assertEquals(list.length, 0); + }, +}); + +Deno.test({ + name: "parseList() rejects unterminated inner list at EOF", + fn() { + assertThrows( + () => parseList("("), + SyntaxError, + "unterminated inner list", + ); + }, +}); + +Deno.test({ + name: "parseDictionary() rejects trailing garbage", + fn() { + assertThrows( + () => parseDictionary("a=1 garbage"), + SyntaxError, + "expected ','", + ); + }, +}); + +Deno.test({ + name: "parseDictionary() parses whitespace-only as empty dictionary", + fn() { + const dict = parseDictionary(" "); + assertEquals(dict.size, 0); + }, +}); + +Deno.test({ + name: "parseDictionary() handles key starting with *", + fn() { + const dict = parseDictionary("*key=1"); + assertEquals(dict.has("*key"), true); + assertEquals((dict.get("*key") as SfItem).value, { + type: "integer", + value: 1, + }); + }, +}); + +Deno.test({ + name: "parseItem() parses token starting with *", + fn() { + const item = parseItem("*foo"); + assertEquals(item.value, { type: "token", value: "*foo" }); + }, +}); + +Deno.test({ + name: "parseItem() rejects trailing garbage", + fn() { + assertThrows( + () => parseItem("42 garbage"), + SyntaxError, + "unexpected character", + ); + }, +}); + +Deno.test({ + name: "parseItem() rejects whitespace-only input", + fn() { + assertThrows( + () => parseItem(" "), + SyntaxError, + "unexpected character", + ); + }, +}); + +Deno.test({ + name: "parseItem() rejects decimal with no fractional digits", + fn() { + assertThrows( + () => parseItem("1."), + SyntaxError, + "requires fractional digits", + ); + }, +}); + +Deno.test({ + name: "parseItem() rejects unterminated binary", + fn() { + assertThrows( + () => parseItem(":aGVsbG8="), + SyntaxError, + "unterminated binary", + ); + }, +}); + +Deno.test({ + name: "parseItem() rejects invalid key start in parameters", + fn() { + assertThrows( + () => parseItem("foo;1invalid=1"), + SyntaxError, + "invalid key start", + ); + }, +}); + +Deno.test({ + name: "parseItem() handles token with all valid chars", + fn() { + const item = parseItem("a0_-.*/+!#$%&'^`|~:"); + assertEquals(item.value.type, "token"); + }, +}); + +Deno.test({ + name: "parseDictionary() handles key with all valid chars", + fn() { + const dict = parseDictionary("a0_-.*=1"); + assertEquals(dict.has("a0_-.*"), true); + }, +}); + +// ============================================================================= +// Serialization Tests (RFC 9651 Section 4.1) +// Note: Serialization is NOT covered by conformance tests +// ============================================================================= + +Deno.test({ + name: "serializeList() serializes basic list", + fn() { + const list: SfList = [ + { value: sfInteger(1), parameters: new Map() }, + { value: sfInteger(42), parameters: new Map() }, + ]; + assertEquals(serializeList(list), "1, 42"); + }, +}); + +Deno.test({ + name: "serializeList() serializes empty list", + fn() { + assertEquals(serializeList([]), ""); + }, +}); + +Deno.test({ + name: "serializeList() serializes inner list", + fn() { + const innerList: SfInnerList = { + items: [ + { value: sfInteger(1), parameters: new Map() }, + { value: sfInteger(2), parameters: new Map() }, + ], + parameters: new Map(), + }; + const list: SfList = [innerList]; + assertEquals(serializeList(list), "(1 2)"); + }, +}); + +Deno.test({ + name: "serializeList() serializes inner list with parameters", + fn() { + const innerList: SfInnerList = { + items: [ + { value: sfInteger(1), parameters: new Map() }, + ], + parameters: new Map([ + ["param", sfToken("value")], + ]), + }; + assertEquals(serializeList([innerList]), "(1);param=value"); + }, +}); + +Deno.test({ + name: "serializeDictionary() serializes basic dictionary", + fn() { + const dict: SfDictionary = new Map([ + ["a", { value: sfInteger(1), parameters: new Map() }], + ["b", { value: sfInteger(2), parameters: new Map() }], + ]); + assertEquals(serializeDictionary(dict), "a=1, b=2"); + }, +}); + +Deno.test({ + name: "serializeDictionary() omits =?1 for true boolean", + fn() { + const dict: SfDictionary = new Map([ + ["a", { value: sfBoolean(true), parameters: new Map() }], + ]); + assertEquals(serializeDictionary(dict), "a"); + }, +}); + +Deno.test({ + name: "serializeDictionary() includes =?0 for false boolean", + fn() { + const dict: SfDictionary = new Map([ + ["a", { value: sfBoolean(false), parameters: new Map() }], + ]); + assertEquals(serializeDictionary(dict), "a=?0"); + }, +}); + +Deno.test({ + name: "serializeDictionary() handles key starting with *", + fn() { + const dict: SfDictionary = new Map([ + ["*key", { value: sfInteger(1), parameters: new Map() }], + ]); + assertEquals(serializeDictionary(dict), "*key=1"); + }, +}); + +Deno.test({ + name: "serializeDictionary() serializes inner list value", + fn() { + const innerList: SfInnerList = { + items: [ + { value: sfInteger(1), parameters: new Map() }, + { value: sfInteger(2), parameters: new Map() }, + ], + parameters: new Map(), + }; + const dict: SfDictionary = new Map([ + ["a", innerList], + ]); + assertEquals(serializeDictionary(dict), "a=(1 2)"); + }, +}); + +Deno.test({ + name: "serializeItem() covers all bare item types", + fn() { + assertEquals( + serializeItem({ value: sfToken("foo"), parameters: new Map() }), + "foo", + ); + assertEquals( + serializeItem({ + value: sfBinary(new Uint8Array([1, 2, 3])), + parameters: new Map(), + }), + ":AQID:", + ); + assertEquals( + serializeItem({ value: sfBoolean(true), parameters: new Map() }), + "?1", + ); + assertEquals( + serializeItem({ value: sfBoolean(false), parameters: new Map() }), + "?0", + ); + const d = new Date(1659578233000); + assertEquals( + serializeItem({ value: sfDate(d), parameters: new Map() }), + "@1659578233", + ); + assertEquals( + serializeItem({ value: sfDisplayString("héllo"), parameters: new Map() }), + '%"h%c3%a9llo"', + ); + }, +}); + +Deno.test({ + name: "serializeItem() escapes quotes and backslash in string", + fn() { + assertEquals( + serializeItem({ + value: sfString('hello "world"'), + parameters: new Map(), + }), + '"hello \\"world\\""', + ); + assertEquals( + serializeItem({ value: sfString("hello\\world"), parameters: new Map() }), + '"hello\\\\world"', + ); + }, +}); + +Deno.test({ + name: "serializeItem() serializes item with parameters", + fn() { + const params = new Map([ + ["a", sfInteger(1)], + ["b", sfBoolean(true)], + ]); + assertEquals( + serializeItem({ value: sfToken("foo"), parameters: params }), + "foo;a=1;b", + ); + }, +}); + +Deno.test({ + name: "serializeItem() handles boundary integers", + fn() { + assertEquals( + serializeItem({ + value: sfInteger(-999_999_999_999_999), + parameters: new Map(), + }), + "-999999999999999", + ); + assertEquals( + serializeItem({ + value: sfInteger(999_999_999_999_999), + parameters: new Map(), + }), + "999999999999999", + ); + }, +}); + +Deno.test({ + name: "serializeItem() handles decimal edge cases", + fn() { + assertEquals( + serializeItem({ value: sfDecimal(1.0), parameters: new Map() }), + "1.0", + ); + assertEquals( + serializeItem({ value: sfDecimal(-3.14), parameters: new Map() }), + "-3.14", + ); + }, +}); + +Deno.test({ + name: "serializeItem() handles token starting with *", + fn() { + assertEquals( + serializeItem({ value: sfToken("*foo"), parameters: new Map() }), + "*foo", + ); + }, +}); + +Deno.test({ + name: 'serializeItem() encodes % and " in display string', + fn() { + assertEquals( + serializeItem({ + value: sfDisplayString('hello % and "'), + parameters: new Map(), + }), + '%"hello %25 and %22"', + ); + }, +}); + +// ============================================================================= +// Serialization Error Tests +// ============================================================================= + +Deno.test({ + name: "serializeItem() rejects out-of-range integer", + fn() { + assertThrows( + () => serializeItem({ value: sfInteger(1e16), parameters: new Map() }), + TypeError, + "out of range", + ); + assertThrows( + () => serializeItem({ value: sfInteger(-1e16), parameters: new Map() }), + TypeError, + "out of range", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects non-integer value for integer type", + fn() { + assertThrows( + () => serializeItem({ value: sfInteger(3.14), parameters: new Map() }), + TypeError, + "must be a whole number", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects non-finite decimal", + fn() { + assertThrows( + () => + serializeItem({ value: sfDecimal(Infinity), parameters: new Map() }), + TypeError, + "must be finite", + ); + assertThrows( + () => serializeItem({ value: sfDecimal(NaN), parameters: new Map() }), + TypeError, + "must be finite", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects decimal with integer part too large", + fn() { + assertThrows( + () => + serializeItem({ + value: sfDecimal(1_000_000_000_000.1), + parameters: new Map(), + }), + TypeError, + "integer part too large", + ); + assertThrows( + () => + serializeItem({ + value: sfDecimal(-1_000_000_000_000.1), + parameters: new Map(), + }), + TypeError, + "integer part too large", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects invalid date", + fn() { + assertThrows( + () => + serializeItem({ + value: sfDate(new Date("invalid")), + parameters: new Map(), + }), + TypeError, + "Invalid date", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects non-printable ASCII in string", + fn() { + assertThrows( + () => serializeItem({ value: sfString("\x00"), parameters: new Map() }), + TypeError, + "Invalid character", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects non-ASCII in string", + fn() { + assertThrows( + () => serializeItem({ value: sfString("héllo"), parameters: new Map() }), + TypeError, + "Invalid character", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects empty token", + fn() { + assertThrows( + () => serializeItem({ value: sfToken(""), parameters: new Map() }), + TypeError, + "cannot be empty", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects token with invalid start character", + fn() { + assertThrows( + () => serializeItem({ value: sfToken("1foo"), parameters: new Map() }), + TypeError, + "must start with ALPHA", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects token with invalid character", + fn() { + assertThrows( + () => serializeItem({ value: sfToken("foo bar"), parameters: new Map() }), + TypeError, + "Invalid character in token", + ); + }, +}); + +Deno.test({ + name: "serializeDictionary() rejects invalid key", + fn() { + const dict: SfDictionary = new Map([ + ["INVALID", { value: sfInteger(1), parameters: new Map() }], + ]); + assertThrows( + () => serializeDictionary(dict), + TypeError, + "must start with lowercase", + ); + }, +}); + +Deno.test({ + name: "serializeDictionary() rejects empty key", + fn() { + const dict: SfDictionary = new Map([ + ["", { value: sfInteger(1), parameters: new Map() }], + ]); + assertThrows( + () => serializeDictionary(dict), + TypeError, + "cannot be empty", + ); + }, +}); + +Deno.test({ + name: "serializeDictionary() rejects key with invalid character", + fn() { + const dict: SfDictionary = new Map([ + ["a!b", { value: sfInteger(1), parameters: new Map() }], + ]); + assertThrows( + () => serializeDictionary(dict), + TypeError, + "Invalid character in key", + ); + }, +}); + +Deno.test({ + name: "serializeItem() rejects invalid parameter key", + fn() { + const params = new Map([ + ["INVALID", sfInteger(1)], + ]); + assertThrows( + () => serializeItem({ value: sfToken("foo"), parameters: params }), + TypeError, + "must start with lowercase", + ); + }, +}); + +// ============================================================================= +// Round-trip Tests +// ============================================================================= + +Deno.test({ + name: "round-trip: list with inner list", + fn() { + const input = "1, 42, (a b)"; + const parsed = parseList(input); + const serialized = serializeList(parsed); + const reparsed = parseList(serialized); + assertEquals(parsed.length, reparsed.length); + }, +}); + +Deno.test({ + name: "round-trip: dictionary with mixed values", + fn() { + const input = 'a=1, b="hello", c'; + const parsed = parseDictionary(input); + const serialized = serializeDictionary(parsed); + const reparsed = parseDictionary(serialized); + assertEquals(parsed.size, reparsed.size); + }, +}); + +Deno.test({ + name: "round-trip: item with parameters", + fn() { + const input = "foo;a=1;b"; + const parsed = parseItem(input); + const serialized = serializeItem(parsed); + const reparsed = parseItem(serialized); + assertEquals(parsed.value, reparsed.value); + assertEquals(parsed.parameters.size, reparsed.parameters.size); + }, +}); + +// ============================================================================= +// Convenience Builder Tests +// ============================================================================= + +Deno.test({ + name: "sf* factory functions create correct bare items", + fn() { + assertEquals(sfInteger(42), { type: "integer", value: 42 }); + assertEquals(sfDecimal(3.14), { type: "decimal", value: 3.14 }); + assertEquals(sfString("hello"), { type: "string", value: "hello" }); + assertEquals(sfToken("foo"), { type: "token", value: "foo" }); + assertEquals(sfBinary(new Uint8Array([1, 2, 3])).type, "binary"); + assertEquals(sfBoolean(true), { type: "boolean", value: true }); + assertEquals(sfBoolean(false), { type: "boolean", value: false }); + const d = new Date(); + assertEquals(sfDate(d), { type: "date", value: d }); + assertEquals(sfDisplayString("héllo"), { + type: "displaystring", + value: "héllo", + }); + }, +}); + +// ============================================================================= +// Type Guard Tests +// ============================================================================= + +Deno.test({ + name: "isItem() distinguishes items from inner lists", + fn() { + const list = parseList("1, (2 3)"); + assertEquals(isItem(list[0]!), true); + assertEquals(isItem(list[1]!), false); + }, +}); + +Deno.test({ + name: "isInnerList() distinguishes inner lists from items", + fn() { + const list = parseList("1, (2 3)"); + assertEquals(isInnerList(list[0]!), false); + assertEquals(isInnerList(list[1]!), true); + }, +}); + +Deno.test({ + name: "isBareItemType() narrows bare item type", + fn() { + const item = parseItem("42"); + assertEquals(isBareItemType(item.value, "integer"), true); + assertEquals(isBareItemType(item.value, "string"), false); + + if (isBareItemType(item.value, "integer")) { + // TypeScript should know this is a number + assertEquals(item.value.value, 42); + } + }, +}); + +Deno.test({ + name: "type guards work with dictionary values", + fn() { + const dict = parseDictionary("a=1, b=(1 2)"); + const a = dict.get("a")!; + const b = dict.get("b")!; + + assertEquals(isItem(a), true); + assertEquals(isInnerList(a), false); + assertEquals(isItem(b), false); + assertEquals(isInnerList(b), true); + }, +}); + +// ============================================================================= +// HTTPWG Official Conformance Tests (https://github.com/httpwg/structured-field-tests) +// ============================================================================= + +// Base32 alphabet for decoding test vectors +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +function decodeBase32(input: string): Uint8Array { + // Remove padding + input = input.replace(/=+$/, "").toUpperCase(); + if (input.length === 0) return new Uint8Array(0); + + const output: number[] = []; + let bits = 0; + let value = 0; + + for (const char of input) { + const idx = BASE32_ALPHABET.indexOf(char); + if (idx === -1) throw new Error(`Invalid base32 character: ${char}`); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + bits -= 8; + output.push((value >> bits) & 0xff); + } + } + return new Uint8Array(output); +} + +// Convert test suite expected value to our internal format +// deno-lint-ignore no-explicit-any +function convertExpectedBareItem(expected: any): SfBareItem { + if (expected === null || expected === undefined) { + throw new Error("Unexpected null/undefined bare item"); + } + if (typeof expected === "boolean") { + return sfBoolean(expected); + } + if (typeof expected === "number") { + if (Number.isInteger(expected)) { + return sfInteger(expected); + } + return sfDecimal(expected); + } + if (typeof expected === "string") { + return sfString(expected); + } + if (typeof expected === "object" && "__type" in expected) { + switch (expected.__type) { + case "token": + return sfToken(expected.value); + case "binary": + return sfBinary(decodeBase32(expected.value)); + case "date": + return sfDate(new Date(expected.value * 1000)); + case "displaystring": + return sfDisplayString(expected.value); + default: + throw new Error(`Unknown __type: ${expected.__type}`); + } + } + throw new Error(`Cannot convert expected value: ${JSON.stringify(expected)}`); +} + +// Convert test suite expected params array to Map +// deno-lint-ignore no-explicit-any +function convertExpectedParams(params: any[]): Map { + const map = new Map(); + for (const [key, value] of params) { + map.set(key, convertExpectedBareItem(value)); + } + return map; +} + +// Convert test suite expected item [bareItem, params] to SfItem +// deno-lint-ignore no-explicit-any +function convertExpectedItem(expected: any[]): SfItem { + const [bareItem, params] = expected; + return { + value: convertExpectedBareItem(bareItem), + parameters: convertExpectedParams(params), + }; +} + +// Convert test suite expected inner list [[items...], params] to SfInnerList +// deno-lint-ignore no-explicit-any +function convertExpectedInnerList(expected: any[]): SfInnerList { + const [items, params] = expected; + return { + items: items.map(convertExpectedItem), + parameters: convertExpectedParams(params), + }; +} + +// Check if expected value represents an inner list +// deno-lint-ignore no-explicit-any +function isExpectedInnerList(expected: any[]): boolean { + // Inner list: [[item1, item2, ...], params] + // Item: [bareItem, params] + // The first element of an inner list is an array of items (arrays) + // The first element of an item is a bare item (not an array, unless it's broken) + if (!Array.isArray(expected) || expected.length !== 2) return false; + const [first] = expected; + if (!Array.isArray(first)) return false; + // If first element is empty array, it's an empty inner list + if (first.length === 0) return true; + // If first element is an array of arrays, it's an inner list + return Array.isArray(first[0]); +} + +// Convert test suite expected list member +// deno-lint-ignore no-explicit-any +function convertExpectedListMember(expected: any[]): SfItem | SfInnerList { + if (isExpectedInnerList(expected)) { + return convertExpectedInnerList(expected); + } + return convertExpectedItem(expected); +} + +// Compare bare items for equality +function bareItemsEqual(a: SfBareItem, b: SfBareItem): boolean { + // Handle integer/decimal comparison - JSON loses distinction between 1 and 1.0 + if ( + (a.type === "integer" || a.type === "decimal") && + (b.type === "integer" || b.type === "decimal") + ) { + return (a.value as number) === (b.value as number); + } + if (a.type !== b.type) return false; + if (a.type === "binary" && b.type === "binary") { + const aVal = a.value as Uint8Array; + const bVal = b.value as Uint8Array; + if (aVal.length !== bVal.length) return false; + return aVal.every((v, i) => v === bVal[i]); + } + if (a.type === "date" && b.type === "date") { + return (a.value as Date).getTime() === (b.value as Date).getTime(); + } + return a.value === b.value; +} + +// Compare parameters for equality +function paramsEqual( + a: ReadonlyMap, + b: ReadonlyMap, +): boolean { + if (a.size !== b.size) return false; + for (const [key, val] of a) { + const bVal = b.get(key); + if (!bVal || !bareItemsEqual(val, bVal)) return false; + } + return true; +} + +// Compare items for equality +function itemsEqual(a: SfItem, b: SfItem): boolean { + return bareItemsEqual(a.value, b.value) && + paramsEqual(a.parameters, b.parameters); +} + +// Compare inner lists for equality +function innerListsEqual(a: SfInnerList, b: SfInnerList): boolean { + if (a.items.length !== b.items.length) return false; + for (let i = 0; i < a.items.length; i++) { + if (!itemsEqual(a.items[i]!, b.items[i]!)) return false; + } + return paramsEqual(a.parameters, b.parameters); +} + +// Compare list members for equality +function listMembersEqual( + a: SfItem | SfInnerList, + b: SfItem | SfInnerList, +): boolean { + const aIsInner = "items" in a; + const bIsInner = "items" in b; + if (aIsInner !== bIsInner) return false; + if (aIsInner && bIsInner) { + return innerListsEqual(a as SfInnerList, b as SfInnerList); + } + return itemsEqual(a as SfItem, b as SfItem); +} + +// Interface for test case from JSON files +interface ConformanceTestCase { + name: string; + raw: string[]; + // deno-lint-ignore camelcase + header_type?: "item" | "list" | "dictionary"; + // deno-lint-ignore no-explicit-any + expected?: any; + canonical?: string[]; + // deno-lint-ignore camelcase + must_fail?: boolean; + // deno-lint-ignore camelcase + can_fail?: boolean; +} + +// Load and run tests from a JSON file +async function runConformanceTests( + t: Deno.TestContext, + filename: string, +): Promise { + const testData = await import(`./testdata/structured_fields/${filename}`, { + with: { type: "json" }, + }); + const tests: ConformanceTestCase[] = testData.default; + + for (const test of tests) { + // Skip tests marked can_fail (implementation-dependent) + if (test.can_fail) continue; + + const rawInput = test.raw.join(", "); + const headerType = test.header_type ?? "item"; + + await t.step(test.name, () => { + if (test.must_fail) { + // Test should fail parsing + assertThrows( + () => { + switch (headerType) { + case "item": + parseItem(rawInput); + break; + case "list": + parseList(rawInput); + break; + case "dictionary": + parseDictionary(rawInput); + break; + } + }, + Error, + ); + } else { + // Test should succeed + switch (headerType) { + case "item": { + const parsed = parseItem(rawInput); + const expected = convertExpectedItem(test.expected); + assertEquals( + itemsEqual(parsed, expected), + true, + `Mismatch for "${test.name}": got ${ + JSON.stringify(parsed) + }, expected ${JSON.stringify(expected)}`, + ); + // Test canonical serialization if provided + if (test.canonical) { + const serialized = serializeItem(parsed); + assertEquals(serialized, test.canonical[0]); + } + break; + } + case "list": { + const parsed = parseList(rawInput); + const expected: SfList = test.expected.map( + convertExpectedListMember, + ); + assertEquals(parsed.length, expected.length); + for (let i = 0; i < parsed.length; i++) { + assertEquals( + listMembersEqual(parsed[i]!, expected[i]!), + true, + `List member ${i} mismatch for "${test.name}"`, + ); + } + // Test canonical serialization if provided + if (test.canonical) { + const serialized = serializeList(parsed); + assertEquals( + serialized, + test.canonical[0] ?? test.canonical.join(", "), + ); + } + break; + } + case "dictionary": { + const parsed = parseDictionary(rawInput); + // Convert expected dictionary format [[key, value], ...] + const expectedEntries: Array<[string, SfItem | SfInnerList]> = + // deno-lint-ignore no-explicit-any + (test.expected as any[]).map( + ([key, value]: [string, unknown]) => [ + key, + convertExpectedListMember(value as unknown[]), + ], + ); + assertEquals(parsed.size, expectedEntries.length); + for (const [key, expectedValue] of expectedEntries) { + const parsedValue = parsed.get(key); + assertEquals( + parsedValue !== undefined, + true, + `Missing key "${key}" in parsed dictionary`, + ); + assertEquals( + listMembersEqual(parsedValue!, expectedValue), + true, + `Dictionary value mismatch for key "${key}" in "${test.name}"`, + ); + } + // Test canonical serialization if provided + if (test.canonical) { + const serialized = serializeDictionary(parsed); + assertEquals( + serialized, + test.canonical[0] ?? test.canonical.join(", "), + ); + } + break; + } + } + } + }); + } +} + +// Run all conformance test files +Deno.test({ + name: "HTTPWG Conformance Tests: binary.json", + async fn(t) { + await runConformanceTests(t, "binary.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: boolean.json", + async fn(t) { + await runConformanceTests(t, "boolean.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: date.json", + async fn(t) { + await runConformanceTests(t, "date.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: dictionary.json", + async fn(t) { + await runConformanceTests(t, "dictionary.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: display-string.json", + async fn(t) { + await runConformanceTests(t, "display-string.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: examples.json", + async fn(t) { + await runConformanceTests(t, "examples.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: item.json", + async fn(t) { + await runConformanceTests(t, "item.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: list.json", + async fn(t) { + await runConformanceTests(t, "list.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: number.json", + async fn(t) { + await runConformanceTests(t, "number.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: param-dict.json", + async fn(t) { + await runConformanceTests(t, "param-dict.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: param-list.json", + async fn(t) { + await runConformanceTests(t, "param-list.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: param-listlist.json", + async fn(t) { + await runConformanceTests(t, "param-listlist.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: string.json", + async fn(t) { + await runConformanceTests(t, "string.json"); + }, +}); + +Deno.test({ + name: "HTTPWG Conformance Tests: token.json", + async fn(t) { + await runConformanceTests(t, "token.json"); + }, +}); From 4fe2894b27d30a9376d1a82d166a3a6f07362649 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 20 Jan 2026 12:18:20 +0100 Subject: [PATCH 03/11] refactor --- http/deno.json | 2 +- http/unstable_structured_fields.ts | 55 ++++++++++++++----------- http/unstable_structured_fields_test.ts | 13 ++++++ 3 files changed, 46 insertions(+), 24 deletions(-) diff --git a/http/deno.json b/http/deno.json index 77930809ed44..a5e6acdb4d92 100644 --- a/http/deno.json +++ b/http/deno.json @@ -17,4 +17,4 @@ "./user-agent": "./user_agent.ts", "./unstable-route": "./unstable_route.ts" } -} \ No newline at end of file +} diff --git a/http/unstable_structured_fields.ts b/http/unstable_structured_fields.ts index a24939048aa8..e3c1bb889d77 100644 --- a/http/unstable_structured_fields.ts +++ b/http/unstable_structured_fields.ts @@ -45,7 +45,7 @@ import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; -const UTF8_DECODER = /*@__PURE__*/ new TextDecoder("utf-8", { fatal: true }); +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); // ============================================================================= // Type Definitions (RFC 9651 Section 3) @@ -377,6 +377,7 @@ export function isInnerList( * * @experimental **UNSTABLE**: New API, yet to be vetted. * + * @typeParam T The bare item type to check for. * @param item The bare item to check. * @param type The type to check for. * @returns `true` if the item is of the specified type. @@ -417,6 +418,13 @@ const CHAR_CODE_UPPER_Z = 90; // 'Z' const CHAR_CODE_LOWER_A = 97; // 'a' const CHAR_CODE_LOWER_Z = 122; // 'z' +// RFC 9651 numeric limits +const MAX_INTEGER_DIGITS = 15; +const MAX_INTEGER = 999_999_999_999_999; +const MAX_DECIMAL_INTEGER_DIGITS = 12; +const MAX_DECIMAL_FRACTIONAL_DIGITS = 3; +const MAX_DECIMAL_INTEGER_PART = 999_999_999_999; + /** Check if character is alphabetic (A-Z or a-z) */ function isAlpha(c: string): boolean { const code = c.charCodeAt(0); @@ -450,7 +458,7 @@ function isKeyChar(c: string): boolean { // Pre-computed lookup table for tchar (RFC 9110 token characters) // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / // "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA -const TCHAR_LOOKUP: boolean[] = /*@__PURE__*/ (() => { +const TCHAR_LOOKUP: boolean[] = (() => { const table: boolean[] = new Array(128).fill(false); const tchars = "!#$%&'*+-.^_`|~0123456789" + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; @@ -461,7 +469,7 @@ const TCHAR_LOOKUP: boolean[] = /*@__PURE__*/ (() => { })(); // Pre-computed lookup table for base64 characters (A-Z, a-z, 0-9, +, /, =) -const BASE64_LOOKUP: boolean[] = /*@__PURE__*/ (() => { +const BASE64_LOOKUP: boolean[] = (() => { const table: boolean[] = new Array(128).fill(false); for (let i = CHAR_CODE_UPPER_A; i <= CHAR_CODE_UPPER_Z; i++) table[i] = true; for (let i = CHAR_CODE_LOWER_A; i <= CHAR_CODE_LOWER_Z; i++) table[i] = true; @@ -778,7 +786,7 @@ function parseIntegerOrDecimal(state: ParserState): SfBareItem { let integerPart = ""; while (isDigit(peek(state))) { integerPart += consume(state); - if (integerPart.length > 15) { + if (integerPart.length > MAX_INTEGER_DIGITS) { throw new SyntaxError( "Invalid structured field: integer too long", ); @@ -787,7 +795,7 @@ function parseIntegerOrDecimal(state: ParserState): SfBareItem { if (peek(state) === ".") { consume(state); // consume '.' - if (integerPart.length > 12) { + if (integerPart.length > MAX_DECIMAL_INTEGER_DIGITS) { throw new SyntaxError( "Invalid structured field: decimal integer part too long", ); @@ -796,7 +804,7 @@ function parseIntegerOrDecimal(state: ParserState): SfBareItem { let fractionalPart = ""; while (isDigit(peek(state))) { fractionalPart += consume(state); - if (fractionalPart.length > 3) { + if (fractionalPart.length > MAX_DECIMAL_FRACTIONAL_DIGITS) { throw new SyntaxError( "Invalid structured field: decimal fractional part too long", ); @@ -815,8 +823,7 @@ function parseIntegerOrDecimal(state: ParserState): SfBareItem { const value = sign * parseInt(integerPart, 10); - // Check range: -999999999999999 to 999999999999999 - if (value < -999_999_999_999_999 || value > 999_999_999_999_999) { + if (value < -MAX_INTEGER || value > MAX_INTEGER) { throw new SyntaxError( "Invalid structured field: integer out of range", ); @@ -907,17 +914,18 @@ function parseToken(state: ParserState): SfBareItem { ); } - let value = consume(state); + const startPos = state.pos; + state.pos++; // Skip validated first char while (!isEof(state)) { const c = peek(state); if (isTchar(c) || c === ":" || c === "/") { - value += consume(state); + state.pos++; } else { break; } } - return { type: "token", value }; + return { type: "token", value: state.input.slice(startPos, state.pos) }; } function parseBinary(state: ParserState): SfBareItem { @@ -1065,19 +1073,19 @@ function parseDisplayString(state: ParserState): SfBareItem { } function parseKey(state: ParserState): string { - const first = peek(state); - if (!isKeyStart(first)) { + if (!isKeyStart(peek(state))) { throw new SyntaxError( `Invalid structured field: invalid key start at position ${state.pos}`, ); } - let key = consume(state); + const startPos = state.pos; + state.pos++; // Skip validated first char while (!isEof(state) && isKeyChar(peek(state))) { - key += consume(state); + state.pos++; } - return key; + return state.input.slice(startPos, state.pos); } function parseParameters(state: ParserState): SfParameters { @@ -1249,7 +1257,7 @@ function serializeInteger(value: number): string { if (!Number.isInteger(value)) { throw new TypeError("Integer must be a whole number"); } - if (value < -999_999_999_999_999 || value > 999_999_999_999_999) { + if (value < -MAX_INTEGER || value > MAX_INTEGER) { throw new TypeError("Integer out of range"); } return String(value); @@ -1260,17 +1268,18 @@ function serializeDecimal(value: number): string { throw new TypeError("Decimal must be finite"); } - // Round to 3 decimal places - const rounded = Math.round(value * 1000) / 1000; + // Round to MAX_DECIMAL_FRACTIONAL_DIGITS decimal places + const scale = 10 ** MAX_DECIMAL_FRACTIONAL_DIGITS; + const rounded = Math.round(value * scale) / scale; - // Check integer part (max 12 digits) + // Check integer part (max MAX_DECIMAL_INTEGER_DIGITS digits) const intPart = Math.trunc(Math.abs(rounded)); - if (intPart > 999_999_999_999) { + if (intPart > MAX_DECIMAL_INTEGER_PART) { throw new TypeError("Decimal integer part too large"); } - // Format with 3 fractional digits - const str = rounded.toFixed(3); + // Format with MAX_DECIMAL_FRACTIONAL_DIGITS fractional digits + const str = rounded.toFixed(MAX_DECIMAL_FRACTIONAL_DIGITS); // Remove trailing zeros but keep at least one digit after decimal let end = str.length; diff --git a/http/unstable_structured_fields_test.ts b/http/unstable_structured_fields_test.ts index cc8218c56579..040280debec4 100644 --- a/http/unstable_structured_fields_test.ts +++ b/http/unstable_structured_fields_test.ts @@ -167,6 +167,19 @@ Deno.test({ }, }); +Deno.test({ + name: "parseItem() rejects invalid char in string after escape", + fn() { + // String with escape sequence followed by invalid character (NUL) + // This tests the slow path in parseString (after processing escapes) + assertThrows( + () => parseItem('"test\\\\\x00"'), + SyntaxError, + "invalid character", + ); + }, +}); + Deno.test({ name: "parseItem() rejects invalid key start in parameters", fn() { From 1fb31c729a8745bb1d8d92988fd1a6f4a5dacde6 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 20 Jan 2026 12:35:03 +0100 Subject: [PATCH 04/11] fix jsdoc --- http/unstable_structured_fields.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/http/unstable_structured_fields.ts b/http/unstable_structured_fields.ts index e3c1bb889d77..52a166ce7ee4 100644 --- a/http/unstable_structured_fields.ts +++ b/http/unstable_structured_fields.ts @@ -11,16 +11,19 @@ * * @example Parsing a Dictionary (e.g., UCP-Agent header) * ```ts - * import { parseDictionary } from "@std/http/unstable-structured-fields"; + * import { isItem, parseDictionary } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * * const header = 'profile="https://example.com/profile.json"'; * const dict = parseDictionary(header); + * const profile = dict.get("profile"); * - * assertEquals(dict.get("profile")?.value, { - * type: "string", - * value: "https://example.com/profile.json", - * }); + * if (profile && isItem(profile)) { + * assertEquals(profile.value, { + * type: "string", + * value: "https://example.com/profile.json", + * }); + * } * ``` * * @example Serializing a Dictionary From 7fd2ab359ef43a29470949a93cbe4594442e14d0 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Thu, 12 Feb 2026 12:42:07 +0100 Subject: [PATCH 05/11] drop sf prefix --- http/unstable_structured_fields.ts | 176 +++++++++--------- http/unstable_structured_fields_test.ts | 230 ++++++++++++------------ 2 files changed, 203 insertions(+), 203 deletions(-) diff --git a/http/unstable_structured_fields.ts b/http/unstable_structured_fields.ts index 52a166ce7ee4..940d412a6195 100644 --- a/http/unstable_structured_fields.ts +++ b/http/unstable_structured_fields.ts @@ -28,11 +28,11 @@ * * @example Serializing a Dictionary * ```ts - * import { serializeDictionary, sfString } from "@std/http/unstable-structured-fields"; + * import { serializeDictionary, string } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * * const dict = new Map([ - * ["profile", { value: sfString("https://example.com/profile.json"), parameters: new Map() }], + * ["profile", { value: string("https://example.com/profile.json"), parameters: new Map() }], * ]); * * assertEquals( @@ -61,7 +61,7 @@ const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); * * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.3} */ -export type SfBareItem = +export type BareItem = | { type: "integer"; value: number } | { type: "decimal"; value: number } | { type: "string"; value: string } @@ -81,7 +81,7 @@ export type SfBareItem = * * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.1.2} */ -export type SfParameters = ReadonlyMap; +export type Parameters = ReadonlyMap; /** * An Item in a Structured Field, consisting of a Bare Item and Parameters. @@ -90,11 +90,11 @@ export type SfParameters = ReadonlyMap; * * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.3} */ -export interface SfItem { +export interface Item { /** The bare item value. */ - value: SfBareItem; + value: BareItem; /** Parameters associated with this item. */ - parameters: SfParameters; + parameters: Parameters; } /** @@ -104,11 +104,11 @@ export interface SfItem { * * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.1.1} */ -export interface SfInnerList { +export interface InnerList { /** The items in the inner list. */ - items: SfItem[]; + items: Item[]; /** Parameters associated with the inner list. */ - parameters: SfParameters; + parameters: Parameters; } /** @@ -118,7 +118,7 @@ export interface SfInnerList { * * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.1} */ -export type SfList = Array; +export type List = Array; /** * A Dictionary Structured Field value. @@ -130,14 +130,14 @@ export type SfList = Array; * * @see {@link https://www.rfc-editor.org/rfc/rfc9651#section-3.2} */ -export type SfDictionary = ReadonlyMap; +export type Dictionary = ReadonlyMap; // ============================================================================= // Convenience Builders // ============================================================================= /** An integer Bare Item. */ -export type SfIntegerItem = Extract; +export type IntegerItem = Extract; /** * Creates an integer Bare Item. @@ -149,18 +149,18 @@ export type SfIntegerItem = Extract; * * @example Usage * ```ts - * import { sfInteger } from "@std/http/unstable-structured-fields"; + * import { integer } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * - * assertEquals(sfInteger(42), { type: "integer", value: 42 }); + * assertEquals(integer(42), { type: "integer", value: 42 }); * ``` */ -export function sfInteger(value: number): SfIntegerItem { +export function integer(value: number): IntegerItem { return { type: "integer", value }; } /** A decimal Bare Item. */ -export type SfDecimalItem = Extract; +export type DecimalItem = Extract; /** * Creates a decimal Bare Item. @@ -172,18 +172,18 @@ export type SfDecimalItem = Extract; * * @example Usage * ```ts - * import { sfDecimal } from "@std/http/unstable-structured-fields"; + * import { decimal } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * - * assertEquals(sfDecimal(3.14), { type: "decimal", value: 3.14 }); + * assertEquals(decimal(3.14), { type: "decimal", value: 3.14 }); * ``` */ -export function sfDecimal(value: number): SfDecimalItem { +export function decimal(value: number): DecimalItem { return { type: "decimal", value }; } /** A string Bare Item. */ -export type SfStringItem = Extract; +export type StringItem = Extract; /** * Creates a string Bare Item. @@ -195,18 +195,18 @@ export type SfStringItem = Extract; * * @example Usage * ```ts - * import { sfString } from "@std/http/unstable-structured-fields"; + * import { string } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * - * assertEquals(sfString("hello"), { type: "string", value: "hello" }); + * assertEquals(string("hello"), { type: "string", value: "hello" }); * ``` */ -export function sfString(value: string): SfStringItem { +export function string(value: string): StringItem { return { type: "string", value }; } /** A token Bare Item. */ -export type SfTokenItem = Extract; +export type TokenItem = Extract; /** * Creates a token Bare Item. @@ -218,18 +218,18 @@ export type SfTokenItem = Extract; * * @example Usage * ```ts - * import { sfToken } from "@std/http/unstable-structured-fields"; + * import { token } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * - * assertEquals(sfToken("foo"), { type: "token", value: "foo" }); + * assertEquals(token("foo"), { type: "token", value: "foo" }); * ``` */ -export function sfToken(value: string): SfTokenItem { +export function token(value: string): TokenItem { return { type: "token", value }; } /** A binary Bare Item. */ -export type SfBinaryItem = Extract; +export type BinaryItem = Extract; /** * Creates a binary Bare Item. @@ -241,19 +241,19 @@ export type SfBinaryItem = Extract; * * @example Usage * ```ts - * import { sfBinary } from "@std/http/unstable-structured-fields"; + * import { binary } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * - * const result = sfBinary(new Uint8Array([1, 2, 3])); + * const result = binary(new Uint8Array([1, 2, 3])); * assertEquals(result.type, "binary"); * ``` */ -export function sfBinary(value: Uint8Array): SfBinaryItem { +export function binary(value: Uint8Array): BinaryItem { return { type: "binary", value }; } /** A boolean Bare Item. */ -export type SfBooleanItem = Extract; +export type BooleanItem = Extract; /** * Creates a boolean Bare Item. @@ -265,18 +265,18 @@ export type SfBooleanItem = Extract; * * @example Usage * ```ts - * import { sfBoolean } from "@std/http/unstable-structured-fields"; + * import { boolean } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * - * assertEquals(sfBoolean(true), { type: "boolean", value: true }); + * assertEquals(boolean(true), { type: "boolean", value: true }); * ``` */ -export function sfBoolean(value: boolean): SfBooleanItem { +export function boolean(value: boolean): BooleanItem { return { type: "boolean", value }; } /** A date Bare Item. */ -export type SfDateItem = Extract; +export type DateItem = Extract; /** * Creates a date Bare Item. @@ -288,20 +288,20 @@ export type SfDateItem = Extract; * * @example Usage * ```ts - * import { sfDate } from "@std/http/unstable-structured-fields"; + * import { date } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * * const d = new Date("2022-08-04T00:00:00Z"); - * assertEquals(sfDate(d), { type: "date", value: d }); + * assertEquals(date(d), { type: "date", value: d }); * ``` */ -export function sfDate(value: Date): SfDateItem { +export function date(value: Date): DateItem { return { type: "date", value }; } /** A display string Bare Item. */ -export type SfDisplayStringItem = Extract< - SfBareItem, +export type DisplayStringItem = Extract< + BareItem, { type: "displaystring" } >; @@ -315,13 +315,13 @@ export type SfDisplayStringItem = Extract< * * @example Usage * ```ts - * import { sfDisplayString } from "@std/http/unstable-structured-fields"; + * import { displayString } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * - * assertEquals(sfDisplayString("héllo"), { type: "displaystring", value: "héllo" }); + * assertEquals(displayString("héllo"), { type: "displaystring", value: "héllo" }); * ``` */ -export function sfDisplayString(value: string): SfDisplayStringItem { +export function displayString(value: string): DisplayStringItem { return { type: "displaystring", value }; } @@ -347,7 +347,7 @@ export function sfDisplayString(value: string): SfDisplayStringItem { * assert(!isItem(list[1]!)); // false - inner list * ``` */ -export function isItem(member: SfItem | SfInnerList): member is SfItem { +export function isItem(member: Item | InnerList): member is Item { return !("items" in member); } @@ -370,8 +370,8 @@ export function isItem(member: SfItem | SfInnerList): member is SfItem { * ``` */ export function isInnerList( - member: SfItem | SfInnerList, -): member is SfInnerList { + member: Item | InnerList, +): member is InnerList { return "items" in member; } @@ -396,10 +396,10 @@ export function isInnerList( * } * ``` */ -export function isBareItemType( - item: SfBareItem, +export function isBareItemType( + item: BareItem, type: T, -): item is Extract { +): item is Extract { return item.type === type; } @@ -551,7 +551,7 @@ function skipSP(state: ParserState): void { * assertEquals(list.length, 2); * ``` */ -export function parseList(input: string): SfList { +export function parseList(input: string): List { const state: ParserState = { input, pos: 0 }; skipSP(state); const list = parseListInternal(state); @@ -564,8 +564,8 @@ export function parseList(input: string): SfList { return list; } -function parseListInternal(state: ParserState): SfList { - const members: SfList = []; +function parseListInternal(state: ParserState): List { + const members: List = []; while (!isEof(state)) { const member = parseItemOrInnerList(state); @@ -611,7 +611,7 @@ function parseListInternal(state: ParserState): SfList { * assertEquals(dict.has("profile"), true); * ``` */ -export function parseDictionary(input: string): SfDictionary { +export function parseDictionary(input: string): Dictionary { const state: ParserState = { input, pos: 0 }; skipSP(state); const dict = parseDictionaryInternal(state); @@ -624,12 +624,12 @@ export function parseDictionary(input: string): SfDictionary { return dict; } -function parseDictionaryInternal(state: ParserState): SfDictionary { - const dict: Map = new Map(); +function parseDictionaryInternal(state: ParserState): Dictionary { + const dict: Map = new Map(); while (!isEof(state)) { const key = parseKey(state); - let member: SfItem | SfInnerList; + let member: Item | InnerList; if (peek(state) === "=") { consume(state); // consume '=' @@ -685,7 +685,7 @@ function parseDictionaryInternal(state: ParserState): SfDictionary { * assertEquals(item.value, { type: "integer", value: 42 }); * ``` */ -export function parseItem(input: string): SfItem { +export function parseItem(input: string): Item { const state: ParserState = { input, pos: 0 }; skipSP(state); const item = parseItemInternal(state); @@ -698,21 +698,21 @@ export function parseItem(input: string): SfItem { return item; } -function parseItemOrInnerList(state: ParserState): SfItem | SfInnerList { +function parseItemOrInnerList(state: ParserState): Item | InnerList { if (peek(state) === "(") { return parseInnerList(state); } return parseItemInternal(state); } -function parseInnerList(state: ParserState): SfInnerList { +function parseInnerList(state: ParserState): InnerList { if (consume(state) !== "(") { throw new SyntaxError( `Invalid structured field: expected '(' at position ${state.pos - 1}`, ); } - const items: SfItem[] = []; + const items: Item[] = []; while (!isEof(state)) { skipSP(state); @@ -737,13 +737,13 @@ function parseInnerList(state: ParserState): SfInnerList { ); } -function parseItemInternal(state: ParserState): SfItem { +function parseItemInternal(state: ParserState): Item { const value = parseBareItem(state); const parameters = parseParameters(state); return { value, parameters }; } -function parseBareItem(state: ParserState): SfBareItem { +function parseBareItem(state: ParserState): BareItem { const c = peek(state); if (c === "-" || isDigit(c)) { @@ -773,7 +773,7 @@ function parseBareItem(state: ParserState): SfBareItem { ); } -function parseIntegerOrDecimal(state: ParserState): SfBareItem { +function parseIntegerOrDecimal(state: ParserState): BareItem { let sign = 1; if (peek(state) === "-") { consume(state); @@ -835,7 +835,7 @@ function parseIntegerOrDecimal(state: ParserState): SfBareItem { return { type: "integer", value }; } -function parseString(state: ParserState): SfBareItem { +function parseString(state: ParserState): BareItem { if (consume(state) !== '"') { throw new SyntaxError( `Invalid structured field: expected '"' at position ${state.pos - 1}`, @@ -909,7 +909,7 @@ function parseString(state: ParserState): SfBareItem { ); } -function parseToken(state: ParserState): SfBareItem { +function parseToken(state: ParserState): BareItem { const first = peek(state); if (!isAlpha(first) && first !== "*") { throw new SyntaxError( @@ -931,7 +931,7 @@ function parseToken(state: ParserState): SfBareItem { return { type: "token", value: state.input.slice(startPos, state.pos) }; } -function parseBinary(state: ParserState): SfBareItem { +function parseBinary(state: ParserState): BareItem { if (consume(state) !== ":") { throw new SyntaxError( `Invalid structured field: expected ':' at position ${state.pos - 1}`, @@ -970,7 +970,7 @@ function parseBinary(state: ParserState): SfBareItem { } } -function parseBoolean(state: ParserState): SfBareItem { +function parseBoolean(state: ParserState): BareItem { if (consume(state) !== "?") { throw new SyntaxError( `Invalid structured field: expected '?' at position ${state.pos - 1}`, @@ -992,7 +992,7 @@ function parseBoolean(state: ParserState): SfBareItem { ); } -function parseDate(state: ParserState): SfBareItem { +function parseDate(state: ParserState): BareItem { if (consume(state) !== "@") { throw new SyntaxError( `Invalid structured field: expected '@' at position ${state.pos - 1}`, @@ -1010,7 +1010,7 @@ function parseDate(state: ParserState): SfBareItem { return { type: "date", value }; } -function parseDisplayString(state: ParserState): SfBareItem { +function parseDisplayString(state: ParserState): BareItem { if (consume(state) !== "%") { throw new SyntaxError( `Invalid structured field: expected '%' at position ${state.pos - 1}`, @@ -1091,15 +1091,15 @@ function parseKey(state: ParserState): string { return state.input.slice(startPos, state.pos); } -function parseParameters(state: ParserState): SfParameters { - const parameters: Map = new Map(); +function parseParameters(state: ParserState): Parameters { + const parameters: Map = new Map(); while (peek(state) === ";") { consume(state); // consume ';' skipSP(state); const key = parseKey(state); - let value: SfBareItem; + let value: BareItem; if (peek(state) === "=") { consume(state); // consume '=' value = parseBareItem(state); @@ -1130,18 +1130,18 @@ function parseParameters(state: ParserState): SfParameters { * * @example Usage * ```ts - * import { serializeList, sfInteger } from "@std/http/unstable-structured-fields"; + * import { serializeList, integer } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * * const list = [ - * { value: sfInteger(1), parameters: new Map() }, - * { value: sfInteger(42), parameters: new Map() }, + * { value: integer(1), parameters: new Map() }, + * { value: integer(42), parameters: new Map() }, * ]; * * assertEquals(serializeList(list), "1, 42"); * ``` */ -export function serializeList(list: SfList): string { +export function serializeList(list: List): string { const parts: string[] = []; for (const member of list) { @@ -1168,17 +1168,17 @@ export function serializeList(list: SfList): string { * * @example Usage * ```ts - * import { serializeDictionary, sfString } from "@std/http/unstable-structured-fields"; + * import { serializeDictionary, string } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * * const dict = new Map([ - * ["key", { value: sfString("value"), parameters: new Map() }], + * ["key", { value: string("value"), parameters: new Map() }], * ]); * * assertEquals(serializeDictionary(dict), 'key="value"'); * ``` */ -export function serializeDictionary(dict: SfDictionary): string { +export function serializeDictionary(dict: Dictionary): string { const parts: string[] = []; for (const [key, member] of dict) { @@ -1214,28 +1214,28 @@ export function serializeDictionary(dict: SfDictionary): string { * * @example Usage * ```ts - * import { serializeItem, sfInteger } from "@std/http/unstable-structured-fields"; + * import { serializeItem, integer } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * - * const item = { value: sfInteger(42), parameters: new Map() }; + * const item = { value: integer(42), parameters: new Map() }; * * assertEquals(serializeItem(item), "42"); * ``` */ -export function serializeItem(item: SfItem): string { +export function serializeItem(item: Item): string { return serializeItemInternal(item); } -function serializeInnerList(innerList: SfInnerList): string { +function serializeInnerList(innerList: InnerList): string { const items = innerList.items.map(serializeItemInternal).join(" "); return `(${items})${serializeParameters(innerList.parameters)}`; } -function serializeItemInternal(item: SfItem): string { +function serializeItemInternal(item: Item): string { return serializeBareItem(item.value) + serializeParameters(item.parameters); } -function serializeBareItem(bareItem: SfBareItem): string { +function serializeBareItem(bareItem: BareItem): string { switch (bareItem.type) { case "integer": return serializeInteger(bareItem.value); @@ -1382,7 +1382,7 @@ function serializeDisplayString(value: string): string { return result; } -function serializeParameters(parameters: SfParameters): string { +function serializeParameters(parameters: Parameters): string { let result = ""; for (const [key, value] of parameters) { diff --git a/http/unstable_structured_fields_test.ts b/http/unstable_structured_fields_test.ts index 040280debec4..e6c063284eec 100644 --- a/http/unstable_structured_fields_test.ts +++ b/http/unstable_structured_fields_test.ts @@ -11,19 +11,19 @@ import { serializeDictionary, serializeItem, serializeList, - type SfBareItem, - sfBinary, - sfBoolean, - sfDate, - sfDecimal, - type SfDictionary, - sfDisplayString, - type SfInnerList, - sfInteger, - type SfItem, - type SfList, - sfString, - sfToken, + type BareItem, + binary, + boolean, + date, + decimal, + type Dictionary, + displayString, + type InnerList, + integer, + type Item, + type List, + string, + token, } from "./unstable_structured_fields.ts"; // ============================================================================= @@ -108,7 +108,7 @@ Deno.test({ fn() { const dict = parseDictionary("*key=1"); assertEquals(dict.has("*key"), true); - assertEquals((dict.get("*key") as SfItem).value, { + assertEquals((dict.get("*key") as Item).value, { type: "integer", value: 1, }); @@ -215,9 +215,9 @@ Deno.test({ Deno.test({ name: "serializeList() serializes basic list", fn() { - const list: SfList = [ - { value: sfInteger(1), parameters: new Map() }, - { value: sfInteger(42), parameters: new Map() }, + const list: List = [ + { value: integer(1), parameters: new Map() }, + { value: integer(42), parameters: new Map() }, ]; assertEquals(serializeList(list), "1, 42"); }, @@ -233,14 +233,14 @@ Deno.test({ Deno.test({ name: "serializeList() serializes inner list", fn() { - const innerList: SfInnerList = { + const innerList: InnerList = { items: [ - { value: sfInteger(1), parameters: new Map() }, - { value: sfInteger(2), parameters: new Map() }, + { value: integer(1), parameters: new Map() }, + { value: integer(2), parameters: new Map() }, ], parameters: new Map(), }; - const list: SfList = [innerList]; + const list: List = [innerList]; assertEquals(serializeList(list), "(1 2)"); }, }); @@ -248,12 +248,12 @@ Deno.test({ Deno.test({ name: "serializeList() serializes inner list with parameters", fn() { - const innerList: SfInnerList = { + const innerList: InnerList = { items: [ - { value: sfInteger(1), parameters: new Map() }, + { value: integer(1), parameters: new Map() }, ], - parameters: new Map([ - ["param", sfToken("value")], + parameters: new Map([ + ["param", token("value")], ]), }; assertEquals(serializeList([innerList]), "(1);param=value"); @@ -263,9 +263,9 @@ Deno.test({ Deno.test({ name: "serializeDictionary() serializes basic dictionary", fn() { - const dict: SfDictionary = new Map([ - ["a", { value: sfInteger(1), parameters: new Map() }], - ["b", { value: sfInteger(2), parameters: new Map() }], + const dict: Dictionary = new Map([ + ["a", { value: integer(1), parameters: new Map() }], + ["b", { value: integer(2), parameters: new Map() }], ]); assertEquals(serializeDictionary(dict), "a=1, b=2"); }, @@ -274,8 +274,8 @@ Deno.test({ Deno.test({ name: "serializeDictionary() omits =?1 for true boolean", fn() { - const dict: SfDictionary = new Map([ - ["a", { value: sfBoolean(true), parameters: new Map() }], + const dict: Dictionary = new Map([ + ["a", { value: boolean(true), parameters: new Map() }], ]); assertEquals(serializeDictionary(dict), "a"); }, @@ -284,8 +284,8 @@ Deno.test({ Deno.test({ name: "serializeDictionary() includes =?0 for false boolean", fn() { - const dict: SfDictionary = new Map([ - ["a", { value: sfBoolean(false), parameters: new Map() }], + const dict: Dictionary = new Map([ + ["a", { value: boolean(false), parameters: new Map() }], ]); assertEquals(serializeDictionary(dict), "a=?0"); }, @@ -294,8 +294,8 @@ Deno.test({ Deno.test({ name: "serializeDictionary() handles key starting with *", fn() { - const dict: SfDictionary = new Map([ - ["*key", { value: sfInteger(1), parameters: new Map() }], + const dict: Dictionary = new Map([ + ["*key", { value: integer(1), parameters: new Map() }], ]); assertEquals(serializeDictionary(dict), "*key=1"); }, @@ -304,14 +304,14 @@ Deno.test({ Deno.test({ name: "serializeDictionary() serializes inner list value", fn() { - const innerList: SfInnerList = { + const innerList: InnerList = { items: [ - { value: sfInteger(1), parameters: new Map() }, - { value: sfInteger(2), parameters: new Map() }, + { value: integer(1), parameters: new Map() }, + { value: integer(2), parameters: new Map() }, ], parameters: new Map(), }; - const dict: SfDictionary = new Map([ + const dict: Dictionary = new Map([ ["a", innerList], ]); assertEquals(serializeDictionary(dict), "a=(1 2)"); @@ -322,31 +322,31 @@ Deno.test({ name: "serializeItem() covers all bare item types", fn() { assertEquals( - serializeItem({ value: sfToken("foo"), parameters: new Map() }), + serializeItem({ value: token("foo"), parameters: new Map() }), "foo", ); assertEquals( serializeItem({ - value: sfBinary(new Uint8Array([1, 2, 3])), + value: binary(new Uint8Array([1, 2, 3])), parameters: new Map(), }), ":AQID:", ); assertEquals( - serializeItem({ value: sfBoolean(true), parameters: new Map() }), + serializeItem({ value: boolean(true), parameters: new Map() }), "?1", ); assertEquals( - serializeItem({ value: sfBoolean(false), parameters: new Map() }), + serializeItem({ value: boolean(false), parameters: new Map() }), "?0", ); const d = new Date(1659578233000); assertEquals( - serializeItem({ value: sfDate(d), parameters: new Map() }), + serializeItem({ value: date(d), parameters: new Map() }), "@1659578233", ); assertEquals( - serializeItem({ value: sfDisplayString("héllo"), parameters: new Map() }), + serializeItem({ value: displayString("héllo"), parameters: new Map() }), '%"h%c3%a9llo"', ); }, @@ -357,13 +357,13 @@ Deno.test({ fn() { assertEquals( serializeItem({ - value: sfString('hello "world"'), + value: string('hello "world"'), parameters: new Map(), }), '"hello \\"world\\""', ); assertEquals( - serializeItem({ value: sfString("hello\\world"), parameters: new Map() }), + serializeItem({ value: string("hello\\world"), parameters: new Map() }), '"hello\\\\world"', ); }, @@ -372,12 +372,12 @@ Deno.test({ Deno.test({ name: "serializeItem() serializes item with parameters", fn() { - const params = new Map([ - ["a", sfInteger(1)], - ["b", sfBoolean(true)], + const params = new Map([ + ["a", integer(1)], + ["b", boolean(true)], ]); assertEquals( - serializeItem({ value: sfToken("foo"), parameters: params }), + serializeItem({ value: token("foo"), parameters: params }), "foo;a=1;b", ); }, @@ -388,14 +388,14 @@ Deno.test({ fn() { assertEquals( serializeItem({ - value: sfInteger(-999_999_999_999_999), + value: integer(-999_999_999_999_999), parameters: new Map(), }), "-999999999999999", ); assertEquals( serializeItem({ - value: sfInteger(999_999_999_999_999), + value: integer(999_999_999_999_999), parameters: new Map(), }), "999999999999999", @@ -407,11 +407,11 @@ Deno.test({ name: "serializeItem() handles decimal edge cases", fn() { assertEquals( - serializeItem({ value: sfDecimal(1.0), parameters: new Map() }), + serializeItem({ value: decimal(1.0), parameters: new Map() }), "1.0", ); assertEquals( - serializeItem({ value: sfDecimal(-3.14), parameters: new Map() }), + serializeItem({ value: decimal(-3.14), parameters: new Map() }), "-3.14", ); }, @@ -421,7 +421,7 @@ Deno.test({ name: "serializeItem() handles token starting with *", fn() { assertEquals( - serializeItem({ value: sfToken("*foo"), parameters: new Map() }), + serializeItem({ value: token("*foo"), parameters: new Map() }), "*foo", ); }, @@ -432,7 +432,7 @@ Deno.test({ fn() { assertEquals( serializeItem({ - value: sfDisplayString('hello % and "'), + value: displayString('hello % and "'), parameters: new Map(), }), '%"hello %25 and %22"', @@ -448,12 +448,12 @@ Deno.test({ name: "serializeItem() rejects out-of-range integer", fn() { assertThrows( - () => serializeItem({ value: sfInteger(1e16), parameters: new Map() }), + () => serializeItem({ value: integer(1e16), parameters: new Map() }), TypeError, "out of range", ); assertThrows( - () => serializeItem({ value: sfInteger(-1e16), parameters: new Map() }), + () => serializeItem({ value: integer(-1e16), parameters: new Map() }), TypeError, "out of range", ); @@ -464,7 +464,7 @@ Deno.test({ name: "serializeItem() rejects non-integer value for integer type", fn() { assertThrows( - () => serializeItem({ value: sfInteger(3.14), parameters: new Map() }), + () => serializeItem({ value: integer(3.14), parameters: new Map() }), TypeError, "must be a whole number", ); @@ -476,12 +476,12 @@ Deno.test({ fn() { assertThrows( () => - serializeItem({ value: sfDecimal(Infinity), parameters: new Map() }), + serializeItem({ value: decimal(Infinity), parameters: new Map() }), TypeError, "must be finite", ); assertThrows( - () => serializeItem({ value: sfDecimal(NaN), parameters: new Map() }), + () => serializeItem({ value: decimal(NaN), parameters: new Map() }), TypeError, "must be finite", ); @@ -494,7 +494,7 @@ Deno.test({ assertThrows( () => serializeItem({ - value: sfDecimal(1_000_000_000_000.1), + value: decimal(1_000_000_000_000.1), parameters: new Map(), }), TypeError, @@ -503,7 +503,7 @@ Deno.test({ assertThrows( () => serializeItem({ - value: sfDecimal(-1_000_000_000_000.1), + value: decimal(-1_000_000_000_000.1), parameters: new Map(), }), TypeError, @@ -518,7 +518,7 @@ Deno.test({ assertThrows( () => serializeItem({ - value: sfDate(new Date("invalid")), + value: date(new Date("invalid")), parameters: new Map(), }), TypeError, @@ -531,7 +531,7 @@ Deno.test({ name: "serializeItem() rejects non-printable ASCII in string", fn() { assertThrows( - () => serializeItem({ value: sfString("\x00"), parameters: new Map() }), + () => serializeItem({ value: string("\x00"), parameters: new Map() }), TypeError, "Invalid character", ); @@ -542,7 +542,7 @@ Deno.test({ name: "serializeItem() rejects non-ASCII in string", fn() { assertThrows( - () => serializeItem({ value: sfString("héllo"), parameters: new Map() }), + () => serializeItem({ value: string("héllo"), parameters: new Map() }), TypeError, "Invalid character", ); @@ -553,7 +553,7 @@ Deno.test({ name: "serializeItem() rejects empty token", fn() { assertThrows( - () => serializeItem({ value: sfToken(""), parameters: new Map() }), + () => serializeItem({ value: token(""), parameters: new Map() }), TypeError, "cannot be empty", ); @@ -564,7 +564,7 @@ Deno.test({ name: "serializeItem() rejects token with invalid start character", fn() { assertThrows( - () => serializeItem({ value: sfToken("1foo"), parameters: new Map() }), + () => serializeItem({ value: token("1foo"), parameters: new Map() }), TypeError, "must start with ALPHA", ); @@ -575,7 +575,7 @@ Deno.test({ name: "serializeItem() rejects token with invalid character", fn() { assertThrows( - () => serializeItem({ value: sfToken("foo bar"), parameters: new Map() }), + () => serializeItem({ value: token("foo bar"), parameters: new Map() }), TypeError, "Invalid character in token", ); @@ -585,8 +585,8 @@ Deno.test({ Deno.test({ name: "serializeDictionary() rejects invalid key", fn() { - const dict: SfDictionary = new Map([ - ["INVALID", { value: sfInteger(1), parameters: new Map() }], + const dict: Dictionary = new Map([ + ["INVALID", { value: integer(1), parameters: new Map() }], ]); assertThrows( () => serializeDictionary(dict), @@ -599,8 +599,8 @@ Deno.test({ Deno.test({ name: "serializeDictionary() rejects empty key", fn() { - const dict: SfDictionary = new Map([ - ["", { value: sfInteger(1), parameters: new Map() }], + const dict: Dictionary = new Map([ + ["", { value: integer(1), parameters: new Map() }], ]); assertThrows( () => serializeDictionary(dict), @@ -613,8 +613,8 @@ Deno.test({ Deno.test({ name: "serializeDictionary() rejects key with invalid character", fn() { - const dict: SfDictionary = new Map([ - ["a!b", { value: sfInteger(1), parameters: new Map() }], + const dict: Dictionary = new Map([ + ["a!b", { value: integer(1), parameters: new Map() }], ]); assertThrows( () => serializeDictionary(dict), @@ -627,11 +627,11 @@ Deno.test({ Deno.test({ name: "serializeItem() rejects invalid parameter key", fn() { - const params = new Map([ - ["INVALID", sfInteger(1)], + const params = new Map([ + ["INVALID", integer(1)], ]); assertThrows( - () => serializeItem({ value: sfToken("foo"), parameters: params }), + () => serializeItem({ value: token("foo"), parameters: params }), TypeError, "must start with lowercase", ); @@ -681,18 +681,18 @@ Deno.test({ // ============================================================================= Deno.test({ - name: "sf* factory functions create correct bare items", + name: "factory functions create correct bare items", fn() { - assertEquals(sfInteger(42), { type: "integer", value: 42 }); - assertEquals(sfDecimal(3.14), { type: "decimal", value: 3.14 }); - assertEquals(sfString("hello"), { type: "string", value: "hello" }); - assertEquals(sfToken("foo"), { type: "token", value: "foo" }); - assertEquals(sfBinary(new Uint8Array([1, 2, 3])).type, "binary"); - assertEquals(sfBoolean(true), { type: "boolean", value: true }); - assertEquals(sfBoolean(false), { type: "boolean", value: false }); + assertEquals(integer(42), { type: "integer", value: 42 }); + assertEquals(decimal(3.14), { type: "decimal", value: 3.14 }); + assertEquals(string("hello"), { type: "string", value: "hello" }); + assertEquals(token("foo"), { type: "token", value: "foo" }); + assertEquals(binary(new Uint8Array([1, 2, 3])).type, "binary"); + assertEquals(boolean(true), { type: "boolean", value: true }); + assertEquals(boolean(false), { type: "boolean", value: false }); const d = new Date(); - assertEquals(sfDate(d), { type: "date", value: d }); - assertEquals(sfDisplayString("héllo"), { + assertEquals(date(d), { type: "date", value: d }); + assertEquals(displayString("héllo"), { type: "displaystring", value: "héllo", }); @@ -780,32 +780,32 @@ function decodeBase32(input: string): Uint8Array { // Convert test suite expected value to our internal format // deno-lint-ignore no-explicit-any -function convertExpectedBareItem(expected: any): SfBareItem { +function convertExpectedBareItem(expected: any): BareItem { if (expected === null || expected === undefined) { throw new Error("Unexpected null/undefined bare item"); } if (typeof expected === "boolean") { - return sfBoolean(expected); + return boolean(expected); } if (typeof expected === "number") { if (Number.isInteger(expected)) { - return sfInteger(expected); + return integer(expected); } - return sfDecimal(expected); + return decimal(expected); } if (typeof expected === "string") { - return sfString(expected); + return string(expected); } if (typeof expected === "object" && "__type" in expected) { switch (expected.__type) { case "token": - return sfToken(expected.value); + return token(expected.value); case "binary": - return sfBinary(decodeBase32(expected.value)); + return binary(decodeBase32(expected.value)); case "date": - return sfDate(new Date(expected.value * 1000)); + return date(new Date(expected.value * 1000)); case "displaystring": - return sfDisplayString(expected.value); + return displayString(expected.value); default: throw new Error(`Unknown __type: ${expected.__type}`); } @@ -815,17 +815,17 @@ function convertExpectedBareItem(expected: any): SfBareItem { // Convert test suite expected params array to Map // deno-lint-ignore no-explicit-any -function convertExpectedParams(params: any[]): Map { - const map = new Map(); +function convertExpectedParams(params: any[]): Map { + const map = new Map(); for (const [key, value] of params) { map.set(key, convertExpectedBareItem(value)); } return map; } -// Convert test suite expected item [bareItem, params] to SfItem +// Convert test suite expected item [bareItem, params] to Item // deno-lint-ignore no-explicit-any -function convertExpectedItem(expected: any[]): SfItem { +function convertExpectedItem(expected: any[]): Item { const [bareItem, params] = expected; return { value: convertExpectedBareItem(bareItem), @@ -833,9 +833,9 @@ function convertExpectedItem(expected: any[]): SfItem { }; } -// Convert test suite expected inner list [[items...], params] to SfInnerList +// Convert test suite expected inner list [[items...], params] to InnerList // deno-lint-ignore no-explicit-any -function convertExpectedInnerList(expected: any[]): SfInnerList { +function convertExpectedInnerList(expected: any[]): InnerList { const [items, params] = expected; return { items: items.map(convertExpectedItem), @@ -861,7 +861,7 @@ function isExpectedInnerList(expected: any[]): boolean { // Convert test suite expected list member // deno-lint-ignore no-explicit-any -function convertExpectedListMember(expected: any[]): SfItem | SfInnerList { +function convertExpectedListMember(expected: any[]): Item | InnerList { if (isExpectedInnerList(expected)) { return convertExpectedInnerList(expected); } @@ -869,7 +869,7 @@ function convertExpectedListMember(expected: any[]): SfItem | SfInnerList { } // Compare bare items for equality -function bareItemsEqual(a: SfBareItem, b: SfBareItem): boolean { +function bareItemsEqual(a: BareItem, b: BareItem): boolean { // Handle integer/decimal comparison - JSON loses distinction between 1 and 1.0 if ( (a.type === "integer" || a.type === "decimal") && @@ -892,8 +892,8 @@ function bareItemsEqual(a: SfBareItem, b: SfBareItem): boolean { // Compare parameters for equality function paramsEqual( - a: ReadonlyMap, - b: ReadonlyMap, + a: ReadonlyMap, + b: ReadonlyMap, ): boolean { if (a.size !== b.size) return false; for (const [key, val] of a) { @@ -904,13 +904,13 @@ function paramsEqual( } // Compare items for equality -function itemsEqual(a: SfItem, b: SfItem): boolean { +function itemsEqual(a: Item, b: Item): boolean { return bareItemsEqual(a.value, b.value) && paramsEqual(a.parameters, b.parameters); } // Compare inner lists for equality -function innerListsEqual(a: SfInnerList, b: SfInnerList): boolean { +function innerListsEqual(a: InnerList, b: InnerList): boolean { if (a.items.length !== b.items.length) return false; for (let i = 0; i < a.items.length; i++) { if (!itemsEqual(a.items[i]!, b.items[i]!)) return false; @@ -920,16 +920,16 @@ function innerListsEqual(a: SfInnerList, b: SfInnerList): boolean { // Compare list members for equality function listMembersEqual( - a: SfItem | SfInnerList, - b: SfItem | SfInnerList, + a: Item | InnerList, + b: Item | InnerList, ): boolean { const aIsInner = "items" in a; const bIsInner = "items" in b; if (aIsInner !== bIsInner) return false; if (aIsInner && bIsInner) { - return innerListsEqual(a as SfInnerList, b as SfInnerList); + return innerListsEqual(a as InnerList, b as InnerList); } - return itemsEqual(a as SfItem, b as SfItem); + return itemsEqual(a as Item, b as Item); } // Interface for test case from JSON files @@ -1005,7 +1005,7 @@ async function runConformanceTests( } case "list": { const parsed = parseList(rawInput); - const expected: SfList = test.expected.map( + const expected: List = test.expected.map( convertExpectedListMember, ); assertEquals(parsed.length, expected.length); @@ -1029,7 +1029,7 @@ async function runConformanceTests( case "dictionary": { const parsed = parseDictionary(rawInput); // Convert expected dictionary format [[key, value], ...] - const expectedEntries: Array<[string, SfItem | SfInnerList]> = + const expectedEntries: Array<[string, Item | InnerList]> = // deno-lint-ignore no-explicit-any (test.expected as any[]).map( ([key, value]: [string, unknown]) => [ From e863f84898078dc1ca136650235d55658c6c12a8 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Thu, 12 Feb 2026 12:59:27 +0100 Subject: [PATCH 06/11] revamp the api --- http/unstable_structured_fields.ts | 161 +++++++------- http/unstable_structured_fields_test.ts | 268 ++++++++++-------------- 2 files changed, 193 insertions(+), 236 deletions(-) diff --git a/http/unstable_structured_fields.ts b/http/unstable_structured_fields.ts index 940d412a6195..c07a735c46a6 100644 --- a/http/unstable_structured_fields.ts +++ b/http/unstable_structured_fields.ts @@ -28,11 +28,11 @@ * * @example Serializing a Dictionary * ```ts - * import { serializeDictionary, string } from "@std/http/unstable-structured-fields"; + * import { item, serializeDictionary, string } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * * const dict = new Map([ - * ["profile", { value: string("https://example.com/profile.json"), parameters: new Map() }], + * ["profile", item(string("https://example.com/profile.json"))], * ]); * * assertEquals( @@ -136,9 +136,6 @@ export type Dictionary = ReadonlyMap; // Convenience Builders // ============================================================================= -/** An integer Bare Item. */ -export type IntegerItem = Extract; - /** * Creates an integer Bare Item. * @@ -155,13 +152,10 @@ export type IntegerItem = Extract; * assertEquals(integer(42), { type: "integer", value: 42 }); * ``` */ -export function integer(value: number): IntegerItem { +export function integer(value: number): Extract { return { type: "integer", value }; } -/** A decimal Bare Item. */ -export type DecimalItem = Extract; - /** * Creates a decimal Bare Item. * @@ -178,13 +172,10 @@ export type DecimalItem = Extract; * assertEquals(decimal(3.14), { type: "decimal", value: 3.14 }); * ``` */ -export function decimal(value: number): DecimalItem { +export function decimal(value: number): Extract { return { type: "decimal", value }; } -/** A string Bare Item. */ -export type StringItem = Extract; - /** * Creates a string Bare Item. * @@ -201,13 +192,10 @@ export type StringItem = Extract; * assertEquals(string("hello"), { type: "string", value: "hello" }); * ``` */ -export function string(value: string): StringItem { +export function string(value: string): Extract { return { type: "string", value }; } -/** A token Bare Item. */ -export type TokenItem = Extract; - /** * Creates a token Bare Item. * @@ -224,13 +212,10 @@ export type TokenItem = Extract; * assertEquals(token("foo"), { type: "token", value: "foo" }); * ``` */ -export function token(value: string): TokenItem { +export function token(value: string): Extract { return { type: "token", value }; } -/** A binary Bare Item. */ -export type BinaryItem = Extract; - /** * Creates a binary Bare Item. * @@ -248,13 +233,12 @@ export type BinaryItem = Extract; * assertEquals(result.type, "binary"); * ``` */ -export function binary(value: Uint8Array): BinaryItem { +export function binary( + value: Uint8Array, +): Extract { return { type: "binary", value }; } -/** A boolean Bare Item. */ -export type BooleanItem = Extract; - /** * Creates a boolean Bare Item. * @@ -271,13 +255,12 @@ export type BooleanItem = Extract; * assertEquals(boolean(true), { type: "boolean", value: true }); * ``` */ -export function boolean(value: boolean): BooleanItem { +export function boolean( + value: boolean, +): Extract { return { type: "boolean", value }; } -/** A date Bare Item. */ -export type DateItem = Extract; - /** * Creates a date Bare Item. * @@ -295,16 +278,10 @@ export type DateItem = Extract; * assertEquals(date(d), { type: "date", value: d }); * ``` */ -export function date(value: Date): DateItem { +export function date(value: Date): Extract { return { type: "date", value }; } -/** A display string Bare Item. */ -export type DisplayStringItem = Extract< - BareItem, - { type: "displaystring" } ->; - /** * Creates a display string Bare Item. * @@ -321,10 +298,71 @@ export type DisplayStringItem = Extract< * assertEquals(displayString("héllo"), { type: "displaystring", value: "héllo" }); * ``` */ -export function displayString(value: string): DisplayStringItem { +export function displayString( + value: string, +): Extract { return { type: "displaystring", value }; } +/** + * Creates an Item from a Bare Item and optional Parameters. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The Bare Item value. + * @param parameters Optional parameters as a `Map` or iterable of key-value pairs. + * @returns An Item with the given value and parameters. + * + * @example Usage + * ```ts + * import { item, integer, token } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(item(integer(42)), { + * value: { type: "integer", value: 42 }, + * parameters: new Map(), + * }); + * + * assertEquals(item(integer(42), [["q", token("fast")]]), { + * value: { type: "integer", value: 42 }, + * parameters: new Map([["q", { type: "token", value: "fast" }]]), + * }); + * ``` + */ +export function item( + value: BareItem, + parameters?: Iterable<[string, BareItem]>, +): Item { + return { value, parameters: new Map(parameters) }; +} + +/** + * Creates an Inner List from Items and optional Parameters. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param items The items in the inner list. + * @param parameters Optional parameters as a `Map` or iterable of key-value pairs. + * @returns An InnerList with the given items and parameters. + * + * @example Usage + * ```ts + * import { innerList, item, integer } from "@std/http/unstable-structured-fields"; + * import { assertEquals } from "@std/assert"; + * + * const list = innerList([item(integer(1)), item(integer(2))]); + * + * assertEquals(list.items.length, 2); + * assertEquals(list.parameters.size, 0); + * ``` + */ +export function innerList( + items: Item[], + parameters?: Iterable<[string, BareItem]>, +): InnerList { + return { items, parameters: new Map(parameters) }; +} + // ============================================================================= // Type Guards // ============================================================================= @@ -375,34 +413,6 @@ export function isInnerList( return "items" in member; } -/** - * Checks if a Bare Item is of a specific type. - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @typeParam T The bare item type to check for. - * @param item The bare item to check. - * @param type The type to check for. - * @returns `true` if the item is of the specified type. - * - * @example Usage - * ```ts - * import { parseItem, isBareItemType } from "@std/http/unstable-structured-fields"; - * import { assert, assertEquals } from "@std/assert"; - * - * const item = parseItem("42"); - * if (isBareItemType(item.value, "integer")) { - * assertEquals(item.value.value, 42); // TypeScript knows value is number - * } - * ``` - */ -export function isBareItemType( - item: BareItem, - type: T, -): item is Extract { - return item.type === type; -} - // ============================================================================= // Parsing (RFC 9651 Section 4.2) // ============================================================================= @@ -1130,13 +1140,10 @@ function parseParameters(state: ParserState): Parameters { * * @example Usage * ```ts - * import { serializeList, integer } from "@std/http/unstable-structured-fields"; + * import { item, serializeList, integer } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * - * const list = [ - * { value: integer(1), parameters: new Map() }, - * { value: integer(42), parameters: new Map() }, - * ]; + * const list = [item(integer(1)), item(integer(42))]; * * assertEquals(serializeList(list), "1, 42"); * ``` @@ -1168,11 +1175,11 @@ export function serializeList(list: List): string { * * @example Usage * ```ts - * import { serializeDictionary, string } from "@std/http/unstable-structured-fields"; + * import { item, serializeDictionary, string } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * * const dict = new Map([ - * ["key", { value: string("value"), parameters: new Map() }], + * ["key", item(string("value"))], * ]); * * assertEquals(serializeDictionary(dict), 'key="value"'); @@ -1214,16 +1221,14 @@ export function serializeDictionary(dict: Dictionary): string { * * @example Usage * ```ts - * import { serializeItem, integer } from "@std/http/unstable-structured-fields"; + * import { item, serializeItem, integer } from "@std/http/unstable-structured-fields"; * import { assertEquals } from "@std/assert"; * - * const item = { value: integer(42), parameters: new Map() }; - * - * assertEquals(serializeItem(item), "42"); + * assertEquals(serializeItem(item(integer(42))), "42"); * ``` */ -export function serializeItem(item: Item): string { - return serializeItemInternal(item); +export function serializeItem(value: Item): string { + return serializeItemInternal(value); } function serializeInnerList(innerList: InnerList): string { diff --git a/http/unstable_structured_fields_test.ts b/http/unstable_structured_fields_test.ts index e6c063284eec..0ccb31e5a5d8 100644 --- a/http/unstable_structured_fields_test.ts +++ b/http/unstable_structured_fields_test.ts @@ -2,15 +2,6 @@ import { assertEquals, assertThrows } from "@std/assert"; import { - isBareItemType, - isInnerList, - isItem, - parseDictionary, - parseItem, - parseList, - serializeDictionary, - serializeItem, - serializeList, type BareItem, binary, boolean, @@ -19,9 +10,19 @@ import { type Dictionary, displayString, type InnerList, + innerList, integer, + isInnerList, + isItem, type Item, + item, type List, + parseDictionary, + parseItem, + parseList, + serializeDictionary, + serializeItem, + serializeList, string, token, } from "./unstable_structured_fields.ts"; @@ -215,11 +216,7 @@ Deno.test({ Deno.test({ name: "serializeList() serializes basic list", fn() { - const list: List = [ - { value: integer(1), parameters: new Map() }, - { value: integer(42), parameters: new Map() }, - ]; - assertEquals(serializeList(list), "1, 42"); + assertEquals(serializeList([item(integer(1)), item(integer(42))]), "1, 42"); }, }); @@ -233,30 +230,22 @@ Deno.test({ Deno.test({ name: "serializeList() serializes inner list", fn() { - const innerList: InnerList = { - items: [ - { value: integer(1), parameters: new Map() }, - { value: integer(2), parameters: new Map() }, - ], - parameters: new Map(), - }; - const list: List = [innerList]; - assertEquals(serializeList(list), "(1 2)"); + assertEquals( + serializeList([innerList([item(integer(1)), item(integer(2))])]), + "(1 2)", + ); }, }); Deno.test({ name: "serializeList() serializes inner list with parameters", fn() { - const innerList: InnerList = { - items: [ - { value: integer(1), parameters: new Map() }, - ], - parameters: new Map([ - ["param", token("value")], + assertEquals( + serializeList([ + innerList([item(integer(1))], [["param", token("value")]]), ]), - }; - assertEquals(serializeList([innerList]), "(1);param=value"); + "(1);param=value", + ); }, }); @@ -264,8 +253,8 @@ Deno.test({ name: "serializeDictionary() serializes basic dictionary", fn() { const dict: Dictionary = new Map([ - ["a", { value: integer(1), parameters: new Map() }], - ["b", { value: integer(2), parameters: new Map() }], + ["a", item(integer(1))], + ["b", item(integer(2))], ]); assertEquals(serializeDictionary(dict), "a=1, b=2"); }, @@ -274,9 +263,7 @@ Deno.test({ Deno.test({ name: "serializeDictionary() omits =?1 for true boolean", fn() { - const dict: Dictionary = new Map([ - ["a", { value: boolean(true), parameters: new Map() }], - ]); + const dict: Dictionary = new Map([["a", item(boolean(true))]]); assertEquals(serializeDictionary(dict), "a"); }, }); @@ -284,9 +271,7 @@ Deno.test({ Deno.test({ name: "serializeDictionary() includes =?0 for false boolean", fn() { - const dict: Dictionary = new Map([ - ["a", { value: boolean(false), parameters: new Map() }], - ]); + const dict: Dictionary = new Map([["a", item(boolean(false))]]); assertEquals(serializeDictionary(dict), "a=?0"); }, }); @@ -294,9 +279,7 @@ Deno.test({ Deno.test({ name: "serializeDictionary() handles key starting with *", fn() { - const dict: Dictionary = new Map([ - ["*key", { value: integer(1), parameters: new Map() }], - ]); + const dict: Dictionary = new Map([["*key", item(integer(1))]]); assertEquals(serializeDictionary(dict), "*key=1"); }, }); @@ -304,15 +287,8 @@ Deno.test({ Deno.test({ name: "serializeDictionary() serializes inner list value", fn() { - const innerList: InnerList = { - items: [ - { value: integer(1), parameters: new Map() }, - { value: integer(2), parameters: new Map() }, - ], - parameters: new Map(), - }; const dict: Dictionary = new Map([ - ["a", innerList], + ["a", innerList([item(integer(1)), item(integer(2))])], ]); assertEquals(serializeDictionary(dict), "a=(1 2)"); }, @@ -321,32 +297,17 @@ Deno.test({ Deno.test({ name: "serializeItem() covers all bare item types", fn() { + assertEquals(serializeItem(item(token("foo"))), "foo"); assertEquals( - serializeItem({ value: token("foo"), parameters: new Map() }), - "foo", - ); - assertEquals( - serializeItem({ - value: binary(new Uint8Array([1, 2, 3])), - parameters: new Map(), - }), + serializeItem(item(binary(new Uint8Array([1, 2, 3])))), ":AQID:", ); - assertEquals( - serializeItem({ value: boolean(true), parameters: new Map() }), - "?1", - ); - assertEquals( - serializeItem({ value: boolean(false), parameters: new Map() }), - "?0", - ); + assertEquals(serializeItem(item(boolean(true))), "?1"); + assertEquals(serializeItem(item(boolean(false))), "?0"); const d = new Date(1659578233000); + assertEquals(serializeItem(item(date(d))), "@1659578233"); assertEquals( - serializeItem({ value: date(d), parameters: new Map() }), - "@1659578233", - ); - assertEquals( - serializeItem({ value: displayString("héllo"), parameters: new Map() }), + serializeItem(item(displayString("héllo"))), '%"h%c3%a9llo"', ); }, @@ -356,14 +317,11 @@ Deno.test({ name: "serializeItem() escapes quotes and backslash in string", fn() { assertEquals( - serializeItem({ - value: string('hello "world"'), - parameters: new Map(), - }), + serializeItem(item(string('hello "world"'))), '"hello \\"world\\""', ); assertEquals( - serializeItem({ value: string("hello\\world"), parameters: new Map() }), + serializeItem(item(string("hello\\world"))), '"hello\\\\world"', ); }, @@ -372,12 +330,10 @@ Deno.test({ Deno.test({ name: "serializeItem() serializes item with parameters", fn() { - const params = new Map([ - ["a", integer(1)], - ["b", boolean(true)], - ]); assertEquals( - serializeItem({ value: token("foo"), parameters: params }), + serializeItem( + item(token("foo"), [["a", integer(1)], ["b", boolean(true)]]), + ), "foo;a=1;b", ); }, @@ -387,17 +343,11 @@ Deno.test({ name: "serializeItem() handles boundary integers", fn() { assertEquals( - serializeItem({ - value: integer(-999_999_999_999_999), - parameters: new Map(), - }), + serializeItem(item(integer(-999_999_999_999_999))), "-999999999999999", ); assertEquals( - serializeItem({ - value: integer(999_999_999_999_999), - parameters: new Map(), - }), + serializeItem(item(integer(999_999_999_999_999))), "999999999999999", ); }, @@ -406,24 +356,15 @@ Deno.test({ Deno.test({ name: "serializeItem() handles decimal edge cases", fn() { - assertEquals( - serializeItem({ value: decimal(1.0), parameters: new Map() }), - "1.0", - ); - assertEquals( - serializeItem({ value: decimal(-3.14), parameters: new Map() }), - "-3.14", - ); + assertEquals(serializeItem(item(decimal(1.0))), "1.0"); + assertEquals(serializeItem(item(decimal(-3.14))), "-3.14"); }, }); Deno.test({ name: "serializeItem() handles token starting with *", fn() { - assertEquals( - serializeItem({ value: token("*foo"), parameters: new Map() }), - "*foo", - ); + assertEquals(serializeItem(item(token("*foo"))), "*foo"); }, }); @@ -431,10 +372,7 @@ Deno.test({ name: 'serializeItem() encodes % and " in display string', fn() { assertEquals( - serializeItem({ - value: displayString('hello % and "'), - parameters: new Map(), - }), + serializeItem(item(displayString('hello % and "'))), '%"hello %25 and %22"', ); }, @@ -448,12 +386,12 @@ Deno.test({ name: "serializeItem() rejects out-of-range integer", fn() { assertThrows( - () => serializeItem({ value: integer(1e16), parameters: new Map() }), + () => serializeItem(item(integer(1e16))), TypeError, "out of range", ); assertThrows( - () => serializeItem({ value: integer(-1e16), parameters: new Map() }), + () => serializeItem(item(integer(-1e16))), TypeError, "out of range", ); @@ -464,7 +402,7 @@ Deno.test({ name: "serializeItem() rejects non-integer value for integer type", fn() { assertThrows( - () => serializeItem({ value: integer(3.14), parameters: new Map() }), + () => serializeItem(item(integer(3.14))), TypeError, "must be a whole number", ); @@ -475,13 +413,12 @@ Deno.test({ name: "serializeItem() rejects non-finite decimal", fn() { assertThrows( - () => - serializeItem({ value: decimal(Infinity), parameters: new Map() }), + () => serializeItem(item(decimal(Infinity))), TypeError, "must be finite", ); assertThrows( - () => serializeItem({ value: decimal(NaN), parameters: new Map() }), + () => serializeItem(item(decimal(NaN))), TypeError, "must be finite", ); @@ -492,20 +429,12 @@ Deno.test({ name: "serializeItem() rejects decimal with integer part too large", fn() { assertThrows( - () => - serializeItem({ - value: decimal(1_000_000_000_000.1), - parameters: new Map(), - }), + () => serializeItem(item(decimal(1_000_000_000_000.1))), TypeError, "integer part too large", ); assertThrows( - () => - serializeItem({ - value: decimal(-1_000_000_000_000.1), - parameters: new Map(), - }), + () => serializeItem(item(decimal(-1_000_000_000_000.1))), TypeError, "integer part too large", ); @@ -516,11 +445,7 @@ Deno.test({ name: "serializeItem() rejects invalid date", fn() { assertThrows( - () => - serializeItem({ - value: date(new Date("invalid")), - parameters: new Map(), - }), + () => serializeItem(item(date(new Date("invalid")))), TypeError, "Invalid date", ); @@ -531,7 +456,7 @@ Deno.test({ name: "serializeItem() rejects non-printable ASCII in string", fn() { assertThrows( - () => serializeItem({ value: string("\x00"), parameters: new Map() }), + () => serializeItem(item(string("\x00"))), TypeError, "Invalid character", ); @@ -542,7 +467,7 @@ Deno.test({ name: "serializeItem() rejects non-ASCII in string", fn() { assertThrows( - () => serializeItem({ value: string("héllo"), parameters: new Map() }), + () => serializeItem(item(string("héllo"))), TypeError, "Invalid character", ); @@ -553,7 +478,7 @@ Deno.test({ name: "serializeItem() rejects empty token", fn() { assertThrows( - () => serializeItem({ value: token(""), parameters: new Map() }), + () => serializeItem(item(token(""))), TypeError, "cannot be empty", ); @@ -564,7 +489,7 @@ Deno.test({ name: "serializeItem() rejects token with invalid start character", fn() { assertThrows( - () => serializeItem({ value: token("1foo"), parameters: new Map() }), + () => serializeItem(item(token("1foo"))), TypeError, "must start with ALPHA", ); @@ -575,7 +500,7 @@ Deno.test({ name: "serializeItem() rejects token with invalid character", fn() { assertThrows( - () => serializeItem({ value: token("foo bar"), parameters: new Map() }), + () => serializeItem(item(token("foo bar"))), TypeError, "Invalid character in token", ); @@ -585,9 +510,7 @@ Deno.test({ Deno.test({ name: "serializeDictionary() rejects invalid key", fn() { - const dict: Dictionary = new Map([ - ["INVALID", { value: integer(1), parameters: new Map() }], - ]); + const dict: Dictionary = new Map([["INVALID", item(integer(1))]]); assertThrows( () => serializeDictionary(dict), TypeError, @@ -599,9 +522,7 @@ Deno.test({ Deno.test({ name: "serializeDictionary() rejects empty key", fn() { - const dict: Dictionary = new Map([ - ["", { value: integer(1), parameters: new Map() }], - ]); + const dict: Dictionary = new Map([["", item(integer(1))]]); assertThrows( () => serializeDictionary(dict), TypeError, @@ -613,9 +534,7 @@ Deno.test({ Deno.test({ name: "serializeDictionary() rejects key with invalid character", fn() { - const dict: Dictionary = new Map([ - ["a!b", { value: integer(1), parameters: new Map() }], - ]); + const dict: Dictionary = new Map([["a!b", item(integer(1))]]); assertThrows( () => serializeDictionary(dict), TypeError, @@ -627,11 +546,8 @@ Deno.test({ Deno.test({ name: "serializeItem() rejects invalid parameter key", fn() { - const params = new Map([ - ["INVALID", integer(1)], - ]); assertThrows( - () => serializeItem({ value: token("foo"), parameters: params }), + () => serializeItem(item(token("foo"), [["INVALID", integer(1)]])), TypeError, "must start with lowercase", ); @@ -699,6 +615,56 @@ Deno.test({ }, }); +Deno.test({ + name: "item() creates item with empty parameters by default", + fn() { + const i = item(integer(42)); + assertEquals(i.value, { type: "integer", value: 42 }); + assertEquals(i.parameters.size, 0); + }, +}); + +Deno.test({ + name: "item() creates item with parameters from iterable", + fn() { + const i = item(token("foo"), [["a", integer(1)], ["b", boolean(true)]]); + assertEquals(i.value, { type: "token", value: "foo" }); + assertEquals(i.parameters.size, 2); + assertEquals(i.parameters.get("a"), integer(1)); + assertEquals(i.parameters.get("b"), boolean(true)); + }, +}); + +Deno.test({ + name: "item() creates item with parameters from Map", + fn() { + const params = new Map([["q", decimal(0.9)]]); + const i = item(token("text/html"), params); + assertEquals(i.parameters.get("q"), decimal(0.9)); + }, +}); + +Deno.test({ + name: "innerList() creates inner list with empty parameters by default", + fn() { + const il = innerList([item(integer(1)), item(integer(2))]); + assertEquals(il.items.length, 2); + assertEquals(il.parameters.size, 0); + }, +}); + +Deno.test({ + name: "innerList() creates inner list with parameters", + fn() { + const il = innerList( + [item(integer(1))], + [["param", token("value")]], + ); + assertEquals(il.items.length, 1); + assertEquals(il.parameters.get("param"), token("value")); + }, +}); + // ============================================================================= // Type Guard Tests // ============================================================================= @@ -721,20 +687,6 @@ Deno.test({ }, }); -Deno.test({ - name: "isBareItemType() narrows bare item type", - fn() { - const item = parseItem("42"); - assertEquals(isBareItemType(item.value, "integer"), true); - assertEquals(isBareItemType(item.value, "string"), false); - - if (isBareItemType(item.value, "integer")) { - // TypeScript should know this is a number - assertEquals(item.value.value, 42); - } - }, -}); - Deno.test({ name: "type guards work with dictionary values", fn() { From 502b545a1625db09acc60335d589d03f55162d05 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Thu, 12 Feb 2026 13:01:51 +0100 Subject: [PATCH 07/11] perf fixes --- http/unstable_structured_fields.ts | 47 ++++++++++++++++-------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/http/unstable_structured_fields.ts b/http/unstable_structured_fields.ts index c07a735c46a6..144f362ba48f 100644 --- a/http/unstable_structured_fields.ts +++ b/http/unstable_structured_fields.ts @@ -49,6 +49,7 @@ import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); +const UTF8_ENCODER = new TextEncoder(); // ============================================================================= // Type Definitions (RFC 9651 Section 3) @@ -784,9 +785,10 @@ function parseBareItem(state: ParserState): BareItem { } function parseIntegerOrDecimal(state: ParserState): BareItem { + const { input } = state; let sign = 1; if (peek(state) === "-") { - consume(state); + state.pos++; sign = -1; } @@ -796,45 +798,47 @@ function parseIntegerOrDecimal(state: ParserState): BareItem { ); } - let integerPart = ""; + const intStart = state.pos; while (isDigit(peek(state))) { - integerPart += consume(state); - if (integerPart.length > MAX_INTEGER_DIGITS) { - throw new SyntaxError( - "Invalid structured field: integer too long", - ); - } + state.pos++; + } + const intLen = state.pos - intStart; + if (intLen > MAX_INTEGER_DIGITS) { + throw new SyntaxError( + "Invalid structured field: integer too long", + ); } if (peek(state) === ".") { - consume(state); // consume '.' - if (integerPart.length > MAX_DECIMAL_INTEGER_DIGITS) { + state.pos++; // consume '.' + if (intLen > MAX_DECIMAL_INTEGER_DIGITS) { throw new SyntaxError( "Invalid structured field: decimal integer part too long", ); } - let fractionalPart = ""; + const fracStart = state.pos; while (isDigit(peek(state))) { - fractionalPart += consume(state); - if (fractionalPart.length > MAX_DECIMAL_FRACTIONAL_DIGITS) { - throw new SyntaxError( - "Invalid structured field: decimal fractional part too long", - ); - } + state.pos++; + } + const fracLen = state.pos - fracStart; + if (fracLen > MAX_DECIMAL_FRACTIONAL_DIGITS) { + throw new SyntaxError( + "Invalid structured field: decimal fractional part too long", + ); } - if (fractionalPart.length === 0) { + if (fracLen === 0) { throw new SyntaxError( "Invalid structured field: decimal requires fractional digits", ); } - const value = sign * parseFloat(`${integerPart}.${fractionalPart}`); + const value = sign * parseFloat(input.slice(intStart, state.pos)); return { type: "decimal", value }; } - const value = sign * parseInt(integerPart, 10); + const value = sign * parseInt(input.slice(intStart, state.pos), 10); if (value < -MAX_INTEGER || value > MAX_INTEGER) { throw new SyntaxError( @@ -1363,8 +1367,7 @@ function serializeDate(value: Date): string { } function serializeDisplayString(value: string): string { - const encoder = new TextEncoder(); - const bytes = encoder.encode(value); + const bytes = UTF8_ENCODER.encode(value); let result = '%"'; for (const byte of bytes) { From 955a2017b8b462685fc7a277aac0c3e49a4fe485 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Thu, 12 Feb 2026 13:09:51 +0100 Subject: [PATCH 08/11] fix spec violation --- http/unstable_structured_fields.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/http/unstable_structured_fields.ts b/http/unstable_structured_fields.ts index 144f362ba48f..6d512d7db931 100644 --- a/http/unstable_structured_fields.ts +++ b/http/unstable_structured_fields.ts @@ -1363,6 +1363,9 @@ function serializeDate(value: Date): string { if (!Number.isFinite(timestamp)) { throw new TypeError("Invalid date"); } + if (timestamp < -MAX_INTEGER || timestamp > MAX_INTEGER) { + throw new TypeError("Date out of range"); + } return `@${timestamp}`; } From 41a87644c0f18f01b4fdf6c599fb614528ea4e04 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Thu, 12 Feb 2026 13:11:34 +0100 Subject: [PATCH 09/11] update doc --- http/unstable_structured_fields.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/http/unstable_structured_fields.ts b/http/unstable_structured_fields.ts index 6d512d7db931..2759f137078e 100644 --- a/http/unstable_structured_fields.ts +++ b/http/unstable_structured_fields.ts @@ -142,7 +142,8 @@ export type Dictionary = ReadonlyMap; * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @param value The integer value (-999999999999999 to 999999999999999). + * @param value The integer value. Must be in the range -999999999999999 to + * 999999999999999; validated during serialization. * @returns A Bare Item of type integer. * * @example Usage @@ -162,7 +163,8 @@ export function integer(value: number): Extract { * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @param value The decimal value. + * @param value The decimal value. Must be finite with an integer part of at + * most 12 digits; validated during serialization. * @returns A Bare Item of type decimal. * * @example Usage @@ -182,7 +184,8 @@ export function decimal(value: number): Extract { * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @param value The string value (ASCII printable characters only). + * @param value The string value. Must contain only ASCII printable characters + * (0x20-0x7E); validated during serialization. * @returns A Bare Item of type string. * * @example Usage @@ -202,7 +205,8 @@ export function string(value: string): Extract { * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @param value The token value. + * @param value The token value. Must start with ALPHA or '*' and contain only + * token characters; validated during serialization. * @returns A Bare Item of type token. * * @example Usage @@ -267,7 +271,8 @@ export function boolean( * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @param value The date value. + * @param value The date value. Must represent a valid date whose Unix timestamp + * is in the integer range; validated during serialization. * @returns A Bare Item of type date. * * @example Usage From da258ca1ae75a7988dbfdbe7abc916f94c278e50 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Thu, 12 Feb 2026 13:16:30 +0100 Subject: [PATCH 10/11] fix doc --- http/unstable_structured_fields.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/http/unstable_structured_fields.ts b/http/unstable_structured_fields.ts index 2759f137078e..ca3a9af98bec 100644 --- a/http/unstable_structured_fields.ts +++ b/http/unstable_structured_fields.ts @@ -1222,7 +1222,7 @@ export function serializeDictionary(dict: Dictionary): string { * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @param item The Item to serialize. + * @param value The Item to serialize. * @returns The serialized string. * @throws {TypeError} If the item contains invalid values. * From b4acd4c83069e0a10e1e3a010aad3baa4160a006 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Thu, 12 Feb 2026 13:18:48 +0100 Subject: [PATCH 11/11] fix doc --- http/unstable_structured_fields.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/http/unstable_structured_fields.ts b/http/unstable_structured_fields.ts index ca3a9af98bec..8b7bde78c4fb 100644 --- a/http/unstable_structured_fields.ts +++ b/http/unstable_structured_fields.ts @@ -331,7 +331,7 @@ export function displayString( * * assertEquals(item(integer(42), [["q", token("fast")]]), { * value: { type: "integer", value: 42 }, - * parameters: new Map([["q", { type: "token", value: "fast" }]]), + * parameters: new Map([["q", token("fast")]]), * }); * ``` */