@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,207 @@
1
+ # Running URLCode yourself
2
+
3
+ This is the stable 0.1 self-hosted runtime. Its deliberately bounded feature set
4
+ is not a claim of suitability for every production workload. Deploy only workloads
5
+ whose requirements fit the [implemented contract](SPECIFICATION.md).
6
+ Provider adapters, automatic TLS/DNS management, distributed rate limits,
7
+ metrics exporters and durable event delivery are not included.
8
+
9
+ ## Process deployment
10
+
11
+ Install a reviewed URLCode commit with Node 22.13+ and `npm ci --omit=dev`.
12
+ Keep the runtime separate from an application checkout pinned to its own commit.
13
+ Functions support only relative project JavaScript modules; do not install or
14
+ execute an untrusted application’s package scripts as part of serving it. Validate using
15
+ the same injected environment as the serving process:
16
+
17
+ ```sh
18
+ node /opt/urlcode/dist/cli.js validate --project /srv/my-links
19
+ node /opt/urlcode/dist/cli.js serve --project /srv/my-links \
20
+ --host 127.0.0.1 --port 3000 --origin https://links.example.com
21
+ ```
22
+
23
+ `--origin` defines the public URL seen by functions; proxy Host/X-Forwarded-*
24
+ headers are intentionally not trusted. Use a process supervisor that restarts on
25
+ failure and sends SIGTERM for shutdown. Shutdown stops accepting requests, gives
26
+ HTTP connections up to 10 seconds, and drains bounded in-flight functions.
27
+
28
+ Serve a read-only application tree where practical. The operator-owned runtime
29
+ account must be able to read application files/dependencies. Authoring happens
30
+ in development/CI, not by modifying a running replica's filesystem.
31
+
32
+ ## Container deployment
33
+
34
+ The supplied image packages the runtime; it does not copy your application or
35
+ local secret files. Build from the reviewed runtime checkout:
36
+
37
+ ```sh
38
+ docker build -t urlcode:0.2.0 .
39
+ docker run --rm --name my-links \
40
+ --read-only --cap-drop ALL --security-opt no-new-privileges \
41
+ --memory 512m --cpus 1 --pids-limit 128 \
42
+ -p 127.0.0.1:3000:3000 \
43
+ -v "$PWD/starters/default:/project:ro" \
44
+ urlcode:0.2.0
45
+ ```
46
+
47
+ Replace the example mount with your app. The image uses the unprivileged `node`
48
+ user; ensure mounted config/functions are readable by it. Only operator-owned components such as the optional link store can use writable
49
+ mounts. Sandboxed application functions cannot access mounted files or installed
50
+ Node packages. The resource values above illustrate
51
+ container limits, not a sizing recommendation; large configuration compilation
52
+ can need more memory. Measure your workload. Tag/redeploy immutable image digests
53
+ in real operation rather than treating a mutable tag as a rollback identity.
54
+
55
+ ## Domains, HTTPS and exposure
56
+
57
+ Point your domain's DNS at the reverse proxy/load balancer you operate, terminate
58
+ HTTPS there, and forward to the loopback/private URLCode port. Use a tested proxy
59
+ such as your existing Caddy/nginx/load-balancer setup for certificates, connection
60
+ limits and rate limiting. No certificate automation is supplied by URLCode yet.
61
+ Keep direct backend access private. Restrict `/_urlcode/*` endpoints to operators
62
+ at the proxy; they are unauthenticated and reveal route count/config digest.
63
+
64
+ If functions perform sensitive actions, implement authentication and authorization
65
+ in the application. A short URL is not automatically an access-control mechanism.
66
+ Functions are untrusted and isolated in WASM by default. Keep separate deployment
67
+ processes/containers and narrowly scoped credentials as additional boundaries.
68
+ Do not expose a public code-upload/multi-tenant service on the basis of the self-hosted release alone
69
+ without separate security review and stronger service-level containment.
70
+
71
+ ## Secrets and rotation
72
+
73
+ `dev`, `test`, `routes`, `audit`, `benchmark` and `validate --local` read
74
+ `.env.local`. Authoring and
75
+ permissions inspection do not read credentials or execute functions. `serve` and
76
+ ordinary `validate` use process environment only. Resolve logical names from
77
+ your own secret store/supervisor and inject them at startup; direct provider
78
+ secret-store integrations remain future work. Bindings also require an external,
79
+ revision-pinned operator policy; see [setup](FUNCTION-SECURITY.md). Do not place secret values in
80
+ command-line arguments, route YAML, image layers or Git.
81
+
82
+ Check tracked files as well as ignore rules. Docker builds use an explicit
83
+ allowlist; npm artifacts include runtime/schema/starter/docs files only. Do not
84
+ build an application image by blindly copying its entire development directory.
85
+ Rotate a credential by replacing its injected value and restarting/redeploying;
86
+ production does not watch or refresh secret values automatically.
87
+
88
+ ## Health, logs and limits
89
+
90
+ - `GET /_urlcode/health`: process liveness.
91
+ - `GET /_urlcode/ready`: 200 when the active snapshot and all function workers
92
+ are available and configured link-store readers are healthy; 503 while a worker/store
93
+ is unavailable. Busy workers alone do not
94
+ mark readiness down. A failed worker or store connection is replaced with
95
+ exponential backoff (250 ms doubling to a 30-second ceiling) and readiness
96
+ reports 503 until every slot is serving again. Replacement does not stop, so a
97
+ request-triggered deadline cannot disable functions until an operator restarts;
98
+ a cause that keeps recurring keeps the instance shedding load and needs an
99
+ operator. Alert on sustained `function_worker`/`link_store_worker` restart events.
100
+ - Probes are answered from their own admission budget (16 by default,
101
+ `--max-in-flight-health`), so they stay available while the application is
102
+ saturated without being an unmetered endpoint. They are unauthenticated and
103
+ report the configuration digest and route count: keep them on an internal
104
+ interface or restrict them at the ingress.
105
+ - Request logs: JSON request ID, status and duration. No URLs, query strings,
106
+ headers, bodies, bindings or user exception text. `--request-log detailed` adds
107
+ the request method and the matched route pattern (`/u/{id}`, or `null` when
108
+ nothing matched). Both come from the reviewed configuration, never from
109
+ request-supplied path, parameter or query text, which is what makes per-route
110
+ error rates and latency available without logging user data.
111
+ - Request IDs are generated per request and returned in `x-request-id`. An
112
+ inbound `x-request-id` is ignored unless `--trust-request-id` is set, which is
113
+ only correct when a trusted proxy sets the header and strips client-supplied
114
+ copies; untrusted values are still rejected unless they are a single header of
115
+ at most 128 characters from `[A-Za-z0-9_.:-]`. Forward stdout to your log
116
+ system and alert on sustained 5xx and latency. The default logger drops records
117
+ when stdout buffering reaches 1 MiB and reports the dropped count when output
118
+ recovers; alert on `logs_dropped`. Function console output is
119
+ suppressed; app-specific diagnostics are not yet a first-class feature.
120
+ Synchronous and asynchronous sink failures are contained; a failed sink drops
121
+ subsequent output and needs operator recovery. Collectors own rotation/retention.
122
+ - HTTP: 8,192-character target, 16 KiB headers, 1 MiB buffered body, 15-second request
123
+ receipt timeout, 10-second header timeout, 5-second keep-alive, 1,000 requests
124
+ per socket and 1,024 active connections. At most 64 application requests are
125
+ admitted through response completion; excess requests receive 503. Health probes
126
+ remain available under admission saturation. A 15-second socket inactivity
127
+ timeout closes stalled readers/writers. Proxy timeouts/rate limits still matter.
128
+ - Functions: 2 concurrent workers (`--workers`), no queue, 5-second deadline
129
+ (`--function-timeout-ms`), 1 MiB buffered response (`--max-response-bytes`) and
130
+ 16 KiB response headers. Saturation 503; timeout 504; error 502.
131
+ QuickJS guests have a 32 MiB heap and 512 KiB stack budget and no network or
132
+ host capabilities. Outer workers have additional V8 limits. Total process/WASM
133
+ memory still needs deployment-level limits; do not equate guest budget with RSS.
134
+
135
+ `urlcode serve`/`dev` and the JavaScript server API both configure workers,
136
+ deadlines and byte limits: `--workers`, `--function-timeout-ms`,
137
+ `--max-response-bytes`, `--max-body-bytes`, `--max-in-flight` and
138
+ `--max-in-flight-health`. Set them on the container command line; these are
139
+ deployment controls, not portable route behavior. Horizontal replicas
140
+ must use identical application/config versions and secret bindings. In-memory
141
+ function state is reset after every invocation, not durable/shared application state.
142
+ General application storage needs a future explicit capability broker; no
143
+ storage/network access is exposed to the guest. The optional native
144
+ [link store](DYNAMIC-LINKS.md) supports live short-link records on one host.
145
+
146
+ The health version combines route-definition and asset-representation digests;
147
+ it does not identify the complete function/runtime release. Record runtime commit,
148
+ application commit, dependency locks and image digest in your deployment system.
149
+
150
+ ## Optional dynamic-link deployment
151
+
152
+ Keep SQLite and management tokens outside the application, in a private durable
153
+ local directory. Initialize through `links init/create`, bind public serving with
154
+ `--link-store`, and expose management on a separate private listener. Restrict
155
+ its token to your trusted backend; apply ingress limits and backups. Public
156
+ serving opens read-only pools; management has a separate writer and read pool.
157
+ Budget connections across processes and monitor writer health separately. See
158
+ [dynamic-link operations](DYNAMIC-LINKS.md). Multiple host replicas must not share
159
+ this file over a network filesystem; no distributed adapter is included yet.
160
+
161
+ ## Deployment and rollback procedure
162
+
163
+ 1. Build a candidate from pinned runtime/application revisions and lockfiles.
164
+ 2. Validate its config/bindings and run local HTTP tests without external redirects.
165
+ 3. Start it on an alternate private port/container. Check readiness and representative
166
+ redirect/function behavior through the intended proxy configuration.
167
+ 4. Switch proxy traffic after checks pass. Drain the previous instance before stopping.
168
+ 5. If checks or observed behavior fail, route traffic back to the retained previous
169
+ instance/image and its compatible secret bindings.
170
+
171
+ This is an operator procedure, not an implemented deployment control plane.
172
+ Rollback cannot undo a function's external side effects or migrate an app's
173
+ state automatically. Plan those independently. Keep Git definitions backed up;
174
+ back up any app-owned persistent state separately. YAML routes require no database; dynamic link records require separate backups.
175
+
176
+ ## Capacity and incident planning
177
+
178
+ See [capacity and concurrency](CAPACITY.md) for hard limits, worker occupancy,
179
+ no-queue rejection, memory/reload budgets and theoretical sizing. See
180
+ [DDoS and recovery](RESILIENCE.md) for ingress responsibilities, incident response,
181
+ rollback/restore procedures, recovery objectives and drills.
182
+
183
+ See the [release-readiness register](RELEASE-READINESS.md) for evidence and open gates.
184
+
185
+ ## Remaining production validation
186
+
187
+ Before approving a production deployment: run sustained soak/load tests on its
188
+ hardware, obtain independent security review, exercise failure/restart and
189
+ upgrade/rollback, establish a clear support/reporting policy, and add the needed
190
+ operational metrics. Provider adapters remain separate roadmap work. See
191
+ [roadmap](../ROADMAP.md). No claim of high
192
+ availability, zero downtime or provider portability beyond the Node process
193
+ adapter is made by the current release.
194
+
195
+ ## Security review
196
+
197
+ The [2026-09-16 internal audit](SECURITY-AUDIT.md) records fixes, regression evidence
198
+ and remaining security/operational gates. This is not an independent assessment.
199
+
200
+ ## Management hardening baseline
201
+
202
+ Management is now restricted to literal loopback addresses. Prefer `--auth-file`
203
+ for individual expiring, revocable credentials with collection/action scopes.
204
+ Every successful built-in store mutation has an atomic, durable SQLite audit row;
205
+ HTTP request logs remain best effort. See [management security](MANAGEMENT-SECURITY.md)
206
+ for policy examples, compatibility, archival and rollback requirements, and
207
+ [operational proof](OPERATIONAL-PROOF.md) for executable recovery drills.
@@ -0,0 +1,135 @@
1
+ # Organize routes your way
2
+
3
+ `urlcode.yaml` is the project entry point. The rest of the folder structure is
4
+ yours. Use one file for a few links or split routes by feature, team, campaign,
5
+ customer or another useful grouping. Folder names do not create URL prefixes.
6
+ Explicit file composition is already implemented; no new runtime mode is needed.
7
+
8
+ ## One file
9
+
10
+ ```yaml
11
+ # urlcode.yaml
12
+ version: "1"
13
+ routes:
14
+ /go:
15
+ redirect:
16
+ url: https://example.com
17
+ ```
18
+
19
+ ## Multiple files and nested folders
20
+
21
+ The public [urlcode-template](https://github.com/jimhoyd-com/urlcode-template)
22
+ contains a function route and a redirect, organized like this:
23
+
24
+ ```text
25
+ urlcode.yaml
26
+ routes/
27
+ functions.yaml
28
+ marketing/
29
+ links.yaml
30
+ functions/
31
+ hello.mjs
32
+ tests/
33
+ requests.json
34
+ ```
35
+
36
+ Entry point:
37
+
38
+ ```yaml
39
+ # urlcode.yaml
40
+ version: "1"
41
+ includes:
42
+ - routes/functions.yaml
43
+ - routes/marketing/links.yaml
44
+ routes: {}
45
+ ```
46
+
47
+ Function route:
48
+
49
+ ```yaml
50
+ # routes/functions.yaml
51
+ version: "1"
52
+ routes:
53
+ /hello:
54
+ function:
55
+ source: functions/hello.mjs
56
+ ```
57
+
58
+ Function source for this short example:
59
+
60
+ ```js
61
+ // functions/hello.mjs
62
+ export default function hello() {
63
+ return Response.json({ message: "Hello!" });
64
+ }
65
+ ```
66
+
67
+ Redirect route:
68
+
69
+ ```yaml
70
+ # routes/marketing/links.yaml
71
+ version: "1"
72
+ routes:
73
+ /go:
74
+ redirect:
75
+ url: https://example.com
76
+ ```
77
+
78
+ These produce `/hello` and `/go`, not `/routes/hello` or `/marketing/go`. The
79
+ actual template adds a validated `{name}` input to the function example. You can
80
+ also colocate code with a feature, for example `features/support/routes.yaml`
81
+ and `features/support/hello.mjs`; use `source: features/support/hello.mjs`.
82
+
83
+ ## Mix inline and included routes
84
+
85
+ Keep a few common routes in the entry point while splitting larger groups:
86
+
87
+ ```yaml
88
+ version: "1"
89
+ includes:
90
+ - routes/functions.yaml
91
+ - routes/marketing/links.yaml
92
+ routes:
93
+ /status:
94
+ respond:
95
+ json: {ok: true}
96
+ ```
97
+
98
+ With the example files above, the combined project has three routes. Update your
99
+ reviewed `--expect-routes` count when adding/removing a route. Simply moving a
100
+ route between files does not change the count or its URL.
101
+
102
+ ## Composition rules
103
+
104
+ - All file references are relative to the project root containing `urlcode.yaml`,
105
+ including `includes`, function `source`, and asset `file`/`directory` references.
106
+ They are never relative to the included YAML file.
107
+ - Each file declares `version: "1"` and `routes`. The entry point uses `routes: {}`
108
+ when all routes live in includes. `.yaml` and `.yml` work.
109
+ - Includes list explicit files, including paths through nested folders. There is
110
+ no directory auto-discovery, glob expansion or remote configuration download.
111
+ - Put all includes in `urlcode.yaml`; included files cannot include other files.
112
+ - Duplicate files or route paths fail validation. Include order does not provide
113
+ overrides or change route-matching precedence.
114
+ - References must stay inside the project. Missing files and escaping symlinks
115
+ fail validation. Do not use secret files as configuration.
116
+ - Current limits: 256 included files, 32 MiB per YAML file, 64 MiB aggregate YAML and 100,000 total routes. Parser-worker and compilation limits also apply; see [capacity](CAPACITY.md).
117
+ These limits apply to the combined project, not separately per folder.
118
+
119
+ `dev` reloads changes to ordinary project YAML files and keeps the last valid
120
+ snapshot if a change is invalid. Configuration in hidden/build/dependency
121
+ folders or behind symlink targets is outside the normal watcher: use ordinary
122
+ source folders or restart after such changes. File grouping does not weaken
123
+ function isolation or operator binding requirements.
124
+
125
+ `validate`, `routes`, `test`, `audit` and `benchmark` all use the same merged
126
+ project. To move files safely: edit the include list and any changed project-root
127
+ references, validate, then run the audit with the same expected count. Paths
128
+ inside a moved route file need no change when their target files remain in place.
129
+ See the [implemented contract](SPECIFICATION.md) and [readiness guide](READINESS.md).
130
+
131
+ See [route matching and new links](ROUTING.md) for parameter/wildcard semantics,
132
+ priority rules and activation of newly added definitions.
133
+
134
+ For layout choices, naming, readable YAML/functions, middleware responsibilities
135
+ and safe refactoring, see [organization and readability practices](BEST-PRACTICES.md).
@@ -0,0 +1,72 @@
1
+ # Performance checks
2
+
3
+ Run from the runtime checkout:
4
+
5
+ ```sh
6
+ npm run benchmark -- 1000
7
+ npm run benchmark -- 10000
8
+ npm run benchmark -- 100000
9
+ ```
10
+
11
+ Each invocation generates a temporary literal-redirect collection, measures
12
+ YAML load/compile/server startup, warms up 100 requests, then runs 5,000 real
13
+ loopback HTTP requests with 16 keep-alive connections. It verifies every status
14
+ and destination, never follows redirects, cleans up its process/files and
15
+ prints JSON with hardware/runtime, startup, memory, throughput and latency.
16
+
17
+ Initial local measurements on 2026-09-16: Apple M4 Pro, 48 GiB RAM, macOS arm64,
18
+ Node 26.8.2. These are short development runs on a shared machine, not an SLA,
19
+ sizing recommendation, soak result or comparison against other products.
20
+
21
+ | Routes | Startup | RSS after startup | Redirect requests/s | p95 latency |
22
+ |---|---:|---:|---:|---:|
23
+ | 1,000 | 29 ms | 86 MiB | 29,398 | 0.87 ms |
24
+ | 10,000 | 168 ms | 186 MiB | 28,458 | 0.85 ms |
25
+ | 100,000 (after parser fix) | 1,431 ms | 621 MiB | 28,563 | 0.85 ms |
26
+
27
+ These runs use the improved parser; the original 100,000-route startup took
28
+ 42,493 ms. A string-key-only duplicate check reduced that to
29
+ 1,431 ms while retaining duplicate rejection tests. Parser transient allocation
30
+ still contributes significantly to RSS; 100k startup exceeds the illustrative
31
+ 512 MiB container limit. Do not size from routing data alone.
32
+
33
+ Before a stable production release, repeat on supported Node 22/24/26 deployment
34
+ hardware, measure long-running memory/reloads and tail latency under sustained
35
+ traffic, include mixed parameter/function workloads and overload/recovery, and
36
+ set regression budgets from repeatable evidence. Worker concurrency and slow
37
+ upstream services need separate tests; literal redirect throughput does not
38
+ predict function throughput. Runtime logs were disabled for these measurements.
39
+
40
+ The 5,000 measured requests do not exercise every route in the larger datasets.
41
+ Client and server share a process; these runs exclude TLS/proxy overhead and
42
+ production logging. No NGINX comparison has been measured. Use the
43
+ [capacity model](CAPACITY.md) and [recovery drills](RESILIENCE.md) when designing
44
+ a deployment benchmark; do not extrapolate native redirect RPS to middleware.
45
+
46
+ ## TypeScript conversion, 2026-09-17
47
+
48
+ The runtime source became TypeScript, shipped as `dist/`: Node's own type
49
+ stripping with the specifier extension rewritten, so `dist/x.js` is
50
+ byte-equivalent to `src/x.ts` with the types removed (same lines, same
51
+ columns; no bundling, minification or syntax transform). The published
52
+ package therefore runs the same JavaScript it ran before. Measured on the same
53
+ machine (Intel Xeon 2.80 GHz, 16 GiB, Linux, Node 22.22.2), a plain-JavaScript
54
+ worktree of the pre-conversion commit against `dist/cli.js`, three alternating
55
+ runs each, medians of medians:
56
+
57
+ | Check | Before (JavaScript source) | After (`dist/`) |
58
+ |---|---:|---:|
59
+ | CLI cold start, `validate --project starters/default` (20 spawns) | 798.6 ms | 797.8 ms |
60
+ | CLI cold start, `routes --project starters/default` | 795.1 ms | 770.9 ms |
61
+ | CLI cold start, `--help` | 331.0 ms | 328.4 ms |
62
+ | `npm run benchmark -- 1000` requests/s (6 runs) | 4,640–4,927 | 4,657–4,914 |
63
+ | `npm run benchmark -- 1000` p95 | 5.77–6.00 ms | 5.65–5.96 ms |
64
+ | `npm run benchmark -- 1000` RSS after startup | 95–100 MiB | 95–100 MiB |
65
+ | `cli benchmark --project starters/default` (function route) | 1,188 req/s, p95 3.50 ms | 1,265 req/s, p95 3.28 ms |
66
+
67
+ Every difference is inside run-to-run spread; the gates (cold start within 2 %,
68
+ throughput within noise, RSS within 5 MiB) were met. Running the `.ts` source
69
+ directly (`node src/cli.ts`, the developer loop) costs about +190 ms of cold
70
+ start and +35 MiB RSS for the stripping itself; that mode never ships.
71
+ Container time-to-ready was not measured here (no Docker in that environment);
72
+ CI's readiness loop covers it.
@@ -0,0 +1,234 @@
1
+ # Host plugins
2
+
3
+ A plugin is host code an operator passes to the runtime in JavaScript or
4
+ TypeScript. It sees
5
+ every request after the route is matched and before the handler runs, may
6
+ answer it outright, and sees every response before it is written. The
7
+ declarative [policies](POLICIES.md) are implemented on the same hook names,
8
+ so first-party and operator behavior share one code path and one test seam.
9
+
10
+ Plugins are not part of the project format. Nothing in YAML names a plugin or
11
+ an npm package; the operator application passes them to `startServer`,
12
+ `createRuntime`, `createVercelHandler` or `createLambdaHandler`. A project stays
13
+ portable while an operator can still add a shared-store limiter, a verified-bot
14
+ check or a purge endpoint. Plugins run in the host process with the host's
15
+ privileges: they are the operator's trust boundary, not the project's, and the
16
+ runtime does not sandbox, review or limit what a plugin does with a request.
17
+ Treat a plugin like any other dependency of the operator application.
18
+
19
+ ## Passing plugins
20
+
21
+ ```js
22
+ import { startServer } from '@jimhoyd/urlcode';
23
+
24
+ await startServer({
25
+ project: './site',
26
+ trustedProxies: ['10.0.0.0/8'],
27
+ plugins: [auditPlugin],
28
+ });
29
+ ```
30
+
31
+ `createRuntime(project, { plugins })` takes the same array for embedding
32
+ without the HTTP server. `createVercelHandler({ plugins })` and
33
+ `createLambdaHandler({ plugins })` pass it through to the adapter's runtime
34
+ with the target set to `vercel` or `aws`. The Cloudflare build takes no
35
+ plugins: the Worker carries only compiled policies. `runtime.plugins` lists
36
+ the activated `{ name, version }` pairs.
37
+
38
+ ## Plugin shape
39
+
40
+ ```js
41
+ const auditPlugin = {
42
+ name: 'audit', // ^[a-z][a-z0-9-]{0,63}$, unique per runtime
43
+ version: '1.0.0', // any string up to 64 characters
44
+ targets: ['node', 'vercel'], // subset of node, vercel, aws, cloudflare
45
+ async onActivate(runtime) {}, // may throw to refuse activation
46
+ async onRequest(request) {}, // return a result to short-circuit
47
+ async onResponse(request, result) { return result; }, // return the result to send
48
+ onError(request, error) {}, // observe a thrown error
49
+ async onClose() {}, // release resources
50
+ };
51
+ ```
52
+
53
+ The package ships declarations for this contract: `Plugin` and
54
+ `PluginRuntime` (what `onActivate` receives) from `@jimhoyd/urlcode/plugins`, with
55
+ `PolicyRequest`, `HandlerResult`, `HeaderPair`, `TargetName` and `TestPlan`
56
+ re-exported beside them, and `HostPlugin` (the same type) from `urlcode`. The
57
+ same plugin in TypeScript:
58
+
59
+ ```ts
60
+ import type { Plugin, PolicyRequest, HandlerResult } from '@jimhoyd/urlcode/plugins';
61
+
62
+ const auditPlugin: Plugin = {
63
+ name: 'audit',
64
+ version: '1.0.0',
65
+ targets: ['node', 'vercel'],
66
+ onRequest(request: PolicyRequest): HandlerResult | undefined {
67
+ if (request.path === '/deny') return { status: 451, headers: [], body: new Uint8Array(0) };
68
+ return undefined;
69
+ },
70
+ onResponse(request, result) { return { ...result, headers: [...result.headers, ['x-plugin', 'seen']] }; },
71
+ };
72
+ ```
73
+
74
+ Validation happens at activation, before any request: at most 32 plugins,
75
+ each an object with a kebab-case `name` no other plugin uses, a `version`
76
+ string and a `targets` array of known target names; every declared hook must
77
+ be a function and at least one must be present. A plugin whose `targets` does
78
+ not include the runtime's target is refused with
79
+ `Plugin "name" does not support the vercel target`, mirroring how adapters
80
+ refuse routes, so the same operator application fails fast where a plugin's
81
+ assumptions do not hold.
82
+
83
+ `onActivate(runtime)` receives `{ testPlan(), version, root, target }`.
84
+ `testPlan()` returns the route inventory (`inventory[].policies` names the
85
+ effective policies per route) and `policies`, the per-route policy summaries
86
+ with their `target` value, so a plugin can inspect what it is joining and
87
+ throw to refuse. Hooks may be async; the runtime awaits them.
88
+
89
+ ## The request object
90
+
91
+ Every request hook receives the same object the policies see, built once per
92
+ request by `policyRequest` in `src/policies.ts`:
93
+
94
+ | Field | Value |
95
+ |---|---|
96
+ | `method` | Request method, upper case |
97
+ | `target` | Raw request target (path plus query string) as received |
98
+ | `path` | Parsed path |
99
+ | `params` | Route parameter values by name |
100
+ | `query` | Parsed query as `URLSearchParams` |
101
+ | `headers` | Request `Headers` |
102
+ | `headerCounts` | Occurrences per header name, lower case, so a duplicated scalar header can be detected (empty on Cloudflare, where the platform joins repeats) |
103
+ | `client` | Client address as the host resolved it through `--trusted-proxies` or the platform's connection address, or `null` when none could be resolved. Never a raw forwarded header |
104
+ | `origin` | Public origin the runtime was told (`--origin`, `URLCODE_ORIGIN` or the platform's), used for HSTS and absolute URLs |
105
+ | `route` | The matched route's configured pattern, never request text |
106
+ | `secrets` | `true` when the route declares secret bindings |
107
+
108
+ The object identity is stable for the life of the request, so a plugin may key
109
+ a `WeakMap` on it to carry state from `onRequest` to `onResponse`. Nothing on
110
+ it reaches the guest: there is no sandbox handle, no deadline, no `env` or
111
+ `secrets` values and no binding. A plugin cannot extend or shorten a function's
112
+ deadline, read or write guest state, or obtain a binding the operator policy
113
+ did not grant to the route.
114
+
115
+ ## Results
116
+
117
+ A result is what the runtime writes:
118
+
119
+ ```js
120
+ { status: 429, headers: [['content-type', 'text/plain; charset=utf-8'], ['retry-after', '30']], body: Buffer.from('Too many requests\n') }
121
+ ```
122
+
123
+ `headers` is an array of `[name, value]` pairs; `body` is a `Buffer`
124
+ (a `Uint8Array` on Cloudflare). `onRequest` returns such a result to answer the
125
+ request without running the handler, or `undefined` to continue. `onResponse`
126
+ returns the result to send; returning `undefined` keeps the one it was given.
127
+ Replace rather than mutate. Every result still passes through the shared
128
+ response writer on the way out: the status must be an integer from 200 to 599,
129
+ header names and values are validated (an invalid one fails the request as a
130
+ function's would), hop-by-hop headers and `Content-Length` are dropped and
131
+ recomputed, `X-Request-Id` and `X-Content-Type-Options: nosniff` are added, and
132
+ `Cache-Control: no-store` is added when the result carries no `Cache-Control`.
133
+ HEAD and 204/205/304 results are sent without a body.
134
+
135
+ ## Ordering
136
+
137
+ Plugins wrap everything else. On the request side the first plugin in the
138
+ array runs first, then the second, then the policies (`agents`, `throttle`,
139
+ `cache`), then the route contract and the handler. On the response side the
140
+ policies run, then plugins in reverse: the first plugin sees the request first
141
+ and the response last, the standard onion. A short-circuit from any
142
+ `onRequest` skips the remaining plugins, every policy request hook and the
143
+ handler, and goes straight to the response side; the response hooks of
144
+ policies with a request phase are skipped for it (nothing is cached, no
145
+ rate-limit headers), security headers and compression still apply, and every
146
+ plugin's `onResponse` still runs.
147
+
148
+ `onError` hooks run in reverse order for an error the runtime throws after
149
+ the request object exists (404 for a disabled route is thrown before it; a
150
+ 405 is an ordinary result and reaches `onResponse`). They observe only: a
151
+ throw inside `onError` is swallowed and the outcome stands. A policy's error
152
+ hook may answer with a fallback instead; when one does, plugin `onError`
153
+ hooks do not run and plugin `onResponse` hooks see the fallback (no
154
+ first-party policy returns one today). `onClose` runs in reverse order when
155
+ the runtime closes, after the policies have released their state; a throw
156
+ there is ignored.
157
+
158
+ On a reload the same plugin objects are activated again: `onActivate` runs
159
+ for the new runtime before `onClose` runs for the retired one, so plugin
160
+ state persists unless `onClose` discards it. An `onActivate` that throws
161
+ rejects the reload and the old runtime keeps serving.
162
+
163
+ ## What a plugin cannot do
164
+
165
+ - Reach inside the guest, extend a deadline or see bindings: the request
166
+ object carries none of those, and there is no other handle.
167
+ - Change a project's routes or policies after activation; `onActivate` can
168
+ refuse, not rewrite.
169
+ - Be selected from YAML. A project cannot require a plugin, and a plugin's
170
+ presence does not change how the project validates.
171
+ - Change a thrown error's status or body from `onError`.
172
+ - Run on Cloudflare: the Worker carries compiled policies only.
173
+
174
+ ## Sketches
175
+
176
+ Both examples are sketches, not shipped code: they omit error handling,
177
+ configuration and the store or verifier they depend on.
178
+
179
+ ### Shared-store throttle
180
+
181
+ The built-in `throttle` keeps counters per instance. Across replicas an
182
+ operator needs a shared store; a plugin keys it on the resolved client and the
183
+ route pattern, both of which the request object already carries.
184
+
185
+ ```js
186
+ // Sketch. `store.increment(key, windowSeconds)` is a fixed-window counter
187
+ // in a shared store and returns the count after increment.
188
+ export function sharedThrottle({ store, quota, window }) {
189
+ return {
190
+ name: 'shared-throttle', version: '0.1.0', targets: ['node', 'vercel', 'aws'],
191
+ async onRequest(req) {
192
+ const key = `${req.route}|${req.client ?? 'shared'}`;
193
+ const used = await store.increment(key, window);
194
+ if (used <= quota) return undefined;
195
+ return {
196
+ status: 429,
197
+ headers: [['content-type', 'text/plain; charset=utf-8'], ['cache-control', 'no-store'], ['retry-after', String(window)]],
198
+ body: Buffer.from('Too many requests\n'),
199
+ };
200
+ },
201
+ };
202
+ }
203
+ ```
204
+
205
+ Combine it with `policies.throttle: false` on the routes it covers, or keep
206
+ the built-in policy as a per-instance ceiling underneath it.
207
+
208
+ ### Verified-bot allow
209
+
210
+ The `agents` policy matches strings only. Verifying that a request claiming to
211
+ be a search crawler really comes from one (reverse DNS as the major engines
212
+ document, or an HTTP Message Signature per the web-bot-auth drafts) needs a
213
+ network lookup and a cache, so it is a plugin. The sketch answers 403 to a
214
+ claimed crawler whose address does not verify and lets everything else
215
+ continue to the `agents` policy.
216
+
217
+ ```js
218
+ // Sketch. `verify(address)` resolves the address back to the crawler's
219
+ // documented domains and caches the answer; it is not part of the runtime.
220
+ export function verifiedBots({ verify, claims = /Googlebot|bingbot/i }) {
221
+ return {
222
+ name: 'verified-bots', version: '0.1.0', targets: ['node'],
223
+ async onRequest(req) {
224
+ const agent = req.headers.get('user-agent') || '';
225
+ if (!claims.test(agent) || !req.client) return undefined;
226
+ if (await verify(req.client)) return undefined;
227
+ return { status: 403, headers: [['content-type', 'text/plain; charset=utf-8'], ['cache-control', 'no-store']], body: Buffer.from('Forbidden\n') };
228
+ },
229
+ };
230
+ }
231
+ ```
232
+
233
+ A plugin that logs should follow the policies' rule: record the route pattern
234
+ and the outcome, not the client address or the User-Agent string.