@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
package/dist/router.js ADDED
@@ -0,0 +1,166 @@
1
+ import { setImmediate as yieldTurn } from 'node:timers/promises';
2
+ import { compileHttp } from './http-policy.js';
3
+ import Ajv from 'ajv/dist/2020.js';
4
+ import { assert } from './errors.js';
5
+ import { functionFile } from './config.js';
6
+ import { parameterName } from './match.js';
7
+
8
+
9
+ // Re-exported so existing importers keep one entry point for routing.
10
+ export { parseTarget, matchRoute, contextFor, resolveValue, redirectLocation } from './match.js';
11
+
12
+ /** The grants compileRoutes consults: a validated OperatorPolicy (policy.ts) or nothing, which denies every binding. */
13
+
14
+
15
+
16
+
17
+ const methodsDefault = ['GET', 'HEAD'];
18
+ const token = /^[A-Za-z_][A-Za-z0-9_]*$/;
19
+ const own = (obj , key ) => Object.hasOwn(obj, key);
20
+ const dict = () => Object.create(null) ;
21
+ function inputValidator(schema , ajv , validators ) {
22
+ const { default: _default, ...shape } = schema;
23
+ const key = JSON.stringify(shape);
24
+ if (!validators.has(key)) {
25
+ assert(validators.size < 1024, 'Maximum 1024 distinct input schemas per snapshot');
26
+ validators.set(key, ajv.compile(shape));
27
+ }
28
+ return validators.get(key) ;
29
+ }
30
+ function segments(pattern ) {
31
+ assert(pattern.startsWith('/') && !/[?#%\\\s\u0000-\u001f\u007f]/u.test(pattern), 'Route must be a literal absolute path with optional whole-segment {parameters}');
32
+ const parts = pattern.split('/').slice(1);
33
+ assert(parts.length <= 32 && pattern.length <= 2048, 'Route exceeds path limits');
34
+ for (const part of parts) {
35
+ assert(part !== '.' && part !== '..', 'Dot path segments are unsupported');
36
+ if (part.includes('{') || part.includes('}')) assert(/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(part), 'Invalid route parameter');
37
+ }
38
+ return parts;
39
+ }
40
+ function referenceCheck(ref , route , allowBindings = false) {
41
+ if (typeof ref !== 'object' || ref === null) return;
42
+ if (ref.from) assert(route.parameters.some(p => p.in === ref.from && p.name === (ref.from === 'header' ? ref.name .toLowerCase() : ref.name)), 'Reference uses undeclared input');
43
+ else if (ref.env && allowBindings) assert(own(route.env, ref.env), 'Reference uses undeclared environment alias');
44
+ else if (ref.secret && allowBindings) assert(own(route.secrets, ref.secret), 'Reference uses undeclared secret alias');
45
+ else assert(false, 'Invalid argument reference');
46
+ }
47
+ // A declared `pass: false` reads as no passthrough, the same as leaving it
48
+ // out; dropping the key is what gives match.ts its RedirectSpec.
49
+ function compiledRedirect(redirect ) {
50
+ if (redirect.query?.pass !== false) return redirect ; // pass is string[] | undefined here; TypeScript cannot narrow through the optional query
51
+ const { pass: _pass, ...query } = redirect.query;
52
+ return { ...redirect, query };
53
+ }
54
+ export async function compileRoutes(loaded , bindings , permissions = {}, projectSha256 ) {
55
+ const deadline=performance.now()+10000;
56
+ let processed=0;
57
+ const exact = new Map (), dynamic = [], mounts = [], modules = new Map ();
58
+ // Node hands the CJS module.exports (the class) to a default import; TypeScript types it as the namespace, whose .default is the same class.
59
+ const ajv = new Ajv.default({ strict: false, allErrors: false }), validators = new Map ();
60
+ for (const [pattern, config] of Object.entries(loaded.routes)) {
61
+ if (++processed % 64 === 0) await yieldTurn();
62
+ assert(performance.now()<deadline, 'Route compilation deadline exceeded');
63
+ const parts = segments(pattern);
64
+ assert(!pattern.startsWith('/_urlcode'), 'The /_urlcode prefix is reserved for runtime operations');
65
+ const names = parts.map(parameterName).filter((name) => Boolean(name));
66
+ assert(!pattern.includes('*') || (config.static && pattern.endsWith('/*') && parts.filter(p => p.includes('*')).length === 1 && parts.at(-1) === '*' && !names.length), 'Only static routes support a terminal /* wildcard');
67
+ assert(!config.static || pattern.endsWith('/*'), 'Static routes require a terminal /* wildcard');
68
+ if (config.page || config.download || config.static) assert((config.methods || methodsDefault).every(m => methodsDefault.includes(m)), 'Asset routes support only GET and HEAD');
69
+ assert(new Set(names).size === names.length, 'Duplicate path parameter');
70
+ // `redirect` and `function` are re-attached below in their compiled shape.
71
+ const { redirect: declaredRedirect, function: declaredFunction, ...declared } = config;
72
+ const route = { ...declared, pattern, parts, names, specificity: parts.length - names.length,
73
+ methods: config.methods || methodsDefault, parameters: [], env: dict(), secrets: dict(), responseHeaders: [], middleware: [] };
74
+ compileHttp(route);
75
+ const seen = new Set ();
76
+ for (const param of config.parameters || []) {
77
+ const schema = param.schema, name = param.in === 'header' ? param.name.toLowerCase() : param.name;
78
+ assert(!seen.has(`${param.in}:${name}`), 'Duplicate parameter declaration');
79
+ seen.add(`${param.in}:${name}`);
80
+ assert(param.in !== 'path' || (names.includes(name) && param.required === true && schema.type === 'string'), 'Path parameters must be declared required strings');
81
+ assert(schema.type !== 'array' || (param.in === 'query' && schema.items), 'Only query arrays with scalar items are supported');
82
+ assert(schema.type === 'array' || !schema.items, 'items requires array input');
83
+ assert(param.in !== 'path' || !own(schema, 'default'), 'Path parameters cannot have defaults');
84
+ assert(!['minLength','maxLength'].some(k => own(schema,k)) || schema.type === 'string', 'String bounds require string type');
85
+ assert(!['minimum','maximum'].some(k => own(schema,k)) || ['integer','number'].includes(schema.type), 'Numeric bounds require numeric type');
86
+ assert(!own(schema,'maxItems') || schema.type === 'array', 'maxItems requires array type');
87
+ const p = { ...param, name, required: param.required === true, validate: inputValidator(schema, ajv, validators) };
88
+ if (own(schema, 'default')) assert(p.validate(schema.default), 'Invalid parameter default');
89
+ route.parameters.push(p);
90
+ }
91
+ assert(names.every(name => route.parameters.some(p => p.in === 'path' && p.name === name)), 'Every path placeholder requires an input declaration');
92
+ for (const [alias, ref] of Object.entries(config.env || {})) {
93
+ if (ref.env) assert(permissions.projectSha256 === projectSha256 && permissions.routes?.[pattern]?.env?.includes(ref.env), 'Environment binding denied by operator policy');
94
+ const value = own(ref, 'value') ? ref.value : bindings[ref.env ];
95
+ assert(typeof value === 'string', 'Missing required environment binding');
96
+ route.env[alias] = value;
97
+ }
98
+ for (const [alias, ref] of Object.entries(config.secrets || {})) {
99
+ assert(permissions.projectSha256 === projectSha256 && permissions.routes?.[pattern]?.secrets?.includes(ref.secret), 'Secret binding denied by operator policy');
100
+ const value = bindings[ref.secret];
101
+ assert(typeof value === 'string' && value.length, 'Missing required secret binding');
102
+ route.secrets[alias] = value;
103
+ }
104
+ if (config.expires) {
105
+ assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(config.expires) && Number.isFinite(Date.parse(config.expires)), 'Expiry must be a UTC ISO timestamp');
106
+ route.expiresAt = Date.parse(config.expires);
107
+ assert(new Date(route.expiresAt).toISOString().replace('.000Z','Z') === config.expires.replace('.000Z','Z'), 'Invalid calendar expiry');
108
+ }
109
+ if (declaredRedirect) {
110
+ const value = declaredRedirect.url;
111
+ assert(!/[\u0000-\u0020\u007f\\]/u.test(value), 'Redirect URL contains unsafe characters');
112
+ let dest ;
113
+ try { dest = new URL(value); } catch { assert(false, 'Redirect URL must be absolute HTTP(S)'); }
114
+ assert(['http:', 'https:'].includes(dest.protocol) && !dest.username && !dest.password, 'Redirect must use HTTP(S) without credentials');
115
+ const authority = value.match(/^https?:\/\/([^/?#]+)/i)?.[1];
116
+ assert(authority && !/[{}]/.test(authority) && !/[{}]/.test(dest.search + dest.hash), 'Redirect placeholders are allowed only in path segments');
117
+ const placeholders = [...value.matchAll(/\{([^}]+)\}/g)].map(m => m[1] );
118
+ assert(placeholders.every(n => token.test(n) && names.includes(n)), 'Redirect placeholder must reference a declared path input');
119
+ assert(!/[{}]/.test(value.replace(/\{[A-Za-z_][A-Za-z0-9_]*\}/g, '')), 'Invalid redirect placeholder');
120
+ const query = declaredRedirect.query || {};
121
+ const reserved = new Set(dest.searchParams.keys());
122
+ for (const [key, ref] of Object.entries(query.map || {})) {
123
+ assert(!reserved.has(key), 'Redirect query mapping conflicts with destination');
124
+ reserved.add(key); referenceCheck(ref, route);
125
+ }
126
+ for (const key of query.pass || []) assert(!reserved.has(key), 'Query passthrough conflicts with destination or mapping');
127
+ route.redirect = compiledRedirect(declaredRedirect);
128
+ }
129
+ if (config.link) {
130
+ assert(route.methods.every(method => methodsDefault.includes(method)), 'Stored links support only GET and HEAD');
131
+ referenceCheck(config.link.code,route);
132
+ }
133
+ for (const item of config.middleware || []) {
134
+ const source = await functionFile(loaded.root,item.source);
135
+ modules.set(source,true);
136
+ route.middleware.push({source,export:item.export || 'default'});
137
+ }
138
+ if (declaredFunction) {
139
+ const source = await functionFile(loaded.root, declaredFunction.source);
140
+ modules.set(source, true);
141
+ route.function = { ...declaredFunction, source, export: declaredFunction.export || 'default' };
142
+ for (const ref of Object.values(declaredFunction.args || {})) referenceCheck(ref, route, true);
143
+ }
144
+ if (config.static) { route.prefix = pattern.slice(0, -1); mounts.push(route); }
145
+ else if (!names.length) exact.set(pattern, route);
146
+ else {
147
+ assert(dynamic.length < 1000, 'Maximum 1000 parameterized routes per snapshot');
148
+ for (const existing of dynamic) {
149
+ if (existing.parts.length === parts.length && existing.specificity === route.specificity) {
150
+ assert(!parts.every((p, i) => p === existing.parts[i] || parameterName(p) || parameterName(existing.parts[i] )), 'Equally specific parameterized routes overlap');
151
+ }
152
+ }
153
+ dynamic.push(route);
154
+ }
155
+ }
156
+ dynamic.sort((a, b) => b.specificity - a.specificity);
157
+ // Index parameter routes by segment count; exact paths take the O(1) fast path.
158
+ const byLength = new Map ();
159
+ for (const route of dynamic) {
160
+ if (!byLength.has(route.parts.length)) byLength.set(route.parts.length, []);
161
+ byLength.get(route.parts.length) .push(route);
162
+ }
163
+ mounts.sort((a,b) => b.prefix .length - a.prefix .length);
164
+ assert(performance.now()<deadline, 'Route compilation deadline exceeded');
165
+ return { exact, byLength, mounts, modules: [...modules.keys()], count: exact.size + dynamic.length + mounts.length };
166
+ }
@@ -0,0 +1,244 @@
1
+ import { projectPlan, hasRedirect } from './readiness.js';
2
+
3
+ import { checkRequest, decorateResponse } from './http-policy.js';
4
+ import { compileAssets, assetResponse } from './assets.js';
5
+ import { loadDocument, loadBindings } from './config.js';
6
+ import { compileRoutes, parseTarget, matchRoute, contextFor, resolveValue, redirectLocation } from './router.js';
7
+ import { FunctionPool } from './functions.js';
8
+
9
+ import { prepareFunctionSnapshot, validatePolicy } from './policy.js';
10
+
11
+ import {openLinkStore} from './link-store.js';
12
+
13
+ import {linkCode,linkData,linkCollection} from './link-records.js';
14
+ import {assert} from './errors.js';
15
+ import { HttpError } from './errors.js';
16
+ import { compilePolicies, closePolicies, policyRequest, compileErrorPolicy, errorHeaders } from './policies.js';
17
+ import { validatePlugins, activatePlugins, pluginsRequest, pluginsResponse, pluginsError, closePlugins } from './plugins.js';
18
+
19
+ import { createObserverSink } from './observability.js';
20
+
21
+ import { applySite } from './site.js';
22
+
23
+
24
+
25
+
26
+ /** A stored link as a link store returns it; the runtime validates the fields it uses. */
27
+
28
+ /** What a `linkStores` binding must provide: a reader and, optionally, its health. */
29
+
30
+ /** An operator-owned SQLite link store the runtime opens read-only for one collection. */
31
+
32
+
33
+ /** An operator plugin (src/plugins.ts). */
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+ /** Per-request facts the host may read after handle() settles; never request text. */
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+ export async function createRuntime(project , rawOptions = {}) {
63
+ // Observers see every event this runtime emits; the operator's log stays
64
+ // the default sink. A server passes none down: it owns its own sink and
65
+ // counters, which survive the runtimes it replaces on reload.
66
+ const { observers, ...options } = rawOptions;
67
+ const sink = createObserverSink(observers, options.log);
68
+ options.log = sink;
69
+ const loaded = await loadDocument(project);
70
+ // Site conventions become ordinary routes before compilation; a declared
71
+ // route at the same path wins. The public origin, when the server knows
72
+ // it, is what absolute URLs in generated files are built from.
73
+ await applySite(loaded, { origin: options.origin, log: options.log });
74
+ const dynamicLinks=loaded.document.dynamicLinks===true;
75
+ assert(dynamicLinks || (!options.linkStore && !Object.keys(options.linkStores||{}).length),'Link-store bindings require dynamicLinks: true in urlcode.yaml');
76
+ const bindings = await loadBindings(loaded.root, options.local, options.environment);
77
+ const snapshot = await prepareFunctionSnapshot(loaded);
78
+ if (options.permissions) validatePolicy(options.permissions);
79
+ const compiled = await compileRoutes(loaded, bindings, options.permissions, snapshot.projectSha256);
80
+ const routes = [...compiled.mounts, ...compiled.exact.values(), ...[...compiled.byLength.values()].flat()];
81
+ const assets = await compileAssets(loaded.root, routes);
82
+ // Host policies compile after assets so a policy can see what a route serves
83
+ // (precompressed variants, cacheability). Cross-request state lives in one
84
+ // per-runtime object and is released with the runtime, never shared across
85
+ // reloads: a new snapshot starts with empty counters and an empty cache.
86
+ const target = options.target || 'node';
87
+ const plugins = validatePlugins(options.plugins, target);
88
+ const shared = { target, log: options.log, routes: routes.length };
89
+ const anyPolicy = Boolean(loaded.document.policies) || routes.some(route => route.policies);
90
+ for (const route of routes) route.policy = anyPolicy ? await compilePolicies(loaded.document, route, { route, shared, target, root: loaded.root }) : null;
91
+ const projectErrorPolicy = anyPolicy ? compileErrorPolicy(loaded.document, { target }) : null;
92
+ // Which route's policy an error belongs to, so its headers follow the route
93
+ // the request matched rather than the project default.
94
+ const errorRoutes = new WeakMap ();
95
+ const stores = Object.assign(Object.create(null) ,options.linkStores);
96
+ let ownedStore ;
97
+ try {
98
+ if(options.linkStore){
99
+ linkCollection(options.linkStore.collection);
100
+ assert(!Object.hasOwn(stores,options.linkStore.collection), 'Duplicate link store binding');
101
+ ownedStore=await openLinkStore({...options.linkStore,project:loaded.root,readOnly:true,log:options.log});
102
+ stores[options.linkStore.collection]=ownedStore;
103
+ }
104
+ for(const route of routes)if(route.link)assert(Object.hasOwn(stores,route.link.collection) && typeof stores[route.link.collection]?.get==='function','Missing operator link store binding');
105
+ }catch(error){await ownedStore?.close();throw error;}
106
+ let pool ;
107
+ try{pool=await new FunctionPool(routes, { root:loaded.root, snapshot, log:options.log, workers:options.workers, timeoutMs:options.timeoutMs, maxBytes:options.maxBytes }).start();}
108
+ catch(error){await ownedStore?.close();throw error;}
109
+ let active = 0, closing = false, finish ;
110
+ // Response phase: cache store, throttle headers, security headers,
111
+ // compression, then operator plugins in reverse. A result produced by a
112
+ // request-phase policy skips that policy's own response hook (a cache hit
113
+ // is not stored twice) but still passes through the others (a hit still
114
+ // carries the client's rate-limit headers; a denial is not stored because
115
+ // its status is not cacheable). A plugin short-circuit ran before any
116
+ // policy, so it skips every request-phase policy's response hook.
117
+ async function finishPolicies(policy , request , result , producer ) {
118
+ let out = result;
119
+ for (const [module, state] of policy?.response || []) {
120
+ if (producer === 'plugin' ? module.onRequest : module === producer) continue;
121
+ out = await module.onResponse?.(state, request, out) ?? out;
122
+ }
123
+ return pluginsResponse(plugins, request, out);
124
+ }
125
+ function policyInventory() {
126
+ return Object.fromEntries(routes.flatMap(route => route.policy && Object.keys(route.policy.describe).length ? [[route.pattern, route.policy.describe] ] : []));
127
+ }
128
+ const workers = () => ({ healthy: pool.slots.filter(slot => slot?.ready).length, slots: pool.size });
129
+ const testPlan = () => ({...projectPlan(compiled),dynamicLinks,policies:policyInventory()});
130
+ await activatePlugins(plugins, { testPlan, version: loaded.version + assets.digest, root: loaded.root, target });
131
+ return {
132
+ get healthy() { return !closing && pool.healthy && Object.values(stores).every(store=>(store.readHealthy??store.healthy)!==false); },
133
+ assetWatch: assets.watch, version: loaded.version + assets.digest, count: compiled.count, root: loaded.root,
134
+ testPlan,
135
+ get plugins() { return plugins.map(plugin => ({ name: plugin.name, version: plugin.version })); },
136
+ get workers() { return workers(); },
137
+ metrics() { const { healthy, slots } = workers(); const snapshot = sink.metrics.snapshot(); snapshot.functionWorkers.healthySlots = healthy; snapshot.functionWorkers.slots = slots; return snapshot; },
138
+ // Security headers for an error answer: the matched route's when handle()
139
+ // threw after matching, the project's otherwise (no match, or a host-side
140
+ // error such as an oversized body or shed admission).
141
+ errorHeaders(error, origin) {
142
+ const policy = error && typeof error === 'object' ? errorRoutes.get(error) : undefined;
143
+ return errorHeaders(policy === undefined ? projectErrorPolicy : policy?.security ?? null, origin);
144
+ },
145
+ requestLimit(target) {
146
+ const match = matchRoute(compiled, parseTarget(target));
147
+ return match?.route.request?.body?.maxBytes;
148
+ },
149
+ async handle({ target, method = 'GET', headers = new Headers(), body, headerCounts, trace = {}, origin = 'http://localhost', client }) {
150
+ if (closing) throw new HttpError(503, 'Runtime unavailable');
151
+ active++;
152
+ let policyReq , policy ;
153
+ try {
154
+ const parsed = parseTarget(target);
155
+ const match = matchRoute(compiled, parsed);
156
+ if (!match) throw new HttpError(404, 'Not found');
157
+ const { route, path } = match;
158
+ // Configured pattern only; never the request path, query or parameter values.
159
+ trace.route = route.pattern;
160
+ // Known from here on, so an error thrown by the route's own checks
161
+ // (disabled, expired) is answered with that route's security headers.
162
+ policy = route.policy;
163
+ if (route.enabled === false) throw new HttpError(404, 'Not found');
164
+ if (route.expiresAt && Date.now() >= route.expiresAt) throw new HttpError(410, 'Gone');
165
+ // Host policies and plugins run once the route is known and before
166
+ // its contract is checked: a denied agent or an exhausted budget is
167
+ // answered without reading a body or touching the sandbox.
168
+ if (policy || plugins.length) {
169
+ policyReq = policyRequest({ method, target, path: parsed.path, params: path, query: parsed.query, headers, headerCounts, client, origin, route });
170
+ trace.client = policyReq.client;
171
+ const early = await pluginsRequest(plugins, policyReq);
172
+ if (early) return await finishPolicies(policy, policyReq, early, 'plugin');
173
+ for (const [module, state] of policy?.request || []) {
174
+ const result = await module.onRequest?.(state, policyReq);
175
+ if (result) return await finishPolicies(policy, policyReq, result, module);
176
+ }
177
+ }
178
+ if (!route.methods.includes(method)) {
179
+ const refused = { status: 405, headers: [['allow', route.methods.join(', ')]], body: Buffer.from('Method not allowed\n') };
180
+ // Counted by throttle already, so it carries the budget headers and
181
+ // the security profile like any other answer; nothing caches a 405.
182
+ return policyReq ? await finishPolicies(policy, policyReq, refused) : refused;
183
+ }
184
+ checkRequest(route, body || Buffer.alloc(0), headers, headerCounts);
185
+ const finishResponse = async (result ) => policyReq ? finishPolicies(policy, policyReq, decorateResponse(route,result)) : decorateResponse(route,result);
186
+ const context = contextFor(route, path, parsed.query, headers, headerCounts);
187
+ let native ;
188
+ if (hasRedirect(route)) native = { status: route.redirect.status || 302,
189
+ headers: [['location', redirectLocation(route, context, parsed.query)]], body: Buffer.alloc(0) };
190
+ else if(route.link){
191
+ // Resolution outcome for a trusted post-response observer. It records
192
+ // why this request ended the way it did; the caller decides whether a
193
+ // finished response is ever reported, and never sees stored data.
194
+ const collection=route.link.collection;
195
+ const observed =trace.link={collection,code:null,result:'invalid_code'};
196
+ let code ;try{code=linkCode(resolveValue(route.link.code,context));}catch{throw new HttpError(404,'Link not found');}
197
+ observed.code=code;observed.result='missing';
198
+ let record ;
199
+ try{const store=stores[collection];assert(store,'Missing operator link store binding');record=await store.get(collection,code);}catch{observed.result='unavailable';throw new HttpError(503,'Link store unavailable');}
200
+ if(!record)throw new HttpError(404,'Link not found');
201
+ let data;try{data=linkData({url:record.url,status:record.status,enabled:record.enabled,expires:record.expires});}catch{observed.result='invalid_record';throw new HttpError(503,'Invalid stored link');}
202
+ if(!data.enabled){observed.result='disabled';throw new HttpError(404,'Link not found');}
203
+ if(data.expires && Date.parse(data.expires)<=Date.now()){observed.result='expired';throw new HttpError(410,'Link expired');}
204
+ observed.result='redirect';
205
+ native={status:data.status,headers:[['location',data.url],['cache-control','no-store']],body:Buffer.alloc(0)};
206
+ }
207
+ else if (route.reply) native = route.reply;
208
+ else if (route.asset) {
209
+ try { native = assetResponse(route, parsed.path, method, headers); }
210
+ catch (error) {
211
+ if (!route.middleware.length || !(error instanceof HttpError)) throw error;
212
+ native = {status:error.status,headers:[['content-type','text/plain; charset=utf-8'],['cache-control','no-store']],body:Buffer.from(error.message+'\n')};
213
+ }
214
+ }
215
+ if (native && !route.middleware.length) return finishResponse(native);
216
+ context.args = Object.fromEntries(Object.entries(route.function?.args || {}).map(([key, ref]) => [key, resolveValue(ref, context)]));
217
+ return finishResponse(await pool.execute(route, { url: origin + target, method, headers: [...headers], body }, context, native));
218
+ } catch (error) {
219
+ if (policy !== undefined && error && typeof error === 'object') errorRoutes.set(error, policy);
220
+ if (policyReq) {
221
+ // A policy may answer instead of the error (stale-if-error serving a
222
+ // stored copy); the first fallback wins and still passes through the
223
+ // response phase. Otherwise the hooks only observe.
224
+ for (const [module, state] of policy?.error || []) {
225
+ let fallback;
226
+ try { fallback = await module.onError?.(state, policyReq, error); } catch { /* an observer cannot change the outcome */ }
227
+ if (fallback) return await finishPolicies(policy, policyReq, fallback, module);
228
+ }
229
+ await pluginsError(plugins, policyReq, error);
230
+ }
231
+ throw error;
232
+ } finally { active--; if (!active && closing) finish?.(); }
233
+ },
234
+ async close() {
235
+ closing = true;
236
+ if (active) await new Promise (resolve => { finish = resolve; });
237
+ await pool.close();
238
+ await ownedStore?.close();
239
+ await closePolicies(shared);
240
+ await closePlugins(plugins);
241
+ await sink.close();
242
+ },
243
+ };
244
+ }
Binary file
@@ -0,0 +1,75 @@
1
+ // Disposable local proof, never a claim about the production deployment.
2
+ import assert from 'node:assert/strict';
3
+ import {mkdtemp,mkdir,writeFile,copyFile,rm,statfs} from 'node:fs/promises';
4
+ import {tmpdir} from 'node:os';
5
+ import {join,resolve} from 'node:path';
6
+ import {DatabaseSync} from 'node:sqlite';
7
+ import {openLinkStore} from '../link-store.js';
8
+ import {startServer} from '../server.js';
9
+
10
+
11
+ import {HttpError} from '../errors.js';
12
+ // The SQLite rows the drills inspect; node:sqlite returns untyped records.
13
+
14
+
15
+
16
+
17
+ // node:sqlite boundary: a single-row query, typed by the caller's expectation.
18
+ const row= (db ,sql ) =>{const r=db.prepare(sql).get();assert.ok(r,`${sql} returned no row`);return r ;};
19
+ const seconds=Number(process.env.URLCODE_SOAK_SECONDS||5);
20
+ assert(Number.isInteger(seconds)&&seconds>=1&&seconds<=3600,'Soak must be 1–3600 seconds');
21
+ const directory=await mkdtemp(join(tmpdir(),'urlcode-drills-')),project=join(directory,'app');
22
+ let store ,app ;
23
+ try {
24
+ await mkdir(project);
25
+ const config='version: "1"\ndynamicLinks: true\nroutes:\n /go:\n redirect: {url: "https://example.com/v1"}\n /function:\n function: {source: f.mjs}\n /live/{code}:\n parameters:\n - {name: code, in: path, required: true, schema: {type: string}}\n link: {collection: links, code: {from: path, name: code}}\n';
26
+ await writeFile(join(project,'urlcode.yaml'),config);
27
+ await writeFile(join(project,'f.mjs'),'export default () => new Response("isolated");');
28
+ const file=join(directory,'links.sqlite');
29
+ store=await openLinkStore({file,project});let link=await store.create('links',{url:'https://example.com/live'},'demo');
30
+ app=await startServer({project,port:0,linkStore:{collection:'links',file},log:()=>{}});
31
+ const server=app;
32
+ const get=async(path )=>{const r=await fetch(`http://127.0.0.1:${server.address.port}${path}`,{redirect:'manual'});const body=await r.text();return {status:r.status,location:r.headers.get('location'),body};};
33
+ const start=performance.now(),samples =[];let requests=0;
34
+ while(performance.now()-start<seconds*1000){
35
+ const before=performance.now();const responses=await Promise.all(['/go','/function','/live/demo'].map(get));
36
+ assert.deepEqual(responses.map(r=>r.status),[302,200,302]);assert.equal(responses[1]?.body,'isolated');
37
+ assert.equal(responses[2]?.location,link.url);samples.push(performance.now()-before);requests+=3;
38
+ if(requests%30===0)link=await store.update('links','demo',{url:`https://example.com/live-${requests}`},link.version);
39
+ }
40
+ await writeFile(join(project,'urlcode.yaml'),'invalid: configuration');assert.equal(await app.reload(),false);assert.equal((await get('/go')).location,'https://example.com/v1');
41
+ await writeFile(join(project,'urlcode.yaml'),config.replace('/v1','/v2'));assert.equal(await app.reload(),true);assert.equal((await get('/go')).location,'https://example.com/v2');
42
+ await writeFile(join(project,'urlcode.yaml'),config);assert.equal(await app.reload(),true);assert.equal((await get('/go')).location,'https://example.com/v1');
43
+ await app.close();app=undefined;await store.close();store=undefined;
44
+ // A read-only connection may have been the last to close, leaving WAL frames.
45
+ // Quiesce all users, explicitly checkpoint, then close before copying.
46
+ const checkpointDb=new DatabaseSync(file);
47
+ try{const result=row (checkpointDb,'PRAGMA wal_checkpoint(TRUNCATE)');assert.equal(result.busy,0);assert.equal(result.log,0);assert.equal(result.checkpointed,0);}finally{checkpointDb.close();}
48
+ const restoreStart=performance.now(),backup=join(directory,'restored.sqlite');await copyFile(file,backup);
49
+ store=await openLinkStore({file:backup,project,readOnly:true});assert.deepEqual(await store.get('links','demo'),link);
50
+ const db=new DatabaseSync(backup,{readOnly:true});assert.equal(row (db,'PRAGMA integrity_check').integrity_check,'ok');assert.equal(row (db,'SELECT max(revision) AS revision FROM urlcode_link_audit').revision,link.version);db.close();
51
+ samples.sort((a,b)=>a-b);
52
+ console.log(JSON.stringify({event:'local-operational-proof',seconds,requests,batchP99Ms:samples[Math.floor(samples.length*.99)],rssBytes:process.memoryUsage().rss,restoreMs:performance.now()-restoreStart,checks:['mixed HTTP load','invalid reload preserves last-good','configuration rollback','quiesced backup/restore including audit'],deploymentProof:false}));
53
+ await store.close();store=undefined;
54
+ // Only enable inside a disposable, size-limited mount (CI uses 16 MiB tmpfs).
55
+ if(process.argv[2]==='--disk-full-dir'){
56
+ const volume=resolve(process.argv[3] ?? '');const isolated=await mkdtemp(join(volume,'urlcode-full-'));
57
+ try {
58
+ const reserve=join(isolated,'reserve');await writeFile(reserve,Buffer.alloc(4*1024*1024));
59
+ const fullFile=join(isolated,'full.sqlite');store=await openLinkStore({file:fullFile,project});let full=false,committed=0;
60
+ for(let i=0;i<12000;i++){
61
+ try{await store.create('links',{url:'https://example.com/'+ 'x'.repeat(7000)},'code-'+i);committed++;}
62
+ catch(error){assert.ok(error instanceof HttpError,'store failure must be an HttpError');assert.equal(error.status,503);full=true;break;}
63
+ }
64
+ assert(full,'Disposable volume did not fill within the 84 MiB write budget');
65
+ const space=await statfs(isolated);assert(space.bavail*space.bsize<1024*1024,'Expected actual volume exhaustion');
66
+ await rm(reserve);await store.create('links',{url:'https://example.com/recovered'},'recovered');committed++;
67
+ await store.close();store=undefined;
68
+ const check=new DatabaseSync(fullFile,{readOnly:true});
69
+ assert.equal(row (check,'PRAGMA integrity_check').integrity_check,'ok');
70
+ assert.equal(row (check,'SELECT count(*) AS n FROM urlcode_links').n,committed);
71
+ assert.equal(row (check,'SELECT count(*) AS n FROM urlcode_link_audit').n,committed);check.close();
72
+ console.log(JSON.stringify({event:'disposable-volume-exhaustion',committed,integrity:'ok',auditAtomic:true}));
73
+ }finally{await store?.close();store=undefined;await rm(isolated,{recursive:true,force:true});}
74
+ }
75
+ } finally {await app?.close();await store?.close();await rm(directory,{recursive:true,force:true});}