@jimhoyd/urlcode 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/CONTRIBUTING.md +80 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +20 -0
  4. package/README.md +342 -0
  5. package/ROADMAP.md +248 -0
  6. package/SECURITY.md +40 -0
  7. package/data/agents/LICENSES/ai-robots-txt.txt +24 -0
  8. package/data/agents/LICENSES/crawler-user-agents.txt +23 -0
  9. package/data/agents/ai-crawlers.json +1240 -0
  10. package/data/agents/crawlers.json +10515 -0
  11. package/data/agents/index.d.ts +3 -0
  12. package/data/agents/index.js +2453 -0
  13. package/data/agents/monitoring.json +1520 -0
  14. package/data/agents/seo.json +3865 -0
  15. package/dist/BUILD-MANIFEST.json +60 -0
  16. package/dist/adapters.js +59 -0
  17. package/dist/agent-lists.js +59 -0
  18. package/dist/assets.js +129 -0
  19. package/dist/authoring.js +68 -0
  20. package/dist/aws.js +110 -0
  21. package/dist/build-cloudflare.js +164 -0
  22. package/dist/cli.js +185 -0
  23. package/dist/client-address.js +85 -0
  24. package/dist/cloudflare.js +165 -0
  25. package/dist/compliance-rules/baseline.js +98 -0
  26. package/dist/compliance-rules/privacy.js +38 -0
  27. package/dist/compliance-rules/shared.js +62 -0
  28. package/dist/compliance-rules/strict.js +61 -0
  29. package/dist/compliance.js +219 -0
  30. package/dist/config-worker.js +9 -0
  31. package/dist/config.js +172 -0
  32. package/dist/errors.js +8 -0
  33. package/dist/function-sources.js +54 -0
  34. package/dist/function-worker.js +105 -0
  35. package/dist/functions.js +174 -0
  36. package/dist/guest-api.js +114 -0
  37. package/dist/header-validation.js +18 -0
  38. package/dist/http-policy.js +74 -0
  39. package/dist/http-response.js +84 -0
  40. package/dist/index.js +11 -0
  41. package/dist/link-api.js +136 -0
  42. package/dist/link-cli.js +141 -0
  43. package/dist/link-events.js +76 -0
  44. package/dist/link-records.js +31 -0
  45. package/dist/link-store-worker.js +150 -0
  46. package/dist/link-store.js +250 -0
  47. package/dist/logging.js +22 -0
  48. package/dist/management-policy.js +41 -0
  49. package/dist/match.js +124 -0
  50. package/dist/observability.js +242 -0
  51. package/dist/plugins.js +74 -0
  52. package/dist/policies/agents.js +248 -0
  53. package/dist/policies/cache.js +297 -0
  54. package/dist/policies/compression.js +187 -0
  55. package/dist/policies/security.js +0 -0
  56. package/dist/policies/throttle.js +131 -0
  57. package/dist/policies.js +142 -0
  58. package/dist/policy.js +55 -0
  59. package/dist/prerender.js +165 -0
  60. package/dist/project-tests.js +23 -0
  61. package/dist/readiness.js +216 -0
  62. package/dist/router.js +166 -0
  63. package/dist/runtime.js +244 -0
  64. package/dist/scaffold.js +0 -0
  65. package/dist/scripts/operational-drills.js +75 -0
  66. package/dist/server.js +270 -0
  67. package/dist/site.js +0 -0
  68. package/dist/sqlite-version.js +6 -0
  69. package/dist/types/adapters.d.ts +12 -0
  70. package/dist/types/agent-lists.d.ts +5 -0
  71. package/dist/types/assets.d.ts +13 -0
  72. package/dist/types/authoring.d.ts +2 -0
  73. package/dist/types/aws.d.ts +35 -0
  74. package/dist/types/build-cloudflare.d.ts +14 -0
  75. package/dist/types/cli.d.ts +2 -0
  76. package/dist/types/client-address.d.ts +9 -0
  77. package/dist/types/cloudflare.d.ts +75 -0
  78. package/dist/types/compliance-rules/baseline.d.ts +12 -0
  79. package/dist/types/compliance-rules/privacy.d.ts +6 -0
  80. package/dist/types/compliance-rules/shared.d.ts +24 -0
  81. package/dist/types/compliance-rules/strict.d.ts +9 -0
  82. package/dist/types/compliance.d.ts +119 -0
  83. package/dist/types/config-worker.d.ts +1 -0
  84. package/dist/types/config.d.ts +21 -0
  85. package/dist/types/errors.d.ts +7 -0
  86. package/dist/types/function-sources.d.ts +21 -0
  87. package/dist/types/function-worker.d.ts +1 -0
  88. package/dist/types/functions.d.ts +92 -0
  89. package/dist/types/guest-api.d.ts +16 -0
  90. package/dist/types/header-validation.d.ts +2 -0
  91. package/dist/types/http-policy.d.ts +39 -0
  92. package/dist/types/http-response.d.ts +43 -0
  93. package/dist/types/index.d.ts +11 -0
  94. package/dist/types/link-api.d.ts +30 -0
  95. package/dist/types/link-cli.d.ts +36 -0
  96. package/dist/types/link-events.d.ts +27 -0
  97. package/dist/types/link-records.d.ts +11 -0
  98. package/dist/types/link-store-worker.d.ts +1 -0
  99. package/dist/types/link-store.d.ts +130 -0
  100. package/dist/types/logging.d.ts +9 -0
  101. package/dist/types/management-policy.d.ts +9 -0
  102. package/dist/types/match.d.ts +81 -0
  103. package/dist/types/observability.d.ts +104 -0
  104. package/dist/types/plugins.d.ts +27 -0
  105. package/dist/types/policies/agents.d.ts +86 -0
  106. package/dist/types/policies/cache.d.ts +82 -0
  107. package/dist/types/policies/compression.d.ts +41 -0
  108. package/dist/types/policies/security.d.ts +37 -0
  109. package/dist/types/policies/throttle.d.ts +55 -0
  110. package/dist/types/policies.d.ts +36 -0
  111. package/dist/types/policy.d.ts +21 -0
  112. package/dist/types/prerender.d.ts +31 -0
  113. package/dist/types/project-tests.d.ts +13 -0
  114. package/dist/types/readiness.d.ts +121 -0
  115. package/dist/types/router.d.ts +11 -0
  116. package/dist/types/runtime.d.ts +91 -0
  117. package/dist/types/scaffold.d.ts +17 -0
  118. package/dist/types/server.d.ts +37 -0
  119. package/dist/types/site.d.ts +12 -0
  120. package/dist/types/sqlite-version.d.ts +1 -0
  121. package/dist/types/types.d.ts +332 -0
  122. package/dist/types/vercel.d.ts +12 -0
  123. package/dist/types.js +177 -0
  124. package/dist/vercel.js +65 -0
  125. package/docs/AI-AUTHORING.md +113 -0
  126. package/docs/ASSETS.md +106 -0
  127. package/docs/AWS.md +77 -0
  128. package/docs/BEST-PRACTICES.md +267 -0
  129. package/docs/CAPACITY.md +208 -0
  130. package/docs/CLOUDFLARE.md +110 -0
  131. package/docs/COMPLIANCE.md +242 -0
  132. package/docs/DYNAMIC-LINKS.md +561 -0
  133. package/docs/FUNCTION-SECURITY.md +113 -0
  134. package/docs/HTTP.md +129 -0
  135. package/docs/INSTALL.md +90 -0
  136. package/docs/LOAD-TESTING.md +91 -0
  137. package/docs/LOCAL-DEVELOPMENT.md +99 -0
  138. package/docs/MANAGEMENT-SECURITY.md +82 -0
  139. package/docs/MIDDLEWARE.md +83 -0
  140. package/docs/MONITORING.md +132 -0
  141. package/docs/OBSERVABILITY.md +229 -0
  142. package/docs/OPERATIONAL-PROOF.md +42 -0
  143. package/docs/OPERATIONS.md +207 -0
  144. package/docs/ORGANIZATION.md +135 -0
  145. package/docs/PERFORMANCE.md +72 -0
  146. package/docs/PLUGINS.md +234 -0
  147. package/docs/POLICIES.md +489 -0
  148. package/docs/PRERENDER.md +193 -0
  149. package/docs/PROJECT-DIRECTION.md +51 -0
  150. package/docs/READINESS.md +125 -0
  151. package/docs/README.md +58 -0
  152. package/docs/RELEASE-READINESS.md +95 -0
  153. package/docs/RELEASE-SECURITY.md +72 -0
  154. package/docs/RESILIENCE.md +160 -0
  155. package/docs/ROUTING.md +95 -0
  156. package/docs/SANDBOX-REVIEW.md +59 -0
  157. package/docs/SCAFFOLDING.md +72 -0
  158. package/docs/SECURITY-AUDIT.md +124 -0
  159. package/docs/SITE.md +150 -0
  160. package/docs/SPECIFICATION.md +232 -0
  161. package/docs/SPIKE-EXTENSIONS.md +475 -0
  162. package/docs/STANDARDS.md +303 -0
  163. package/docs/STARTERS.md +51 -0
  164. package/docs/TUNNELS.md +75 -0
  165. package/docs/TYPESCRIPT.md +102 -0
  166. package/docs/VERCEL.md +107 -0
  167. package/docs/YAML-GUIDE.md +521 -0
  168. package/docs/YAML-REFERENCE.md +328 -0
  169. package/docs/policies/agents.md +182 -0
  170. package/docs/policies/cache.md +139 -0
  171. package/docs/policies/compression.md +168 -0
  172. package/docs/policies/security.md +160 -0
  173. package/docs/policies/throttle.md +103 -0
  174. package/examples/assets/.env.example +1 -0
  175. package/examples/assets/.gitattributes +2 -0
  176. package/examples/assets/Makefile +30 -0
  177. package/examples/assets/README.md +18 -0
  178. package/examples/assets/functions/hello.mjs +3 -0
  179. package/examples/assets/gitignore.template +7 -0
  180. package/examples/assets/public/about.html +2 -0
  181. package/examples/assets/public/assets/example.txt +1 -0
  182. package/examples/assets/public/guide.txt +1 -0
  183. package/examples/assets/tests/requests.json +44 -0
  184. package/examples/assets/urlcode.yaml +27 -0
  185. package/examples/aws/README.md +13 -0
  186. package/examples/aws/handler.mjs +5 -0
  187. package/examples/aws/public/index.html +3 -0
  188. package/examples/aws/public/notes.txt +1 -0
  189. package/examples/aws/template.yaml +33 -0
  190. package/examples/aws/tests/requests.json +9 -0
  191. package/examples/aws/urlcode.yaml +19 -0
  192. package/examples/cloudflare/README.md +18 -0
  193. package/examples/cloudflare/package.json +14 -0
  194. package/examples/cloudflare/tests/requests.json +9 -0
  195. package/examples/cloudflare/urlcode.yaml +24 -0
  196. package/examples/cloudflare/wrangler.toml +5 -0
  197. package/examples/compliance/README.md +20 -0
  198. package/examples/compliance/rules.mjs +65 -0
  199. package/examples/cookbook/README.md +21 -0
  200. package/examples/cookbook/functions/choice.mjs +4 -0
  201. package/examples/cookbook/functions/echo.mjs +3 -0
  202. package/examples/cookbook/functions/hello.mjs +3 -0
  203. package/examples/cookbook/functions/text.mjs +3 -0
  204. package/examples/cookbook/middleware/headers.mjs +6 -0
  205. package/examples/cookbook/public/about.html +1 -0
  206. package/examples/cookbook/public/assets/index.html +1 -0
  207. package/examples/cookbook/public/assets/site.css +1 -0
  208. package/examples/cookbook/public/favicon.svg +1 -0
  209. package/examples/cookbook/public/guide.txt +1 -0
  210. package/examples/cookbook/public/llms.txt +6 -0
  211. package/examples/cookbook/routes/code.yaml +55 -0
  212. package/examples/cookbook/routes/files.yaml +17 -0
  213. package/examples/cookbook/routes/policies.yaml +28 -0
  214. package/examples/cookbook/routes/redirects.yaml +38 -0
  215. package/examples/cookbook/routes/responses.yaml +26 -0
  216. package/examples/cookbook/tests/requests.json +254 -0
  217. package/examples/cookbook/urlcode.yaml +22 -0
  218. package/examples/live-links/README.md +11 -0
  219. package/examples/live-links/tests/requests.json +6 -0
  220. package/examples/live-links/urlcode.yaml +16 -0
  221. package/examples/monitoring/blackbox-jobs.yaml +37 -0
  222. package/examples/monitoring/prometheus-rules.yaml +88 -0
  223. package/examples/monitoring/prometheus-scrape.yaml +20 -0
  224. package/examples/monitoring/vector.toml +65 -0
  225. package/examples/prerender/README.md +48 -0
  226. package/examples/prerender/functions/page.mjs +6 -0
  227. package/examples/prerender/middleware/template.mjs +29 -0
  228. package/examples/prerender/prerender.d.mts +4 -0
  229. package/examples/prerender/prerender.mjs +63 -0
  230. package/examples/prerender/tests/requests.json +39 -0
  231. package/examples/prerender/urlcode.yaml +50 -0
  232. package/examples/tunnel/dev-with-ngrok.sh +61 -0
  233. package/examples/vercel/README.md +13 -0
  234. package/examples/vercel/api/index.js +5 -0
  235. package/examples/vercel/package.json +8 -0
  236. package/examples/vercel/public/index.html +3 -0
  237. package/examples/vercel/public/notes.txt +1 -0
  238. package/examples/vercel/tests/requests.json +48 -0
  239. package/examples/vercel/urlcode.yaml +19 -0
  240. package/examples/vercel/vercel.json +10 -0
  241. package/llms.txt +46 -0
  242. package/package.json +123 -0
  243. package/schemas/urlcode.schema.json +1230 -0
  244. package/starters/default/.gitattributes +1 -0
  245. package/starters/default/Makefile +30 -0
  246. package/starters/default/README.md +34 -0
  247. package/starters/default/functions/hello.mjs +3 -0
  248. package/starters/default/gitignore.template +8 -0
  249. package/starters/default/middleware/headers.mjs +6 -0
  250. package/starters/default/routes/functions.yaml +20 -0
  251. package/starters/default/routes/marketing/links.yaml +7 -0
  252. package/starters/default/starter.json +5 -0
  253. package/starters/default/tests/requests.json +56 -0
  254. package/starters/default/urlcode.yaml +8 -0
@@ -0,0 +1,303 @@
1
+ # Standards conformance audit
2
+
3
+ What URLCode implements of each RFC and specification it touches or names,
4
+ verified against the code and the test suite at the commit this file was
5
+ written for (`817f0ab`, 2026-09-17, Node v22.22.2). Every row cites the
6
+ lines that implement the behavior and the test that proves it; "untested"
7
+ means no test in `test/` asserts that specific behavior. Where the code was
8
+ not conclusive, the behavior was observed by running a server with the test
9
+ helpers and sending raw requests over `node:net` / `node:http`; those rows
10
+ say "probed".
11
+
12
+ Status vocabulary:
13
+
14
+ - **conforms**: the implemented subset follows the standard's requirements.
15
+ - **partial**: some of the standard is implemented and the rest is out of
16
+ scope or approximated; the deviation is stated with its rationale.
17
+ - **deviates**: the runtime does something the standard forbids or requires
18
+ differently; listed again in the gap list at the end.
19
+ - **not applicable**: the standard is only named, or is delegated to a
20
+ platform, and no code claims to implement it.
21
+
22
+ ## Summary
23
+
24
+ | Standard | Status | Section |
25
+ |---|---|---|
26
+ | RFC 9110 HTTP Semantics | partial (one deviation: HEAD `Content-Length` on non-asset routes) | [1](#1-rfc-9110-http-semantics) |
27
+ | RFC 9111 HTTP Caching | partial | [2](#2-rfc-9111-http-caching) |
28
+ | RFC 5861 stale-while-revalidate / stale-if-error | partial | [3](#3-rfc-5861-stale-while-revalidate-and-stale-if-error) |
29
+ | RFC 8246 immutable | conforms | [4](#4-rfc-8246-immutable) |
30
+ | RFC 9213 targeted cache control | conforms | [5](#5-rfc-9213-targeted-cache-control) |
31
+ | RFC 6585 additional status codes (429, 431) | partial (431 answered as 400) | [6](#6-rfc-6585-additional-http-status-codes) |
32
+ | RFC 9112 HTTP/1.1 message syntax | partial (absolute-form/asterisk-form 400; 414 answered as 400) | [7](#7-rfc-9112-http11-message-syntax) |
33
+ | RFC 3986 URI | partial (dot segments rejected, not normalized) | [8](#8-rfc-3986-uri-and-percent-encoding) |
34
+ | RFC 7239 Forwarded / X-Forwarded-For | not applicable (RFC 7239); X-Forwarded-For implemented | [9](#9-rfc-7239-forwarded-and-x-forwarded-for) |
35
+ | RFC 9309 robots.txt | not applicable (documented recipe only; recipe YAML is invalid) | [10](#10-rfc-9309-robotstxt) |
36
+ | RFC 6797 HSTS | conforms | [11](#11-rfc-6797-hsts) |
37
+ | RFC 1950/1951/1952, 7932, 8878 content codings | conforms | [12](#12-rfc-19501952-rfc-7932-rfc-8878-content-codings) |
38
+ | RFC 6265 Set-Cookie | partial (transport only) | [13](#13-rfc-6265-set-cookie) |
39
+ | RFC 6266 / RFC 8187 Content-Disposition | conforms | [14](#14-rfc-6266--rfc-8187-content-disposition) |
40
+ | draft-ietf-httpapi-ratelimit-headers | conforms to the draft syntax (Internet-Draft, not an RFC) | [15](#15-ietf-httpapi-ratelimit-header-fields-internet-draft) |
41
+ | web-bot-auth drafts | not applicable | [16](#16-web-bot-auth-drafts) |
42
+ | OWASP Secure Headers Project | conforms to the pinned table | [17](#17-owasp-secure-headers-project) |
43
+ | CSP Level 3 | partial (emits one obsolete directive from OSHP) | [18](#18-content-security-policy-level-3) |
44
+ | JSON Schema 2020-12 | conforms (Ajv 8.20.0) | [19](#19-json-schema-2020-12) |
45
+ | YAML 1.2 | conforms (JSON-compatible profile) | [20](#20-yaml-12) |
46
+ | CIDR notation | partial (IPv4-embedded IPv6 other than `::ffff:` mis-parsed) | [21](#21-cidr-notation-for---trusted-proxies) |
47
+
48
+ ## 1. RFC 9110 HTTP Semantics
49
+
50
+ | Requirement | Status | Behavior | Test |
51
+ |---|---|---|---|
52
+ | §9 methods; default GET/HEAD | conforms | `src/router.ts:10` `methodsDefault = ['GET','HEAD']`; explicit lists replace it (`:56`); schema enum at `schemas/urlcode.schema.json:107-118` | `test/http.test.ts` "literal precedence, methods, HEAD, query isolation, disabled/expired and health" |
53
+ | §15.5.6 405 with `Allow` | conforms | `src/runtime.ts:117-121` answers `405` with `allow: <route.methods>`; health probes at `src/server.ts:122`; Worker at `src/cloudflare.ts:96` | same test (`allow === 'GET, HEAD'`); `test/policies.test.ts` "interoperability: ... 405 carries policy headers"; `test/aws.test.ts` "a Lambda response carries exactly ..." (405 on POST) |
54
+ | §9.1 unrecognized method → 501 | partial | Node's parser refuses unknown tokens before the runtime sees them; `src/server.ts:155-157` `clientError` writes `400`. Probed: `BREW /r` → 400. Rationale: llhttp rejects at parse time; the runtime never sees the method | untested |
55
+ | §9.3.2 HEAD suppresses body | conforms | `src/http-response.ts:23-24` drops the body for HEAD and 204/205/304 | `test/http.test.ts` (HEAD `/go` body `''`); `test/http-policy.test.ts` lines 16, 23; `test/vercel.test.ts:49` |
56
+ | §8.6 `Content-Length` on HEAD MUST equal what GET would send | **deviates** | `src/http-response.ts:28` writes `result.contentLength ?? body?.length ?? 0`; for HEAD `body` is already `undefined` (`:24`), so a `respond` or function result without `contentLength` sends `content-length: 0`. Probed: `GET /r` → `content-length: 13`, `HEAD /r` → `content-length: 0`; same for a function route (25 vs 0). Assets set `contentLength` (`src/assets.ts:108,113`) and cache hits do (`src/policies/cache.ts:116`), so those are correct (probed: `HEAD /a` → 3000). Compression skips dynamic bodies on HEAD (`src/policies/compression.ts:158`) so the identity length would be the right value | untested (tests assert only that the HEAD body is empty) |
57
+ | §15.4 redirects: 301/302/303/307/308, absolute `Location` | conforms | schema enum `schemas/urlcode.schema.json:258-262`; `src/match.ts:77-90` builds `Location` from `new URL(...).href`; default 302 at `src/runtime.ts:127`; link handler `:144` | `test/http.test.ts` (location assertions); `test/http-policy.test.ts` "YAML response headers apply to functions and redirects" |
58
+ | §10.2.2 `Location` reserved from YAML | conforms | `src/http-policy.ts:7` reserved set includes `location`, `allow`, `content-range`, `accept-ranges`, `etag`, `last-modified`, `content-encoding` | `test/http-policy.test.ts` "invalid response policy and contradictory declarations fail activation" |
59
+ | §7.6.1 hop-by-hop headers never set by a handler | conforms | `src/http-response.ts:5` `forbiddenHeaders` (connection, keep-alive, transfer-encoding, content-length, upgrade, trailer, proxy-authenticate, proxy-authorization, te) stripped at `:16`, refused in YAML at `src/http-policy.ts:7,14`, and on error answers `:50` | `test/http-policy.test.ts` "invalid response policy ..." |
60
+ | §5.6.2 field names are tokens; §5.5 no CTLs in values | conforms | `src/header-validation.ts:6-7` (`token` regex, `invalidValue = /[^\t -~-ÿ]/`), applied at `src/http-response.ts:17`, `src/http-policy.ts:18-19`, `src/policies/security.ts:73` | `test/header-validation.test.ts` all three tests (compared against `node:http` over 0x00–0x11F) |
61
+ | §8.8.3 ETag, strong vs weak | conforms | Assets: strong `"sha256(type+disposition+body)"` at `src/assets.ts:51`. Precompressed variants: strong tag with coding suffix inside the quotes, `src/policies/compression.ts:18,130`. Dynamically compressed bodies: handler tag weakened to `W/...` (`:130`). `revalidate` strategy: strong SHA-256 over the body when the handler sent none, `src/policies/cache.ts:183` | `test/assets.test.ts` "asset conditions, byte ranges ..."; `test/policy-compression.test.ts` "page assets are precompressed once, served by reference with a suffixed strong ETag, and revalidate" and "function JSON compresses with a weak ETag ..."; `test/policy-cache.test.ts` "revalidate answers 304 ..." |
62
+ | §8.8.2 `Last-Modified` | conforms | second-resolution IMF-fixdate from mtime, `src/assets.ts:43` | `test/assets.test.ts` (304 on `if-modified-since`) |
63
+ | §13.1.1 `If-Match` strong comparison | conforms | `src/assets.ts:90,92`: `weak=false` compares the full tag, so `W/"x"` never matches; `*` matches | `test/assets.test.ts` (`if-match: W/<etag>` → 412) |
64
+ | §13.1.2 `If-None-Match` weak comparison | conforms | `src/assets.ts:90,94` strips `W/`; `*` matches; `src/policies/cache.ts:166-169` and `src/policies/compression.ts:133-135` do the same for their own tags | `test/assets.test.ts` (`W/<etag>` → 304); `test/policy-cache.test.ts` "revalidate answers 304 ..." |
65
+ | §13.1.3 / §13.1.4 date conditions ignored when the ETag condition is present; invalid dates ignored | conforms | `src/assets.ts:93` (`!match && ...`), `:94` (`none ? ... : ...`); `Date.parse` → `NaN` makes both comparisons false, i.e. the header is ignored | `test/assets.test.ts` (`if-none-match: "other"` + matching `if-modified-since` → 200) |
66
+ | §13.2.2 evaluation order If-Match → If-Unmodified-Since → If-None-Match → If-Modified-Since → If-Range | conforms | `src/assets.ts:92-97` in that order; Range evaluated after preconditions and only for GET | `test/assets.test.ts` (`range` + `if-none-match` → 304; HEAD + `range` → 200 with full length) |
67
+ | §14 Range, single byte range; §14.4 206 `Content-Range`; §15.5.17 416 with `bytes */size` | conforms | `src/assets.ts:95-110`; suffix and open-ended ranges, `BigInt` arithmetic (`:100-102`), 416 at `:105`; multiple/malformed/non-`bytes` units ignored (§14.2 "MAY ignore") | `test/assets.test.ts` "asset conditions, byte ranges, empty files and HEAD obey HTTP ordering" |
68
+ | §13.1.5 `If-Range` | partial | `src/assets.ts:97`: only an exact strong ETag match enables the range; a date `If-Range` is treated as non-matching and the full 200 is sent. Rationale (`src/assets.ts:96` comment, `docs/ASSETS.md`): a date validator is only usable when it is known to be strong; sending the full representation is the prescribed fallback for a non-matching validator | `test/assets.test.ts` (`if-range: "old"` → 200; `if-range: <etag>` → 206) |
69
+ | §15.4.5 304 header set | conforms | Assets keep `Content-Type`, `ETag`, `Last-Modified`, `Cache-Control`, `Accept-Ranges` (`src/assets.ts:87-89`); `Content-Length` omitted for 304 (`src/http-response.ts:23,28`). `revalidate` keeps `etag, cache-control, cdn-cache-control, vary, last-modified, content-location, expires, date, content-type` (`src/policies/cache.ts:190`) | `test/policy-cache.test.ts` "revalidate answers 304 ..." (checks `etag`, `cache-control`, `content-type` on the 304) |
70
+ | §12.5.3 `Accept-Encoding` negotiation | conforms | `src/policies/compression.ts:92-113`: q-weights, `*` for unnamed codings, highest weight wins, project order breaks ties; identity is sent when nothing is acceptable, including `identity;q=0` / `*;q=0` (§12.5.3 permits either identity or 415; the code chooses identity, comment at `:88-91`) | `test/policy-compression.test.ts` "negotiation follows RFC 9110 q-values with the project order as tie-break" |
71
+ | §8.4 `Content-Encoding` | conforms | set only by the compression policy (`src/policies/compression.ts:163`); reserved from YAML (`src/http-policy.ts:7`); request bodies with a non-identity `Content-Encoding` are refused with 415 (`src/http-policy.ts:42`) | `test/policy-compression.test.ts`; `test/http-policy.test.ts` "request body policies reject size, media, encoding and malformed JSON" |
72
+ | §12.5.5 `Vary` | conforms | `Vary: Accept-Encoding` added or merged, `*` respected (`src/policies/compression.ts:119-126`), also on 304/206 (`:143-144`); cache strategies merge declared `vary` names (`src/policies/cache.ts:156-165`) | `test/policy-compression.test.ts` "declared text responses compress ... Vary is set once"; `test/policy-cache.test.ts` "vary headers separate keys and are emitted ..." |
73
+ | §15 status codes used | conforms | 400 (`src/match.ts:17-24`, `src/http-policy.ts:40,47,50`), 404 (`src/runtime.ts:95,102`, `src/assets.ts:84`), 405 (`:117`), 410 (`:103`), 412/304/206/416 (assets), 413 (`src/server.ts:39,47`, `src/http-policy.ts:39`), 415 (`:42,44,49`), 429/503 (throttle), 502/504 (function failures, see `docs/SPECIFICATION.md` "Functions"), 503 (`src/server.ts:110,116,125`). Rationale for 502/504 on sandbox failures: the function pool is treated as an upstream | `test/http.test.ts`, `test/http-policy.test.ts`, `test/policy-throttle.test.ts` |
74
+ | §15.5.9 408 on idle timeout (SHOULD) | partial | `src/server.ts:152` `server.setTimeout(15000, socket => socket.destroy())` closes without a status; probed: an incomplete request head is dropped after ~15 s with no response bytes. Rationale: the socket is destroyed to free the slot; the requirement is a SHOULD | untested |
75
+ | §10.1.5 `User-Agent` | conforms | read-only matching in `src/policies/agents.ts:202-213`; the header value is never logged | `test/policy-agents.test.ts` "a bundled deny list refuses matching agents and logs the list name, never the header" |
76
+ | §10.2.3 `Retry-After` (delay-seconds) | conforms | `src/policies/throttle.ts:100` integer seconds | `test/policy-throttle.test.ts` "quota reached answers 429 with Retry-After and RateLimit headers" |
77
+ | §6.4.1 `Content-Length` on every non-bodyless response | conforms | `src/http-response.ts:28`, error answers `:57` | `test/aws.test.ts` (header comparison includes `content-length`); `test/policy-compression.test.ts` (`content-length` equals encoded length) |
78
+
79
+ ## 2. RFC 9111 HTTP Caching
80
+
81
+ | Requirement | Status | Behavior | Test |
82
+ |---|---|---|---|
83
+ | §5.2.2 response directives `no-store`, `no-cache`, `public`, `private`, `max-age` | conforms | strategy table `src/policies/cache.ts:63-82`: `no-store`→`no-store`, `revalidate`→`no-cache`, `public`→`public, max-age=N`, `private`→`private, max-age=N`; runtime default `cache-control: no-store` when a handler sets none (`src/http-response.ts:30`), errors always `no-store` (`:48`); throttle and agents denials `no-store` (`src/policies/throttle.ts:100`, `src/policies/agents.ts:212`) | `test/policy-cache.test.ts` "each strategy emits its catalogue headers; explicit fields override what the strategy implies" |
84
+ | §5.1 `Age` on a stored response | conforms | `src/policies/cache.ts:112-117` replaces any `Age` with `floor((now-storedAt)/1000)` | `test/policy-cache.test.ts` "swr serves fresh from the origin cache ..." |
85
+ | §3 storing: never store `Set-Cookie`, `private`, `no-store`, secret-bearing or oversized results | conforms | `src/policies/cache.ts:208,224-225`; only statuses in `statuses` (default `200,301,302,404,410`, `:16`) | `test/policy-cache.test.ts` "Set-Cookie, secret-bearing routes, handler no-store and oversized bodies are never stored" |
86
+ | §4.1 `Vary` in the cache key | conforms | key = route, path, query, declared `vary` header values (`src/policies/cache.ts:102-105`); `Vary` emitted on storable statuses (`:217`) | `test/policy-cache.test.ts` "vary headers separate keys and are emitted ..." |
87
+ | §4.3 validation requests never served from the origin cache | conforms | `src/policies/cache.ts:119-124` bypasses for `If-None-Match`, `If-Modified-Since`, `If-Match`, `If-Unmodified-Since`, `Range` | `test/policies.test.ts` "interoperability: conditional requests bypass origin hits ..." |
88
+ | §4.4 invalidation on unsafe methods | partial | only GET results are stored and only GET/HEAD looked up (`:121,145`); a POST to the same route does not invalidate an entry. Rationale: the store belongs to one runtime and is dropped on reload (`:263-269`); handlers are side-effect free by design | untested |
89
+ | §5.2.1 request directives (`no-cache`, `max-age=0`, `no-store`) honored by a cache | **partial** | the origin memory cache ignores request `Cache-Control` and `Pragma`. Probed: `GET /pub` with `Cache-Control: no-cache` and with `max-age=0` both served from memory with `age: 0`. Rationale (`src/policies/cache.ts:4-11` comment, `docs/policies/cache.md` "Origin memory cache"): this is the origin's own micro-cache in the NGINX `proxy_cache` sense, part of the origin server rather than a shared cache in the RFC 9111 sense; a client cannot bypass it, as it cannot bypass an origin's internal memoization | untested |
90
+ | §4.2.4 serving stale | partial | stale served only within `stale-while-revalidate` and only once per entry (`:133`), never beyond; see §3 below | `test/policy-cache.test.ts` "swr serves fresh ... serves stale once and refreshes on the next request" |
91
+ | `micro` strategy: `no-store` to clients, 1–5 s origin memory | conforms (origin-side) | `src/policies/cache.ts:77-80`; the client-facing header is honest (`no-store`) while the origin memoizes | `test/policy-cache.test.ts` "micro caches at the origin for one second while telling clients no-store" |
92
+ | Explicit YAML / handler `Cache-Control` beats the strategy | conforms | `src/policies/cache.ts:93-94,208-214` | `test/policy-cache.test.ts` "vary headers ... explicit YAML cache-control wins over the strategy" and "assets keep their handler cacheControl under an inherited policy ..." |
93
+
94
+ ## 3. RFC 5861 stale-while-revalidate and stale-if-error
95
+
96
+ | Requirement | Status | Behavior | Test |
97
+ |---|---|---|---|
98
+ | §3 `stale-while-revalidate=N` emitted | conforms | `src/policies/cache.ts:73,75` | `test/policy-cache.test.ts` "each strategy emits its catalogue headers" |
99
+ | §3 background revalidation at the origin cache | partial | a policy has no handle to the handler, so a stale hit is served once and flagged (`:133`); the next request refreshes synchronously; a failed refresh clears the flag (`:247-249`). Documented in `docs/policies/cache.md` "swr at the origin" | "swr serves fresh from the origin cache, serves stale once and refreshes on the next request"; "a failed fill releases waiters to the handler and never stores" |
100
+ | §4 `stale-if-error=N` emitted | conforms | `src/policies/cache.ts:75` | "each strategy emits its catalogue headers" |
101
+ | §4 serving stale on error at the origin | partial | header-only: `onError` (`:241-250`) only releases waiters and clears the flag; it never returns a fallback result although the runtime would accept one (`src/runtime.ts:163-167`). Documented in `docs/policies/cache.md` "sie limitation" | untested (no test asserts a 5xx is *not* replaced) |
102
+
103
+ ## 4. RFC 8246 immutable
104
+
105
+ | Requirement | Status | Behavior | Test |
106
+ |---|---|---|---|
107
+ | `immutable` extension emitted with a long `max-age` | conforms | `src/policies/cache.ts:68-72`: `public, max-age=<maxAge ?? 31536000>, immutable`; refused unless the pattern has an 8+ hex-char segment or a digest-named parameter, or `force: true` (`:23-24,69-70`) | `test/policy-cache.test.ts` "immutable is refused on unhashed paths and accepted with a hashed segment, a hash parameter or force" |
108
+ | Asset `cacheControl: public, max-age=31536000, immutable` | conforms | allowed literal in the schema; passed through at `src/assets.ts:53,87` | `test/assets.test.ts` "native page, download and static handlers ..." (cacheControl variants) |
109
+
110
+ ## 5. RFC 9213 targeted cache control
111
+
112
+ | Requirement | Status | Behavior | Test |
113
+ |---|---|---|---|
114
+ | `CDN-Cache-Control` as a Structured-Field dictionary | conforms | `cdn-only` emits `Cache-Control: no-store` plus `CDN-Cache-Control: max-age=N` (`src/policies/cache.ts:81,213`); reserved from YAML/`set` only indirectly (not in `src/http-policy.ts:7`; a YAML `CDN-Cache-Control` would be replaced by the strategy at `:212` unless YAML also sets `cache-control`) | `test/policy-cache.test.ts` "each strategy emits its catalogue headers" |
115
+
116
+ ## 6. RFC 6585 additional HTTP status codes
117
+
118
+ | Requirement | Status | Behavior | Test |
119
+ |---|---|---|---|
120
+ | §4 429 Too Many Requests with an explanatory body | conforms | `src/policies/throttle.ts:13,100-101`: `429`, `Too many requests\n`, `Retry-After`, `RateLimit*`; any 4xx/5xx `status` allowed with a matching short body | `test/policy-throttle.test.ts` "quota reached answers 429 ..." and "status other than 429 gets a matching short body" |
121
+ | §5 431 Request Header Fields Too Large | **deviates** | `src/server.ts:79` sets `maxHeaderSize: 16384`, but the custom `clientError` handler at `:155-157` writes `400 Bad Request` for every parse error. Node's default handler would send 431 for `HPE_HEADER_OVERFLOW` (and 408 for `ERR_HTTP_REQUEST_TIMEOUT`). Probed: a 20 000-byte header value → `400`. Rationale in code: one fixed minimal answer for any malformed head; no comment justifies losing 431 specifically | untested |
122
+
123
+ ## 7. RFC 9112 HTTP/1.1 message syntax
124
+
125
+ | Requirement | Status | Behavior | Test |
126
+ |---|---|---|---|
127
+ | §3.2.1 origin-form request target | conforms | `src/match.ts:16-26` requires a leading `/`, rejects `//`, `#`, controls, `\`, length > 8192 | `test/http.test.ts` "malformed and ambiguous URL encodings fail before routing" (`//evil.example`, `/%00` ...) |
128
+ | §3.2.2 absolute-form MUST be accepted by servers | **deviates** | `src/match.ts:17` rejects anything not starting with `/`. Probed: `GET http://127.0.0.1:<port>/r HTTP/1.1` → `400 Invalid request target`. Rationale (`docs/CLOUDFLARE.md` "The request target"): the self-hosted server inspects the request line verbatim and shares `parseTarget` with runtimes that never see absolute-form; the Worker/Lambda paths reconstruct an origin-form target | untested |
129
+ | §3.2.4 asterisk-form for `OPTIONS *` | deviates (minor) | same check; probed `OPTIONS * HTTP/1.1` → 400. No route can be `*`, so no handler could answer it anyway | untested |
130
+ | §3 414 for over-long request targets | deviates | `src/match.ts:17` throws `HttpError(400)` for targets over 8192 bytes; probed → `400 Invalid request target`. RFC 9112 §3: "MUST respond with a 414". Node's own limit (`maxHeaderSize`) would 431/400 earlier for the whole head | untested (test covers the 400 for other malformed targets only) |
131
+ | §3.2 missing `Host` → 400 | conforms | enforced by Node's parser; probed `GET /r HTTP/1.1` with no `Host` → 400 | untested |
132
+ | §6.1 `Content-Length` + `Transfer-Encoding` both present → reject | conforms | Node parser; probed → 400 | untested |
133
+ | §7 chunked request bodies | conforms | Node decodes; `src/server.ts:38-53` counts decoded bytes against the limit and answers 413 | `test/http.test.ts` "chunked oversized body returns 413 and server remains usable" |
134
+ | header field limits | conforms | `maxHeaderSize: 16384`, `headersTimeout: 10000`, `requestTimeout: 15000`, `keepAliveTimeout: 5000` (`src/server.ts:79`), `maxRequestsPerSocket 1000`, `maxConnections 1024` (`:153-154`); response headers ≤ 16 KiB / 256 pairs (`src/http-policy.ts:24,58`) | `test/http.test.ts` "limits reject oversized requests and responses" |
135
+ | §9.3 `Connection: close` on error answers | conforms | `src/http-response.ts:64`; `clientError` reply at `src/server.ts:156` | untested |
136
+ | duplicate header fields | conforms | raw headers walked (`src/server.ts:132-135`), counts passed to policy; duplicated scalar header parameters → 400 (`src/match.ts:59`), duplicated `Content-Type` → 400 (`src/http-policy.ts:41`), duplicate `X-Forwarded-For` ignored (`src/server.ts:138`), duplicate `X-Request-Id` not trusted (`:87`) | `test/http.test.ts` "header inputs are case insensitive and duplicate scalars fail" |
137
+ | HTTP/1.0 requests | conforms | Node answers with `Connection: close`; probed | untested |
138
+
139
+ ## 8. RFC 3986 URI and percent-encoding
140
+
141
+ | Requirement | Status | Behavior | Test |
142
+ |---|---|---|---|
143
+ | §2.1 `%HH` must be two hex digits, case-insensitive | conforms | `src/match.ts:19` `/%(?![0-9a-f]{2})/i` → 400; probed `/r%41` → decoded to `/rA` (404, i.e. accepted and decoded) | `test/http.test.ts` "malformed and ambiguous URL encodings fail before routing" (`/%ZZ`, `/ok?q=%`) |
144
+ | §2.2 encoded delimiters keep their encoding (`%2F` is not `/`) | conforms (stricter) | `%2F` and `%5C` in the path are refused with 400 rather than kept encoded (`src/match.ts:19`), so an encoded slash can never span a segment. Rationale: `docs/ROUTING.md` "Encoded slashes ... are rejected" | same test (`/%2f`, `/%5c`) |
145
+ | decode exactly once | conforms | `decodeURIComponent` once on path and once on query at `src/match.ts:21`; `%252F` stays `%2F` after one decode and is re-encoded into the redirect (`:78`) | same test (`/a%252Fb` → `.../a%252Fb`) |
146
+ | non-UTF-8 percent sequences | stricter than RFC 3986 | `/%ff` → 400 because `decodeURIComponent` requires UTF-8 (RFC 3987/WHATWG behavior); RFC 3986 itself permits arbitrary octets | same test (`/%ff`, `/ok?q=%ff`) |
147
+ | §5.2.4 dot segments | deviates (deliberate) | `.` and `..` segments are refused with 400 (`src/match.ts:24`) rather than removed by `remove_dot_segments`; `%2e` decodes to `.` and is refused the same way. Rationale: `docs/SPECIFICATION.md` "Routes" — no normalization so a route key means exactly one path | same test (`/..`, `/%2e`); `test/assets.test.ts` (`/assets/../urlcode.yaml`, `/assets/%2e%2e/urlcode.yaml` → 400) |
148
+ | §3.3 empty path segments | conforms | `//` at the start refused (`:17`, network-path ambiguity); internal empty segments only match a literal route containing them; `{param}` requires a non-empty segment (`:34`) | `test/http.test.ts` (`//evil.example`) |
149
+ | §3.4 query | conforms | split on the first `?` (`:18`); parsed by `URLSearchParams`, which applies `application/x-www-form-urlencoded` rules (`+` = space), a WHATWG rule RFC 3986 does not define; the validity check at `:21` mirrors it | "typed inputs, defaults, arrays, mapping and passthrough" |
150
+ | §3.5 fragment never in a request target | conforms | `#` refused (`:17`) | untested directly |
151
+ | non-ASCII octets in the target | conforms | refused by Node's parser (probed raw `GET /caf\xe9` → 400) | untested |
152
+ | route keys | conforms | `src/router.ts:24` forbids `?#%\` and whitespace/controls in route patterns; ≤ 2048 chars, ≤ 32 segments (`:26`) | `test/config.test.ts` "semantic validation rejects ambiguous routes and unsafe redirects" |
153
+ | redirect URL validation | conforms | absolute `http(s)`, no userinfo, placeholders only in path segments, single-component `encodeURIComponent` (`src/router.ts:91-109`, `src/match.ts:78`); 16 KiB `Location` cap (`:88`) | `test/http.test.ts` (`/p/hello%20world` → `.../hello%20world`); `test/config.test.ts` |
154
+
155
+ ## 9. RFC 7239 Forwarded and X-Forwarded-For
156
+
157
+ | Requirement | Status | Behavior | Test |
158
+ |---|---|---|---|
159
+ | RFC 7239 `Forwarded` field | **not applicable** (not implemented) | no code reads `Forwarded`; only `x-forwarded-for` is read (`src/server.ts:138`, `src/vercel.ts:46`). `docs/SPIKE-EXTENSIONS.md:212` claims "RFC 7239 `Forwarded` is accepted from the same trusted set", which is false (see factual errors) | none |
160
+ | `X-Forwarded-For` (de-facto, no RFC) trusted only from configured proxies | conforms to common practice | `src/client-address.ts:59-66`: peer must be in `--trusted-proxies`; walk from the right skipping trusted hops; all-trusted chain → leftmost; header ignored when absent or duplicated (`src/server.ts:138` requires exactly one field) | `test/policies.test.ts` "client identity trusts forwarded headers only from configured proxies" |
161
+ | malformed entries | partial, and mis-documented | entries that are not a bare IP are **filtered out and the walk continues** (`src/client-address.ts:62`); the test proves `'garbage, 198.51.100.1'` → `198.51.100.1`. The comment at `src/client-address.ts:58` and `docs/POLICIES.md:240` say "a malformed entry stops the walk at the peer", which is not what the code does. Consequence: RFC 7239-style `node:port` (`203.0.113.5:1234`) or `"[2001:db8::1]:443"` entries are dropped; probed `203.0.113.5:1234, 10.0.0.2` behind a trusted peer → client resolved as `10.0.0.2` (the proxy itself) | test proves the skip; the documented "stop at the peer" is untested because it is not implemented |
162
+ | bracketed IPv6 and IPv4-mapped peers | conforms | `normalizeAddress` strips `[...]` and `::ffff:` (`:68-75`) | same test (`::ffff:10.1.2.3` peer) |
163
+ | HSTS / origin never derived from `X-Forwarded-Proto`/`Host` | conforms | `src/policies/security.ts:100` uses the operator origin; `src/server.ts:151` | `test/policy-security.test.ts` "HSTS follows the request origin scheme" (`x-forwarded-proto: https` does not enable it) |
164
+
165
+ ## 10. RFC 9309 robots.txt
166
+
167
+ | Requirement | Status | Behavior | Test |
168
+ |---|---|---|---|
169
+ | Serving or honoring `robots.txt` | **not applicable** | the runtime neither serves nor reads `robots.txt`; `docs/policies/agents.md:153-178` gives a `respond` recipe. That recipe is **invalid**: it puts `headers:` under `respond`, which the schema refuses (`schemas/urlcode.schema.json:605-617` has only `status`, `text`, `json`; probed: `Invalid configuration at /routes/~1robots.txt/respond (additionalProperties)`). `respond.text` already defaults to `text/plain; charset=utf-8` (`src/http-policy.ts:33`), so the header is unnecessary | none |
170
+
171
+ ## 11. RFC 6797 HSTS
172
+
173
+ | Requirement | Status | Behavior | Test |
174
+ |---|---|---|---|
175
+ | §6.1 directive syntax `max-age=...; includeSubDomains` | conforms | `src/policies/security.ts:20` `max-age=31536000; includeSubDomains` | `test/policy-security.test.ts` "HSTS follows the request origin scheme" |
176
+ | §7.2 MUST NOT send STS over non-secure transport | conforms | emitted only when the configured origin is `https:` (`src/policies/security.ts:100,111`); never inferred from forwarded headers | same test (plain origin → absent; `x-forwarded-proto` ignored; `origin: https://...` → present) |
177
+ | Worker | conforms | scheme from the request URL (`docs/policies/security.md` "Targets") | `test/policy-security.test.ts` "the Worker emits the same security headers as the self-hosted server" |
178
+
179
+ ## 12. RFC 1950/1952, RFC 7932, RFC 8878 content codings
180
+
181
+ | Coding | Status | Behavior | Test |
182
+ |---|---|---|---|
183
+ | `gzip` (RFC 1952) | conforms | `zlib.gzipSync` (`src/policies/compression.ts:27`); dynamic level 6, stored 9 | `test/policy-compression.test.ts` "declared text responses compress per Accept-Encoding ..." (round-trips with `gunzipSync`) |
184
+ | `deflate` = zlib container (RFC 1950 over RFC 1951), as RFC 9110 §8.4.1.1 defines the coding | conforms | `zlib.deflateSync` (`:28`) produces the zlib format, not raw deflate | untested end to end (only `decode.deflate` is defined in the test) |
185
+ | `br` (RFC 7932) | conforms | `zlib.brotliCompressSync` with `BROTLI_PARAM_QUALITY` and `SIZE_HINT` (`:25-26`); dynamic quality 4, stored 9 | same test (`brotliDecompressSync` round-trip) |
186
+ | `zstd` (RFC 8878) | conforms when available | `zlib.zstdCompressSync` (`:29-30`); refused at activation when Node lacks it (`:57`); available on the audited Node (probed) | "zstd is honoured only when node:zlib provides it; serverless targets delegate the policy" |
187
+ | request-body decompression | not implemented (documented) | `src/http-policy.ts:42` → 415 for any non-identity `Content-Encoding`; `docs/HTTP.md` "no automatic decompression" | `test/http-policy.test.ts` "request body policies reject size, media, encoding ..." |
188
+ | BREACH mitigation (not an RFC; CVE-2013-3587) | conforms to the documented rule | skip when the route holds secrets or the response sets a cookie unless `allowWithSecrets` (`src/policies/compression.ts:146`) | "function JSON compresses with a weak ETag; Set-Cookie and secrets skip unless allowWithSecrets" |
189
+
190
+ ## 13. RFC 6265 Set-Cookie
191
+
192
+ | Requirement | Status | Behavior | Test |
193
+ |---|---|---|---|
194
+ | §3 one `Set-Cookie` header per cookie, never folded | conforms | cookies collected separately (`src/http-response.ts:18`) and written as an array (`:36`); YAML `Set-Cookie` is the only list-valued header (`src/http-policy.ts:15`); AWS format 2.0 `cookies` array (`src/aws.ts`) | `test/http.test.ts` "function Request/Response ABI, scoped bindings, cookies ..."; `test/aws.test.ts` "cookies arrive through the format 2.0 array and leave through it" |
195
+ | §4.1.1 cookie syntax validation | partial | only the generic header rules apply (token name, no CTLs); no attribute parsing, signing or `Cookie` request parsing (`docs/HTTP.md` "Still outside this contract") | n/a |
196
+ | cookies vs caching/compression | conforms | responses with `Set-Cookie` are never stored (`src/policies/cache.ts:225`) and not compressed by default (`src/policies/compression.ts:146`) | `test/policy-cache.test.ts` "Set-Cookie, secret-bearing routes ..."; `test/policy-compression.test.ts` |
197
+
198
+ ## 14. RFC 6266 / RFC 8187 Content-Disposition
199
+
200
+ | Requirement | Status | Behavior | Test |
201
+ |---|---|---|---|
202
+ | `attachment; filename=...; filename*=UTF-8''...` | conforms | `content-disposition@3.0.0` `create()` at `src/assets.ts:50`; filenames with separators or controls refused (`:49`) | `test/assets.test.ts` "native page, download and static handlers ..." (`filename*=UTF-8''r%C3%A9sum%C3%A9.bin`) and "assets reject unsafe paths ... " (`bad\r\nname`) |
203
+
204
+ ## 15. IETF httpapi RateLimit header fields (Internet-Draft)
205
+
206
+ The vocabulary is **draft-ietf-httpapi-ratelimit-headers**, an IETF
207
+ Internet-Draft of the HTTPAPI working group. It is not an RFC; its field
208
+ names and syntax have changed between revisions and may change again.
209
+
210
+ | Requirement | Status | Behavior | Test |
211
+ |---|---|---|---|
212
+ | `RateLimit-Policy` as a Structured-Field list of items with `q` and `w` parameters | conforms to the current draft syntax | `src/policies/throttle.ts:76` `"default";q=<quota>;w=<window>` | `test/policy-throttle.test.ts` "quota reached answers 429 with Retry-After and RateLimit headers" (`'"default";q=2;w=60'`) |
213
+ | `RateLimit` item with `r` (remaining) and `t` (seconds to reset) | conforms | `:76` `"default";r=<remaining>;t=<reset>`; `t` from the sliding-window end (`:72`) | same test (`/^"default";r=1;t=\d+$/`) |
214
+ | present on allowed and refused responses | conforms | request phase attaches the budget (`:97`), response phase writes it (`:104-107`); refusals carry it directly (`:100-101`) | same test; `test/policies.test.ts` "... 405 carries policy headers" |
215
+ | `pk` (partition key) parameter | not emitted | keys are internal (`:44-50`) | n/a |
216
+ | refused requests not counted | conforms to the draft's guidance | `:89-91` | "window slides: the previous window fades out instead of resetting at once" |
217
+
218
+ ## 16. web-bot-auth drafts
219
+
220
+ | Requirement | Status | Behavior | Test |
221
+ |---|---|---|---|
222
+ | HTTP Message Signatures for bots (IETF `web-bot-auth` drafts) | **not applicable** | named in `docs/policies/agents.md:66-69` as future plugin territory; no code | none |
223
+
224
+ ## 17. OWASP Secure Headers Project
225
+
226
+ | Requirement | Status | Behavior | Test |
227
+ |---|---|---|---|
228
+ | OSHP "best practices" values | conforms to the pinned table | `src/policies/security.ts:19-29`, pinned as `oshpRevision = 'OSHP 2024 best practices'` (`:13`); docs table generated from the same constant; `X-Content-Type-Options: nosniff` supplied by the runtime on every response (`src/http-response.ts:29,48`) rather than the profile. The upstream JSON could not be fetched from the audit environment (proxy 403), so the table is verified against the code and docs, not re-verified against owasp.org | `test/policy-security.test.ts` "profile tables are frozen, ordered and consistent with each other", "profile headers land on redirects, declared responses and function results; existing headers win" |
229
+ | fill-gaps semantics; `set`/`unset` | conforms | `src/policies/security.ts:98-116` | "set overrides the profile and existing headers; unset drops a profile header" |
230
+ | applied to errors and early denials | conforms | `src/runtime.ts:80-83,142`; `src/http-response.ts:46-58` | `test/policies.test.ts` "error responses carry the security headers of the matched route or the project"; `test/policy-security.test.ts` "early results that skip the handler still carry the profile" |
231
+ | `X-Frame-Options` (RFC 7034, informational) `deny` | conforms | `:21` | as above |
232
+
233
+ ## 18. Content Security Policy Level 3
234
+
235
+ | Requirement | Status | Behavior | Test |
236
+ |---|---|---|---|
237
+ | CSP3 serialized policy syntax | conforms | `default-src 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content` (`src/policies/security.ts:24`) | `test/policy-security.test.ts` (value equality with the frozen table) |
238
+ | `block-all-mixed-content` | partial | this directive is obsolete in CSP3 / the current Mixed Content specification and is ignored by current browsers; it is emitted because OSHP recommends it. Harmless, but not a Level 3 directive | n/a |
239
+ | `upgrade-insecure-requests` | conforms | defined by the W3C Upgrade Insecure Requests specification, delivered through CSP; emitted regardless of scheme (browsers ignore it on plain HTTP) | n/a |
240
+ | Report-only and `report-to` | conforms (operator-supplied) | any `Content-Security-Policy-Report-Only` value is passed verbatim through `set` (`docs/policies/security.md`); the runtime does not host a reporting endpoint | "set overrides the profile ..." |
241
+
242
+ ## 19. JSON Schema 2020-12
243
+
244
+ | Requirement | Status | Behavior | Test |
245
+ |---|---|---|---|
246
+ | project schema dialect | conforms | `schemas/urlcode.schema.json:2` `$schema: https://json-schema.org/draft/2020-12/schema`; compiled by `ajv/dist/2020.js` (Ajv 8.20.0, `package.json:60`) at `src/config.ts:6,10` with `strict: true`, `allowUnionTypes: true`, `allErrors: false` | `test/config.test.ts` "strict schema rejects unknown features and multiple handlers"; `test/policies.test.ts` "policies validate in YAML and unknown keys fail" |
247
+ | input parameter schemas (subset: `type`, `enum`, `default`, `minLength`/`maxLength`, `minimum`/`maximum`, `items`, `maxItems`) | conforms | `src/router.ts:14-22` compiles each distinct schema with Ajv 2020 (`strict: false`, `:44`); `default` is stripped before compiling because 2020-12 treats it as an annotation (`:15`); semantic cross-checks at `:63-71`; unsupported keywords (`pattern`, `format`, nested objects) are refused by the project schema, not silently ignored | `test/http.test.ts` "typed inputs, defaults, arrays, mapping and passthrough" |
248
+ | standalone validators for the Worker | conforms | precompiled by the build into ES modules (`docs/CLOUDFLARE.md` "validators.js") | `test/cloudflare.test.ts` "the generated Worker entry and validators carry no imports the platform cannot resolve" |
249
+ | bounded compilation | conforms | ≤ 1024 distinct schemas per snapshot (`src/router.ts:18`) | untested |
250
+
251
+ ## 20. YAML 1.2
252
+
253
+ | Requirement | Status | Behavior | Test |
254
+ |---|---|---|---|
255
+ | YAML 1.2 core schema, JSON-compatible profile | conforms | `yaml@2.9.1` (`package.json:65`) `parseDocument(text, { version: '1.2', uniqueKeys: false, strict: true })` at `src/config.ts:14`; parser errors and warnings fail (`:15`) | `test/config.test.ts` "YAML rejects ambiguity and nonportable constructs" |
256
+ | no aliases, anchors, tags, merge keys, multiple documents | conforms (deliberate subset) | `:17` (aliases/anchors/tags), `:37` (`<<` and prototype keys), multi-document rejected by the parser in strict mode, `maxAliasCount: 0` (`:30`) | same test (`&x`/`*x`, `!custom`, `!!str`, `---`, `<<`) |
257
+ | string keys only, unique keys | conforms | `:22-25` (linear duplicate check, documented at `:20-21`) | same test (`x: 1\nx: 2`) |
258
+ | scalars: string, finite number, boolean, null | conforms | `:28`, `:33` (`.inf`/`.nan` refused) | same test (`.inf`, `.NaN`) |
259
+ | YAML 1.2 boolean semantics (`yes`/`no` are strings) | conforms | follows from `version: '1.2'` | untested |
260
+ | resource bounds | conforms | 32 MiB per file, 64 MiB aggregate, 40 nesting levels, worker with heap and wall-clock limits (`src/config.ts:11,31,52,84-105`) | "configuration worker deadline terminates loading and releases admission"; "configuration worker enforces aggregate source budget across includes" |
261
+
262
+ ## 21. CIDR notation for `--trusted-proxies`
263
+
264
+ | Requirement | Status | Behavior | Test |
265
+ |---|---|---|---|
266
+ | `address[/prefix]` for IPv4 and IPv6 (RFC 4632 / RFC 4291 §2.3 notation) | conforms | `src/client-address.ts:25-32`; prefix defaults to the full length; prefix range checked; ≤ 256 ranges (`:36`); comma-separated string or array (`:35`) | `test/policies.test.ts` "client identity trusts forwarded headers only from configured proxies" (`10.0.0.0/8, ::1, 192.168.1.5`; `/33` refused) |
267
+ | host bits set beyond the prefix | lenient | `10.1.2.3/8` accepted and masked at match time (`:40-50`); probed | untested |
268
+ | IPv4-mapped IPv6 (`::ffff:a.b.c.d`) | conforms | `:15-16`, `:72-73` | same test (`::ffff:10.1.2.3`) |
269
+ | other IPv4-embedded IPv6 forms (`64:ff9b::1.2.3.4`, RFC 4291 §2.2 form 3) | **deviates** | `toBytes` splits on `:` and `parseInt`s each group as hex, so `1.2.3.4` becomes group value `1`; probed: `64:ff9b::1.2.3.4/96` → last group `0x0001` instead of `0x0102 0x0304`. Only `::ffff:` is special-cased | untested |
270
+ | zone identifiers (`fe80::1%eth0`) | accidental | `net.isIP` accepts them (probed → 6); `parseInt('1%eth0', 16)` yields `1`, so the zone is silently ignored | untested |
271
+
272
+ ## Gaps, ranked
273
+
274
+ 1. **HEAD `Content-Length: 0` on `respond`, function and middleware results** (RFC 9110 §8.6 MUST NOT; §1). Fix: in `src/http-response.ts:28` compute the length from `result.body` before it is dropped for HEAD — e.g. `const declared = result.contentLength ?? (result.body?.length ?? 0);` then `if (!bodyless) headers.push(['content-length', String(declared)])`. `docs/policies/compression.md:131-132` already describes the intended behavior ("reports the identity length"). Test: extend `test/http-policy.test.ts` "declarative text, JSON and empty responses use correct bodies and statuses" to assert HEAD `content-length` equals GET's for `/fn` and `/json`; the Cloudflare and AWS parity tests then cover the adapters.
275
+ 2. **431 and 408 collapsed into 400** (RFC 6585 §5; RFC 9110 §15.5.9; §6, §7). Fix: in `src/server.ts:155-157` branch on `error.code`: `HPE_HEADER_OVERFLOW` → `431`, `ERR_HTTP_REQUEST_TIMEOUT` → `408`, else `400`; keep `Connection: close` and `Content-Length: 0`. Test: add to `test/http.test.ts` "limits reject oversized requests and responses" a raw-socket request with a 20 KiB header expecting `431`.
276
+ 3. **`X-Forwarded-For` malformed-entry semantics are mis-documented and `node:port` entries are dropped** (§9). Fix (documentation, minimal): change `docs/POLICIES.md:240` and the comment at `src/client-address.ts:58` to "a malformed entry is skipped". Fix (behavior, optional): in `normalizeAddress` strip a trailing `:port` from IPv4 and `[v6]:port` forms before `isIP`. Test: extend `test/policies.test.ts` "client identity trusts forwarded headers only from configured proxies" with `'203.0.113.5:1234, 10.0.0.2'`.
277
+ 4. **absolute-form and asterisk-form request targets answered 400** (RFC 9112 §3.2.2 MUST accept; §7). Fix: in `src/server.ts:137` (and `requestLimit` at `src/runtime.ts:85`) rewrite `req.url` when it matches `/^https?:\/\/[^/?#]*(\/.*)?$/i` to its path-and-query part (or `/` when empty) before `parseTarget`; leave `*` as 400 or answer `OPTIONS *` with 405 + `Allow`. Test: raw-socket request in `test/http.test.ts` expecting the same answer as origin-form.
278
+ 5. **414 for over-long targets answered 400** (RFC 9112 §3; §7). Fix: `src/match.ts:17` split the length check into its own `throw new HttpError(414, 'URI too long')`. Test: `test/http.test.ts` "malformed and ambiguous URL encodings fail before routing" add a 9000-byte path expecting 414.
279
+ 6. **origin memory cache ignores request `Cache-Control: no-cache` / `max-age=0`** (RFC 9111 §5.2.1; §2). Either document it explicitly in `docs/policies/cache.md` "Origin memory cache" ("request cache directives are not honored: the store is part of the origin"), or add `'cache-control'` to the bypass at `src/policies/cache.ts:119-124` when it contains `no-cache` or `no-store`. Test: add a request with `cache-control: no-cache` to `test/policy-cache.test.ts` "swr serves fresh from the origin cache ..." asserting the chosen behavior.
280
+ 7. **IPv4-embedded IPv6 CIDRs other than `::ffff:`** (§21). Fix: in `toBytes` (`src/client-address.ts:17-22`), if the last group contains `.` convert it to two 16-bit groups. Test: `parseCidr('64:ff9b::1.2.3.4/96')` bytes in `test/policies.test.ts`.
281
+ 8. **`stale-if-error` is header-only** (RFC 5861 §4; §3): documented; `onError` could return `served(entry, age)` when an entry exists within `staleIfError` — the runtime already accepts a fallback (`src/runtime.ts:163-167`). Test: a function that throws after a first successful fill.
282
+ 9. **Untested conformance points** that hold today but have no guard: 304 omits `Content-Length` (assets and `revalidate`); `deflate` end-to-end round-trip; missing `Host` → 400; CL+TE → 400; `Connection: close` on error answers; YAML 1.2 `yes`/`no` as strings; ≤ 1024 input schemas. Each is one assertion in the test named in its row.
283
+ 10. **`block-all-mixed-content`** in the OSHP CSP (§18): obsolete but harmless; keep while the pinned OSHP revision recommends it, and note it in `docs/policies/security.md` next to the CSP paragraph.
284
+
285
+ ## Factual errors in existing documentation
286
+
287
+ - `docs/policies/agents.md:163-175`: the `robots.txt` recipe puts `headers:` under `respond:`; the schema refuses it. Remove the `headers:` line (the default content type is already `text/plain; charset=utf-8`), or move it to `response: { headers: { ... } }` beside `respond`.
288
+ - `docs/policies/compression.md:131-132`: "A dynamic body is not compressed for `HEAD` ... and reports the identity length" — it currently reports `content-length: 0` (gap 1). Until fixed, the sentence should read "reports no usable length (`Content-Length: 0`)"; after the fix the sentence is correct.
289
+ - `docs/POLICIES.md:240` and the comment at `src/client-address.ts:58`: "a malformed entry stops the walk at the peer" — the code skips the entry and continues (proven by the test). Should read "a malformed entry is skipped".
290
+ - `docs/SPIKE-EXTENSIONS.md:212`: "RFC 7239 `Forwarded` is accepted from the same trusted set" — nothing reads `Forwarded`. Should read "only `X-Forwarded-For` is read; RFC 7239 `Forwarded` is not parsed".
291
+
292
+ ## How to keep this current
293
+
294
+ - Any change to `src/http-response.ts` (what a response *is*) must extend `test/http-policy.test.ts` "declarative text, JSON and empty responses ..." and rerun the parity tests (`test/aws.test.ts` "a Lambda response carries exactly ...", `test/cloudflare.test.ts` "the Worker runtime answers exactly ..."), then update §1 here.
295
+ - Any change to `src/match.ts` `parseTarget` or `src/router.ts` `segments` must extend `test/http.test.ts` "malformed and ambiguous URL encodings fail before routing" and update §7–§8.
296
+ - Any change to `src/assets.ts` conditional or range logic must extend `test/assets.test.ts` "asset conditions, byte ranges, empty files and HEAD obey HTTP ordering" and update §1 (validators, ranges).
297
+ - A new cache strategy or directive touches `src/policies/cache.ts:63-82` and `test/policy-cache.test.ts` "each strategy emits its catalogue headers"; update §2–§5.
298
+ - A new content coding touches the `codecs` table in `src/policies/compression.ts:24-31` and `test/policy-compression.test.ts` (round-trip decode table at the top of the file); update §12.
299
+ - A change to the OSHP table bumps `oshpRevision` in `src/policies/security.ts:13`, regenerates the docs table, and must keep `test/policy-security.test.ts` "profile tables are frozen, ordered and consistent" passing; update §17–§18.
300
+ - A change to the RateLimit field syntax (when the draft moves) touches `src/policies/throttle.ts:75-77` and the header regexes in `test/policy-throttle.test.ts`; update §15 with the draft revision.
301
+ - A change to `--trusted-proxies` parsing touches `src/client-address.ts` and `test/policies.test.ts` "client identity trusts forwarded headers only from configured proxies"; update §9 and §21.
302
+ - A change to the YAML profile or schema dialect touches `src/config.ts:10-42`, `test/config.test.ts` "YAML rejects ambiguity ..." and "strict schema rejects unknown features ..."; update §19–§20.
303
+ - When a gap above is closed, delete its row from the ranked list and flip the row status in the table; when a new standard is touched, add a section with the same four columns (requirement, status, behavior with `file:line`, test name).
@@ -0,0 +1,51 @@
1
+ # One starter, two examples
2
+
3
+ There is one starting project: a URL that runs a function and a regular redirect.
4
+ No `dynamic` versus `redirects` choice, and no `--template` option.
5
+
6
+ With URLCode installed:
7
+
8
+ ```sh
9
+ urlcode init ../my-links
10
+ urlcode dev --project ../my-links
11
+ urlcode audit --project ../my-links --expect-routes 2
12
+ ```
13
+
14
+ Or use the public GitHub template, which includes a pinned runtime dependency:
15
+
16
+ ```sh
17
+ git clone https://github.com/jimhoyd-com/urlcode-template.git my-links
18
+ cd my-links
19
+ npm ci
20
+ npm run dev
21
+ npm run audit
22
+ ```
23
+
24
+ GitHub's **Use this template** button creates your own repository directly.
25
+ These are two ways to obtain the same route examples, not two project types.
26
+ The CLI copies app files from `starters/default` and uses the installed runtime;
27
+ the public repository adds npm dependency/lockfile/CI for independent installation.
28
+ Neither path forks the runtime or needs a hosting account or database.
29
+
30
+ ## Files and growth
31
+
32
+ `urlcode.yaml` includes a function route file and a redirect file in a nested
33
+ folder. A JavaScript function, HTTP assertions and optional Makefile are included.
34
+ See [organization](ORGANIZATION.md) for choosing your own layout. Defaults allow
35
+ GET/HEAD and use redirect 302; add configuration only when changing behavior.
36
+
37
+ Initialization refuses an existing destination. Own the app in your own repository,
38
+ keep secrets out of Git, and upgrade the runtime separately without regenerating
39
+ application files. Add pages, downloads, more functions and business-specific
40
+ features to this same project. Update tests and the expected route count as it grows.
41
+ See [readiness](READINESS.md) and [security](FUNCTION-SECURITY.md).
42
+
43
+ `examples/assets` contains a larger runnable file/page/download demonstration:
44
+ `make dev PROJECT=examples/assets`. It is an example, not a separate init choice.
45
+
46
+ The old `starter-dynamic` and `starter-redirects` branches are historical snapshots;
47
+ use `urlcode-template` for new clones. They are no longer maintained or advertised
48
+ as onboarding paths. Existing projects remain ordinary valid URLCode apps.
49
+
50
+ The runtime is licensed under Apache-2.0. Provider adapters follow the
51
+ [roadmap](../ROADMAP.md).
@@ -0,0 +1,75 @@
1
+ # Public tunnels for local development
2
+
3
+ A tunnel gives a project running on your machine a public HTTPS address, which
4
+ is what you need to test redirects from a phone, receive a provider's webhook,
5
+ complete an OAuth callback or show someone a work in progress.
6
+
7
+ URLCode has one requirement that matters here, and one warning.
8
+
9
+ ## Always pass `--origin`
10
+
11
+ Forwarded headers are deliberately not trusted, so the runtime cannot infer its
12
+ public address. Without `--origin`, functions receive `http://127.0.0.1:3000/...`
13
+ as `request.url` even though the visitor arrived over HTTPS at a public host —
14
+ so anything built from the request URL is wrong: OAuth redirect URIs, absolute
15
+ links in generated HTML, canonical URLs, signature checks.
16
+
17
+ ```sh
18
+ urlcode dev --project . --origin https://your-subdomain.ngrok-free.app
19
+ ```
20
+
21
+ The startup record reports what was chosen, so you can confirm it:
22
+
23
+ ```json
24
+ {"event":"listening","address":"127.0.0.1","port":3000,"mode":"dev","origin":"https://your-subdomain.ngrok-free.app"}
25
+ ```
26
+
27
+ `--origin` must be a bare HTTP(S) origin: no path, no credentials, no trailing
28
+ slash. It works with `serve` too, and behind a reverse proxy it is the same
29
+ mechanism.
30
+
31
+ ## ngrok
32
+
33
+ Start the tunnel, then start URLCode with the origin it gave you:
34
+
35
+ ```sh
36
+ ngrok http 3000
37
+ urlcode dev --project . --origin https://your-subdomain.ngrok-free.app
38
+ ```
39
+
40
+ Because that address changes on every restart of a free tunnel,
41
+ [`examples/tunnel/dev-with-ngrok.sh`](../examples/tunnel/dev-with-ngrok.sh)
42
+ reads it from the ngrok agent's local API and passes it for you:
43
+
44
+ ```sh
45
+ ngrok http 3000 # terminal 1
46
+ PROJECT=. ./examples/tunnel/dev-with-ngrok.sh # terminal 2
47
+ ```
48
+
49
+ It selects the HTTPS tunnel forwarding to your port and refuses to guess when
50
+ there is no match, so a second unrelated tunnel on the same agent cannot send
51
+ your traffic somewhere unexpected. `--print` shows the resolved origin without
52
+ starting anything. It discovers a tunnel; it does not start, stop or configure
53
+ ngrok, and it holds no ngrok credentials.
54
+
55
+ Other tunnels — Cloudflare Tunnel, Tailscale Funnel, localtunnel, an SSH remote
56
+ forward — work the same way: get the public origin, pass it to `--origin`.
57
+
58
+ ## What you are exposing
59
+
60
+ A tunnel makes a development server reachable by anyone with the URL, including
61
+ scanners that find it within minutes.
62
+
63
+ - `dev` loads `.env.local` and watches files. Any secret in that file is
64
+ available to the functions you just published.
65
+ - The runtime has **no authentication and no rate limiting**. Put access control
66
+ in the tunnel: ngrok's OAuth, OIDC or basic auth; Cloudflare Access; a
67
+ Tailscale ACL.
68
+ - **Never tunnel the link-management API.** It is a separate listener bound to
69
+ loopback on purpose, holding credentials that can rewrite live links. Tunnel
70
+ the application port only.
71
+ - Prefer `serve` with a fixed snapshot, a project containing no real secrets,
72
+ and a tunnel you shut down when finished.
73
+
74
+ A tunnel is for development and demos. For a real deployment, terminate TLS at a
75
+ proxy you control and read [operations](OPERATIONS.md).
@@ -0,0 +1,102 @@
1
+ # Using URLCode from TypeScript
2
+
3
+ The runtime is written in TypeScript and the published package ships
4
+ declarations for every export, so an operator application, a plugin, an
5
+ observer, a compliance rules module or a build script can be type-checked
6
+ against the same contract the runtime enforces at load time. Nothing in the
7
+ project format changes: YAML and sandboxed functions are unaffected, and a
8
+ JavaScript application keeps working exactly as before.
9
+
10
+ ## What ships
11
+
12
+ The tarball contains `dist/`: one `.js` per runtime module, `dist/types/*.d.ts`
13
+ beside them, and `dist/BUILD-MANIFEST.json` with a SHA-256 per emitted file.
14
+ `package.json` resolves every subpath through export conditions:
15
+
16
+ | Import | Runtime | Declarations |
17
+ |---|---|---|
18
+ | `urlcode` | `dist/index.js` | `dist/types/index.d.ts` |
19
+ | `@jimhoyd/urlcode/plugins`, `@jimhoyd/urlcode/policies`, `@jimhoyd/urlcode/observability`, `@jimhoyd/urlcode/compliance`, `@jimhoyd/urlcode/prerender` | `dist/<name>.js` | `dist/types/<name>.d.ts` |
20
+ | `@jimhoyd/urlcode/aws`, `@jimhoyd/urlcode/vercel`, `@jimhoyd/urlcode/cloudflare` | `dist/<name>.js` | `dist/types/<name>.d.ts` |
21
+ | `@jimhoyd/urlcode/schema` | `schemas/urlcode.schema.json` | — |
22
+
23
+ Each entry has three conditions: `types` (the declarations), `default` (the
24
+ built JavaScript) and `development`, which points at the `.ts` source and
25
+ exists only so the repository's own tests and examples can import the package
26
+ by name without a build (`node --conditions=development`). An installed
27
+ package never uses it; Node refuses to strip types under `node_modules`.
28
+
29
+ Use `"module": "NodeNext"` (or `"bundler"`) resolution so the `exports` map
30
+ and its `types` condition apply. The declarations reference Node's types
31
+ (`Buffer`, `node:http`, `NodeJS.Timeout`), so a consumer needs `@types/node`,
32
+ as any Node application already has. The package smoke test installs the
33
+ packed tarball and type-checks a consumer that imports every subpath, so a
34
+ release cannot ship a declaration that does not resolve.
35
+
36
+ ## The main exported types
37
+
38
+ - `urlcode`: `Runtime`, `RuntimeOptions`, `RuntimeRequest`, `RequestTrace`,
39
+ `Server`, `ServerOptions`, `HostPlugin`, `Observer`, `TestPlan`, `LinkStore`,
40
+ `LinkRow`, `LinkStoreOptions`, `LinkReader`, `LinkStoreBinding`, `LinkApi`,
41
+ `LinkApiOptions`, `LinkEvent`, `LinkObserverOptions`.
42
+ - `@jimhoyd/urlcode/plugins`: `Plugin`, `PluginRuntime`, `PolicyRequest`,
43
+ `HandlerResult`, `HeaderPair`, `TargetName`. See [plugins](PLUGINS.md).
44
+ - `@jimhoyd/urlcode/policies`: `PolicyModule`, `PolicyRegistry`, `PolicyRequest`,
45
+ `PolicyContext`, `PolicyChain`, `PolicyShared`. See [policies](POLICIES.md).
46
+ - `@jimhoyd/urlcode/observability`: `Observer`, `ObserverEvent`, `MetricsSnapshot`,
47
+ `Metrics`, `RecordContext`. See [observability](OBSERVABILITY.md).
48
+ - `@jimhoyd/urlcode/compliance`: `ComplianceRule`, `ProjectRule`, `RouteRule`,
49
+ `ProjectContext`, `RouteContext`, `RawFinding`, `Finding`,
50
+ `ComplianceOptions`, `ComplianceReport`, `ComplianceProfileName`. See
51
+ [compliance](COMPLIANCE.md).
52
+ - `@jimhoyd/urlcode/prerender`: `PrerenderOptions`, `PrerenderedPage`,
53
+ `NativeProjectOptions`. See [prerendering](PRERENDER.md).
54
+ - `@jimhoyd/urlcode/aws`, `@jimhoyd/urlcode/vercel`, `@jimhoyd/urlcode/cloudflare`: `LambdaEvent`,
55
+ `LambdaHandler`, `LambdaHandlerOptions`; `VercelHandler`,
56
+ `VercelHandlerOptions`; `Artifact`, `WorkerRoute`, `Validators`.
57
+
58
+ ```ts
59
+ import { startServer, type ServerOptions, type Observer } from '@jimhoyd/urlcode';
60
+ import type { Plugin } from '@jimhoyd/urlcode/plugins';
61
+
62
+ const audit: Plugin = { name: 'audit', version: '1.0.0', targets: ['node'], onError(request, error) { console.error(request.route, error); } };
63
+ const forwarder: Observer = { name: 'forwarder', version: '1.0.0', onEvent(event) { queue.push(event); } };
64
+ const options: ServerOptions = { project: './site', port: 3000, plugins: [audit], observers: [forwarder], metrics: true };
65
+ const server = await startServer(options);
66
+ await server.close();
67
+ ```
68
+
69
+ ## How `dist/` is built, and why it is the same JavaScript
70
+
71
+ `npm run build` (`scripts/build.ts`) runs Node's own type stripping over every
72
+ `src/*.ts`, the same transform that runs the source in development, and
73
+ rewrites relative specifiers from `.ts` to `.js`. It does not bundle, minify,
74
+ down-level or transform syntax, and it refuses to emit a file whose line count
75
+ differs from its source, so every line and column of `dist/x.js` is the
76
+ corresponding line of `src/x.ts` with types turned into whitespace. The
77
+ `.d.ts` files come from `tsc` and never touch runtime output; the build fails
78
+ on any type error. `dist` is never committed: the release workflow builds it
79
+ in the digest-pinned container, records the Node and TypeScript versions and
80
+ the per-file hashes in the signed manifest, and CI's `build-fidelity` job
81
+ builds twice and diffs the trees. See [release security](RELEASE-SECURITY.md).
82
+
83
+ ## No runtime cost
84
+
85
+ Because the shipped JavaScript is the stripped source, the package runs what
86
+ it ran before the conversion. The measurement in
87
+ [performance](PERFORMANCE.md#typescript-conversion-2026-09-17) compared the
88
+ last plain-JavaScript commit with `dist/` on the same machine: CLI cold start,
89
+ routing throughput, p95 latency and RSS are within run-to-run spread. Running
90
+ the `.ts` source directly, as `npm run dev` does in a clone, costs about
91
+ 190 ms of cold start and 35 MiB for the stripping itself; that mode is the
92
+ developer loop and never ships.
93
+
94
+ ## Contributing in TypeScript
95
+
96
+ The source, scripts, tests and benchmarks are checked by `npm run typecheck`
97
+ (strict, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
98
+ `erasableSyntaxOnly`), which `npm run verify` runs. Only erasable syntax is
99
+ allowed, so no enums, namespaces or parameter properties: what Node can strip
100
+ is exactly what the build emits. Contributors need Node 22.18+ to run the
101
+ source; installed packages still run on 22.13+. See
102
+ [local development](LOCAL-DEVELOPMENT.md).