@jimhoyd/urlcode 0.3.0 → 0.4.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/urlcode-authoring/SKILL.md +106 -0
- package/.claude/skills/urlcode-operations/SKILL.md +114 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/CONTRIBUTING.md +30 -2
- package/README.md +157 -230
- package/ROADMAP.md +85 -2
- package/SECURITY.md +8 -1
- package/dist/BUILD-MANIFEST.json +62 -32
- package/dist/adapters.js +4 -23
- package/dist/agent-lists.js +1 -1
- package/dist/agents-guide.js +113 -0
- package/dist/authoring-files.js +60 -0
- package/dist/authoring.js +11 -1
- package/dist/aws.js +4 -3
- package/dist/build-cloudflare.js +11 -24
- package/dist/bulk.js +37 -0
- package/dist/capabilities.js +208 -0
- package/dist/capability-query.js +72 -0
- package/dist/catalog.js +105 -0
- package/dist/cli.js +154 -15
- package/dist/client-address.js +1 -1
- package/dist/compliance-rules/baseline.js +8 -8
- package/dist/compliance-rules/privacy.js +3 -3
- package/dist/compliance-rules/strict.js +5 -5
- package/dist/conditions.js +88 -0
- package/dist/config.js +60 -4
- package/dist/context.js +156 -0
- package/dist/ecosystem-cli.js +88 -0
- package/dist/egress.js +98 -0
- package/dist/examples.js +92 -0
- package/dist/explain-cli.js +64 -0
- package/dist/explain.js +132 -0
- package/dist/extensions.js +172 -0
- package/dist/function-sources.js +15 -3
- package/dist/index.js +37 -0
- package/dist/init-with.js +165 -0
- package/dist/interchange-cli.js +42 -0
- package/dist/interchange.js +189 -0
- package/dist/link-cli.js +1 -1
- package/dist/management-policy.js +0 -1
- package/dist/manifest.js +111 -0
- package/dist/match.js +2 -2
- package/dist/mcp-authoring.js +147 -0
- package/dist/mcp.js +97 -0
- package/dist/observability.js +6 -0
- package/dist/operator-host.js +29 -0
- package/dist/plugins.js +12 -0
- package/dist/policies/agents.js +2 -2
- package/dist/policies/compression.js +2 -1
- package/dist/policies/security.js +0 -0
- package/dist/policies.js +1 -1
- package/dist/policy.js +29 -7
- package/dist/prerender.js +100 -41
- package/dist/project-tests.js +3 -3
- package/dist/provider-verification.js +92 -0
- package/dist/proxy.js +44 -0
- package/dist/readiness.js +5 -5
- package/dist/recipes.js +41 -0
- package/dist/route-diff.js +106 -0
- package/dist/router.js +42 -2
- package/dist/runtime.js +95 -14
- package/dist/schema-query.js +62 -0
- package/dist/signals.js +24 -0
- package/dist/site.js +0 -0
- package/dist/tooling.js +96 -0
- package/dist/types/adapters.d.ts +7 -4
- package/dist/types/agent-lists.d.ts +0 -1
- package/dist/types/agents-guide.d.ts +17 -0
- package/dist/types/authoring-files.d.ts +10 -0
- package/dist/types/aws.d.ts +3 -1
- package/dist/types/build-cloudflare.d.ts +1 -0
- package/dist/types/bulk.d.ts +27 -0
- package/dist/types/capabilities.d.ts +55 -0
- package/dist/types/capability-query.d.ts +24 -0
- package/dist/types/catalog.d.ts +65 -0
- package/dist/types/client-address.d.ts +0 -1
- package/dist/types/compliance-rules/baseline.d.ts +1 -9
- package/dist/types/compliance-rules/privacy.d.ts +1 -4
- package/dist/types/compliance-rules/strict.d.ts +0 -5
- package/dist/types/conditions.d.ts +19 -0
- package/dist/types/config.d.ts +20 -2
- package/dist/types/context.d.ts +65 -0
- package/dist/types/ecosystem-cli.d.ts +17 -0
- package/dist/types/egress.d.ts +46 -0
- package/dist/types/examples.d.ts +50 -0
- package/dist/types/explain-cli.d.ts +11 -0
- package/dist/types/explain.d.ts +95 -0
- package/dist/types/extensions.d.ts +122 -0
- package/dist/types/function-sources.d.ts +5 -0
- package/dist/types/index.d.ts +33 -0
- package/dist/types/init-with.d.ts +30 -0
- package/dist/types/interchange-cli.d.ts +16 -0
- package/dist/types/interchange.d.ts +42 -0
- package/dist/types/link-cli.d.ts +2 -1
- package/dist/types/management-policy.d.ts +0 -1
- package/dist/types/manifest.d.ts +81 -0
- package/dist/types/match.d.ts +1 -0
- package/dist/types/mcp-authoring.d.ts +92 -0
- package/dist/types/mcp.d.ts +12 -0
- package/dist/types/observability.d.ts +2 -0
- package/dist/types/operator-host.d.ts +8 -0
- package/dist/types/plugins.d.ts +2 -0
- package/dist/types/policies/agents.d.ts +0 -2
- package/dist/types/policies/compression.d.ts +2 -0
- package/dist/types/policies/security.d.ts +0 -1
- package/dist/types/policy.d.ts +15 -4
- package/dist/types/project-tests.d.ts +3 -1
- package/dist/types/provider-verification.d.ts +53 -0
- package/dist/types/proxy.d.ts +21 -0
- package/dist/types/readiness.d.ts +1 -1
- package/dist/types/recipes.d.ts +30 -0
- package/dist/types/route-diff.d.ts +27 -0
- package/dist/types/runtime.d.ts +11 -0
- package/dist/types/schema-query.d.ts +12 -0
- package/dist/types/signals.d.ts +25 -0
- package/dist/types/site.d.ts +0 -1
- package/dist/types/tooling.d.ts +115 -0
- package/dist/types/types.d.ts +57 -0
- package/dist/types/typescript-authoring.d.ts +12 -0
- package/dist/types/vercel.d.ts +3 -1
- package/dist/types/verify-deployment.d.ts +47 -0
- package/dist/types.js +21 -2
- package/dist/typescript-authoring.js +104 -0
- package/dist/vercel.js +4 -3
- package/dist/verify-deployment.js +270 -0
- package/docs/AI-AUTHORING.md +130 -8
- package/docs/BULK.md +79 -0
- package/docs/CAPABILITIES.md +179 -0
- package/docs/CAPACITY.md +1 -1
- package/docs/CI.md +142 -0
- package/docs/CONDITIONS.md +74 -0
- package/docs/DEPLOYMENT-CHECKS.md +108 -0
- package/docs/DYNAMIC-LINKS.md +18 -518
- package/docs/EGRESS.md +125 -0
- package/docs/EXTENSIONS.md +226 -0
- package/docs/FRAMEWORK.md +182 -0
- package/docs/INSTALL.md +45 -7
- package/docs/INTERCHANGE.md +134 -0
- package/docs/MIDDLEWARE-EXAMPLES.md +75 -0
- package/docs/MIDDLEWARE.md +2 -0
- package/docs/NEXT-PHASE-PLAN.md +90 -0
- package/docs/NEXT-STEPS.md +415 -0
- package/docs/OBSERVABILITY.md +4 -2
- package/docs/OPERATIONAL-PROOF.md +4 -1
- package/docs/OPERATIONS.md +6 -3
- package/docs/PLUGINS.md +37 -0
- package/docs/POLICIES.md +12 -309
- package/docs/PRERENDER.md +40 -0
- package/docs/PROJECT-DIRECTION.md +42 -0
- package/docs/PROVIDER-VERIFICATION.md +84 -0
- package/docs/READINESS.md +21 -1
- package/docs/README.md +82 -31
- package/docs/RECIPES.md +99 -0
- package/docs/RELEASE-READINESS.md +11 -9
- package/docs/RELEASE-SECURITY.md +27 -4
- package/docs/SECURITY-AUDIT.md +1 -1
- package/docs/SPECIFICATION.md +95 -8
- package/docs/SPIKE-BUSINESS-SUITE.md +1013 -0
- package/docs/SPIKE-EXTENSION-MODEL.md +419 -0
- package/docs/SPIKE-EXTENSIONS.md +1 -0
- package/docs/SPIKE-LAMBDA-COMPILE.md +199 -0
- package/docs/STANDARDS.md +150 -142
- package/docs/STARTERS.md +21 -1
- package/docs/TOOLING.md +291 -0
- package/docs/TYPESCRIPT-AUTHORING.md +67 -0
- package/docs/TYPESCRIPT.md +1 -1
- package/docs/USABILITY-REVIEW.md +123 -0
- package/docs/YAML-GUIDE.md +18 -479
- package/docs/YAML-REFERENCE.md +127 -16
- package/docs/links/cli.md +110 -0
- package/docs/links/limits.md +175 -0
- package/docs/links/management-api.md +80 -0
- package/docs/links/pools.md +75 -0
- package/docs/links/setup.md +135 -0
- package/docs/policies/agents.md +1 -1
- package/docs/policies/contract.md +52 -0
- package/docs/policies/hardened.md +56 -0
- package/docs/policies/interoperability.md +169 -0
- package/docs/policies/operations.md +45 -0
- package/docs/yaml/assets.md +36 -0
- package/docs/yaml/conditions.md +20 -0
- package/docs/yaml/functions.md +160 -0
- package/docs/yaml/links.md +30 -0
- package/docs/yaml/middleware.md +29 -0
- package/docs/yaml/organization.md +74 -0
- package/docs/yaml/policies.md +37 -0
- package/docs/yaml/redirects.md +64 -0
- package/docs/yaml/responses.md +57 -0
- package/docs/yaml/site.md +24 -0
- package/examples/assets/example.yaml +17 -0
- package/examples/aws/example.yaml +20 -0
- package/examples/cloudflare/example.yaml +19 -0
- package/examples/compliance/example.yaml +11 -0
- package/examples/conditions/README.md +12 -0
- package/examples/conditions/example.yaml +19 -0
- package/examples/conditions/tests/requests.json +13 -0
- package/examples/conditions/urlcode.yaml +24 -0
- package/examples/cookbook/README.md +8 -4
- package/examples/cookbook/example.yaml +17 -0
- package/examples/cookbook/functions/catalog.mjs +3 -0
- package/examples/cookbook/functions/fail.mjs +4 -0
- package/examples/cookbook/functions/items.mjs +3 -0
- package/examples/cookbook/functions/profile.mjs +3 -0
- package/examples/cookbook/functions/resource.mjs +3 -0
- package/examples/cookbook/functions/status.mjs +3 -0
- package/examples/cookbook/middleware/auth.mjs +48 -0
- package/examples/cookbook/middleware/body.mjs +15 -0
- package/examples/cookbook/middleware/bucket.mjs +19 -0
- package/examples/cookbook/middleware/cors.mjs +21 -0
- package/examples/cookbook/middleware/debug.mjs +13 -0
- package/examples/cookbook/middleware/envelope.mjs +11 -0
- package/examples/cookbook/middleware/errors.mjs +11 -0
- package/examples/cookbook/middleware/etag.mjs +18 -0
- package/examples/cookbook/middleware/locale.mjs +16 -0
- package/examples/cookbook/middleware/maintenance.mjs +10 -0
- package/examples/cookbook/middleware/methods.mjs +15 -0
- package/examples/cookbook/middleware/negotiate.mjs +20 -0
- package/examples/cookbook/middleware/referer.mjs +12 -0
- package/examples/cookbook/middleware/request-id.mjs +16 -0
- package/examples/cookbook/route-index.json +676 -0
- package/examples/cookbook/routes/middleware.yaml +126 -0
- package/examples/cookbook/tests/requests.json +526 -0
- package/examples/cookbook/urlcode.yaml +1 -0
- package/examples/egress/README.md +22 -0
- package/examples/egress/example.yaml +19 -0
- package/examples/egress/urlcode.yaml +19 -0
- package/examples/extensions/README.md +7 -0
- package/examples/extensions/example.yaml +21 -0
- package/examples/extensions/urlcode.yaml +25 -0
- package/examples/live-links/example.yaml +21 -0
- package/examples/monitoring/example.yaml +8 -0
- package/examples/prerender/example.yaml +16 -0
- package/examples/provider-conformance/README.md +12 -0
- package/examples/provider-conformance/example.yaml +14 -0
- package/examples/provider-conformance/urlcode.yaml +34 -0
- package/examples/tunnel/example.yaml +8 -0
- package/examples/vercel/example.yaml +19 -0
- package/llms-full.txt +2709 -0
- package/llms.txt +48 -19
- package/package.json +29 -7
- package/packaging/claude-plugin/.claude-plugin/plugin.json +19 -0
- package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +106 -0
- package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +114 -0
- package/recipes/authenticated-json-api/README.md +51 -0
- package/recipes/authenticated-json-api/functions/profile.mjs +5 -0
- package/recipes/authenticated-json-api/recipe.yaml +34 -0
- package/recipes/authenticated-json-api/tests/requests.json +39 -0
- package/recipes/authenticated-json-api/urlcode.yaml +12 -0
- package/recipes/contact-form/README.md +25 -0
- package/recipes/contact-form/functions/contact.mjs +17 -0
- package/recipes/contact-form/recipe.yaml +33 -0
- package/recipes/contact-form/tests/requests.json +47 -0
- package/recipes/contact-form/urlcode.yaml +18 -0
- package/recipes/cors-api/README.md +16 -0
- package/recipes/cors-api/functions/items.mjs +3 -0
- package/recipes/cors-api/middleware/cors.mjs +21 -0
- package/recipes/cors-api/recipe.yaml +26 -0
- package/recipes/cors-api/tests/requests.json +65 -0
- package/recipes/cors-api/urlcode.yaml +12 -0
- package/recipes/health-page/README.md +13 -0
- package/recipes/health-page/recipe.yaml +23 -0
- package/recipes/health-page/tests/requests.json +36 -0
- package/recipes/health-page/urlcode.yaml +19 -0
- package/recipes/json-api/README.md +6 -0
- package/recipes/json-api/functions/echo.mjs +3 -0
- package/recipes/json-api/recipe.yaml +25 -0
- package/recipes/json-api/tests/requests.json +34 -0
- package/recipes/json-api/urlcode.yaml +12 -0
- package/recipes/middleware/README.md +34 -0
- package/recipes/middleware/functions/catalog.mjs +3 -0
- package/recipes/middleware/functions/fail.mjs +4 -0
- package/recipes/middleware/functions/items.mjs +3 -0
- package/recipes/middleware/functions/profile.mjs +3 -0
- package/recipes/middleware/functions/resource.mjs +3 -0
- package/recipes/middleware/functions/status.mjs +3 -0
- package/recipes/middleware/middleware/auth.mjs +48 -0
- package/recipes/middleware/middleware/body.mjs +15 -0
- package/recipes/middleware/middleware/bucket.mjs +19 -0
- package/recipes/middleware/middleware/cors.mjs +21 -0
- package/recipes/middleware/middleware/debug.mjs +13 -0
- package/recipes/middleware/middleware/envelope.mjs +11 -0
- package/recipes/middleware/middleware/errors.mjs +11 -0
- package/recipes/middleware/middleware/etag.mjs +18 -0
- package/recipes/middleware/middleware/locale.mjs +16 -0
- package/recipes/middleware/middleware/maintenance.mjs +10 -0
- package/recipes/middleware/middleware/methods.mjs +15 -0
- package/recipes/middleware/middleware/negotiate.mjs +20 -0
- package/recipes/middleware/middleware/referer.mjs +12 -0
- package/recipes/middleware/middleware/request-id.mjs +16 -0
- package/recipes/middleware/public/guide.txt +1 -0
- package/recipes/middleware/recipe.yaml +50 -0
- package/recipes/middleware/tests/requests.json +528 -0
- package/recipes/middleware/urlcode.yaml +127 -0
- package/recipes/protected-download/README.md +22 -0
- package/recipes/protected-download/files/report.txt +1 -0
- package/recipes/protected-download/recipe.yaml +31 -0
- package/recipes/protected-download/tests/requests.json +32 -0
- package/recipes/protected-download/urlcode.yaml +15 -0
- package/recipes/redirect/README.md +7 -0
- package/recipes/redirect/recipe.yaml +25 -0
- package/recipes/redirect/tests/requests.json +19 -0
- package/recipes/redirect/urlcode.yaml +9 -0
- package/recipes/static-plus-api/README.md +15 -0
- package/recipes/static-plus-api/functions/info.mjs +3 -0
- package/recipes/static-plus-api/public/assets/index.html +3 -0
- package/recipes/static-plus-api/public/assets/site.css +1 -0
- package/recipes/static-plus-api/public/index.html +8 -0
- package/recipes/static-plus-api/recipe.yaml +29 -0
- package/recipes/static-plus-api/tests/requests.json +56 -0
- package/recipes/static-plus-api/urlcode.yaml +23 -0
- package/recipes/typescript/README.md +7 -0
- package/recipes/typescript/functions/hello.ts +5 -0
- package/recipes/typescript/recipe.yaml +23 -0
- package/recipes/typescript/tests/requests.json +18 -0
- package/recipes/typescript/urlcode.yaml +5 -0
- package/recipes/webhook-receiver/README.md +16 -0
- package/recipes/webhook-receiver/functions/receive.mjs +16 -0
- package/recipes/webhook-receiver/recipe.yaml +26 -0
- package/recipes/webhook-receiver/tests/requests.json +59 -0
- package/recipes/webhook-receiver/urlcode.yaml +16 -0
- package/schemas/recipe.schema.json +138 -0
- package/schemas/urlcode.schema.json +656 -80
- package/skills/urlcode/SKILL.md +98 -0
- package/starters/default/.github/workflows/urlcode.yml +23 -0
- package/starters/default/.mcp.json +12 -0
- package/starters/default/AGENTS.md +79 -0
package/dist/recipes.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {fileURLToPath} from 'node:url';
|
|
2
|
+
import {readAuthoringFile,publishAuthoringProject} from './authoring-files.js';
|
|
3
|
+
import {validateDocument,parseYaml} from './config.js';
|
|
4
|
+
import {readMetadata,searchMetadata} from './catalog.js';
|
|
5
|
+
|
|
6
|
+
import {assert} from './errors.js';
|
|
7
|
+
|
|
8
|
+
/** recipe.yaml, validated against schemas/recipe.schema.json; `name` repeats `id` and `files` is the copy list. */
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
// This fixed local catalog is trusted package data, never an executable registry:
|
|
14
|
+
// names come from here, metadata from each recipe's schema-checked recipe.yaml.
|
|
15
|
+
export const recipeNames=['redirect','json-api','typescript','middleware','health-page','static-plus-api','cors-api','webhook-receiver','contact-form','authenticated-json-api','protected-download'] ;
|
|
16
|
+
const recipesRoot=fileURLToPath(new URL('../recipes/',import.meta.url));
|
|
17
|
+
const root=(name )=>recipesRoot+name+'/';
|
|
18
|
+
async function metadata(name ) {const value=await readMetadata(root(name),name,'recipe.yaml');return {name,...value};}
|
|
19
|
+
export async function listRecipes() {
|
|
20
|
+
const result =[];
|
|
21
|
+
for(const name of recipeNames)result.push(await metadata(name));
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
function flatten(hits ) {return hits.map(hit=>({...hit.entry,score:hit.score,matched:hit.matched}));}
|
|
25
|
+
/** Matches id, description, tags and capabilities locally; no service is consulted. */
|
|
26
|
+
export async function searchRecipes(text ) {
|
|
27
|
+
const hits=searchMetadata(await listRecipes(),text);
|
|
28
|
+
return {query:text,count:hits.length,results:flatten(hits)};
|
|
29
|
+
}
|
|
30
|
+
export async function showRecipe(name ) {
|
|
31
|
+
assert((recipeNames ).includes(name),'Unknown local recipe');
|
|
32
|
+
const summary=await metadata(name),content =Object.create(null) ;
|
|
33
|
+
for(const path of summary.files)content[path]=(await readAuthoringFile(root(name),path,1048576)).toString('utf8');
|
|
34
|
+
assert(content['urlcode.yaml'],'Recipe must copy urlcode.yaml');
|
|
35
|
+
validateDocument(parseYaml(content['urlcode.yaml']));
|
|
36
|
+
return {...summary,content};
|
|
37
|
+
}
|
|
38
|
+
export async function addRecipe(name ,output ,{dryRun=false} ={}) {
|
|
39
|
+
const recipe=await showRecipe(name);
|
|
40
|
+
return {name,output:await publishAuthoringProject(output,new Map(Object.entries(recipe.content)),dryRun),dryRun,files:[...recipe.files]};
|
|
41
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Route-inventory diff: compares two `urlcode routes` reports and reports what
|
|
2
|
+
// was added, removed or changed. Pure and generic: it knows nothing about
|
|
3
|
+
// pull requests; the GitHub action renders its Markdown into a PR comment.
|
|
4
|
+
import { ConfigError } from './errors.js';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
/** One route as the diff sees it: the inventory entry plus its policy description from the `policies` map. */
|
|
8
|
+
|
|
9
|
+
/** The parts of a `urlcode routes` report the diff reads. */
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
const states = ['active','disabled','expired'];
|
|
15
|
+
const isRecord = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
16
|
+
const isStrings = (value ) => Array.isArray(value) && value.every(item => typeof item === 'string');
|
|
17
|
+
function entry(value , index ) {
|
|
18
|
+
const fail = (what ) => { throw new ConfigError(`Route report inventory[${index}] ${what}`); };
|
|
19
|
+
if (!isRecord(value)) return fail('is not an object');
|
|
20
|
+
const { path, handler, methods, middleware, policies, generated, state } = value;
|
|
21
|
+
if (typeof path !== 'string' || !path) return fail('needs a path');
|
|
22
|
+
if (handler !== undefined && typeof handler !== 'string') return fail('has an invalid handler');
|
|
23
|
+
if (!isStrings(methods)) return fail('needs methods');
|
|
24
|
+
if (typeof middleware !== 'number' || !Number.isInteger(middleware) || middleware < 0) return fail('needs a middleware count');
|
|
25
|
+
if (!isStrings(policies)) return fail('needs policies');
|
|
26
|
+
if (generated !== undefined && typeof generated !== 'string') return fail('has an invalid generated marker');
|
|
27
|
+
if (typeof state !== 'string' || !states.includes(state )) return fail('needs a state');
|
|
28
|
+
return { path, handler, methods, middleware, policies, ...(generated !== undefined ? { generated } : {}), state:state };
|
|
29
|
+
}
|
|
30
|
+
/** Validates a parsed `urlcode routes` JSON report (a child-process or file boundary) into a snapshot. */
|
|
31
|
+
export function parseRouteSnapshot(value ) {
|
|
32
|
+
if (!isRecord(value) || !Array.isArray(value.inventory)) throw new ConfigError('Route report needs an inventory array');
|
|
33
|
+
const inventory = value.inventory.map(entry);
|
|
34
|
+
const policies = {};
|
|
35
|
+
if (value.policies !== undefined) {
|
|
36
|
+
if (!isRecord(value.policies)) throw new ConfigError('Route report policies must be an object');
|
|
37
|
+
for (const [path, description] of Object.entries(value.policies)) {
|
|
38
|
+
if (!isRecord(description)) throw new ConfigError(`Route report policies[${path}] must be an object`);
|
|
39
|
+
policies[path] = description;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { inventory, policies };
|
|
43
|
+
}
|
|
44
|
+
// Key order in a report is not a change: compare canonical JSON.
|
|
45
|
+
function canonical(value ) {
|
|
46
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
47
|
+
if (isRecord(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
48
|
+
return JSON.stringify(value) ?? 'null';
|
|
49
|
+
}
|
|
50
|
+
function records(snapshot ) {
|
|
51
|
+
const map = new Map ();
|
|
52
|
+
for (const item of snapshot.inventory) {
|
|
53
|
+
if (map.has(item.path)) throw new ConfigError(`Route report lists ${item.path} twice`);
|
|
54
|
+
const policy = snapshot.policies?.[item.path];
|
|
55
|
+
map.set(item.path, policy ? { ...item, policy } : { ...item });
|
|
56
|
+
}
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
/** Compares two route reports. Paths are sorted; an entry changes when any inventory field or its policy description differs. */
|
|
60
|
+
export function diffRoutes(before , after ) {
|
|
61
|
+
const previous = records(before), current = records(after);
|
|
62
|
+
const paths = [...new Set([...previous.keys(), ...current.keys()])].sort();
|
|
63
|
+
const diff = { added:[], removed:[], changed:[] };
|
|
64
|
+
for (const path of paths) {
|
|
65
|
+
const old = previous.get(path), now = current.get(path);
|
|
66
|
+
if (old && !now) diff.removed.push(old);
|
|
67
|
+
else if (now && !old) diff.added.push(now);
|
|
68
|
+
else if (old && now && canonical(old) !== canonical(now)) diff.changed.push({ path, before:old, after:now });
|
|
69
|
+
}
|
|
70
|
+
return diff;
|
|
71
|
+
}
|
|
72
|
+
export const hasRouteChanges = (diff ) => diff.added.length + diff.removed.length + diff.changed.length > 0;
|
|
73
|
+
|
|
74
|
+
const fields = ['handler','methods','state','middleware','policies','generated','policy'] ;
|
|
75
|
+
|
|
76
|
+
const cell = (value ) => {
|
|
77
|
+
const text = value === undefined ? '' : Array.isArray(value) && value.every(item => typeof item === 'string') ? value.join(', ') : typeof value === 'string' ? value : canonical(value);
|
|
78
|
+
// Backslashes first, so an escape this adds is never itself re-escaped.
|
|
79
|
+
return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ') || '-';
|
|
80
|
+
};
|
|
81
|
+
const code = (value ) => `\`${value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\|/g, '\\|')}\``;
|
|
82
|
+
function table(headers , rows ) {
|
|
83
|
+
return [`| ${headers.join(' | ')} |`, `|${headers.map(() => '---').join('|')}|`, ...rows.map(row => `| ${row.join(' | ')} |`)];
|
|
84
|
+
}
|
|
85
|
+
const routeRow = (record ) => [code(record.path), cell(record.handler), cell(record.methods), cell(record.state), String(record.middleware), cell(record.policies), cell(record.generated)];
|
|
86
|
+
/** Renders a diff as Markdown: one table per nonempty section, or "No route changes". */
|
|
87
|
+
export function renderRouteDiff(diff ) {
|
|
88
|
+
if (!hasRouteChanges(diff)) return 'No route changes\n';
|
|
89
|
+
const lines = [];
|
|
90
|
+
const routeHeaders = ['Route','Handler','Methods','State','Middleware','Policies','Generated'];
|
|
91
|
+
const section = (title , list ) => {
|
|
92
|
+
if (!list.length) return;
|
|
93
|
+
lines.push(`### ${title} (${list.length})`, '', ...table(routeHeaders, list.map(routeRow)), '');
|
|
94
|
+
};
|
|
95
|
+
section('Added routes', diff.added);
|
|
96
|
+
section('Removed routes', diff.removed);
|
|
97
|
+
if (diff.changed.length) {
|
|
98
|
+
const rows = [];
|
|
99
|
+
for (const change of diff.changed) {
|
|
100
|
+
const differing = fields.filter((field ) => canonical(change.before[field]) !== canonical(change.after[field]));
|
|
101
|
+
for (const field of differing) rows.push([code(change.path), field, cell(change.before[field]), cell(change.after[field])]);
|
|
102
|
+
}
|
|
103
|
+
lines.push(`### Changed routes (${diff.changed.length})`, '', ...table(['Route','Field','Before','After'], rows), '');
|
|
104
|
+
}
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|
package/dist/router.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { hasExtensionPolicy } from './extensions.js';
|
|
2
|
+
import { validateProxy } from './proxy.js';
|
|
3
|
+
import { validateSignal } from './signals.js';
|
|
4
|
+
|
|
5
|
+
import { normalizeMatch, assertDisjointMatches } from './conditions.js';
|
|
6
|
+
import { effectivePolicies } from './policies.js';
|
|
1
7
|
import { setImmediate as yieldTurn } from 'node:timers/promises';
|
|
2
8
|
import { compileHttp } from './http-policy.js';
|
|
3
9
|
import Ajv from 'ajv/dist/2020.js';
|
|
@@ -63,7 +69,7 @@ export async function compileRoutes(loaded , bindings
|
|
|
63
69
|
const parts = segments(pattern);
|
|
64
70
|
assert(!pattern.startsWith('/_urlcode'), 'The /_urlcode prefix is reserved for runtime operations');
|
|
65
71
|
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');
|
|
72
|
+
assert(!pattern.includes('*') || ((config.static || config.extension) && pattern.endsWith('/*') && parts.filter(p => p.includes('*')).length === 1 && parts.at(-1) === '*' && !names.length), 'Only static or extension routes support a terminal /* wildcard');
|
|
67
73
|
assert(!config.static || pattern.endsWith('/*'), 'Static routes require a terminal /* wildcard');
|
|
68
74
|
if (config.page || config.download || config.static) assert((config.methods || methodsDefault).every(m => methodsDefault.includes(m)), 'Asset routes support only GET and HEAD');
|
|
69
75
|
assert(new Set(names).size === names.length, 'Duplicate path parameter');
|
|
@@ -72,6 +78,28 @@ export async function compileRoutes(loaded , bindings
|
|
|
72
78
|
const route = { ...declared, pattern, parts, names, specificity: parts.length - names.length,
|
|
73
79
|
methods: config.methods || methodsDefault, parameters: [], env: dict(), secrets: dict(), responseHeaders: [], middleware: [] };
|
|
74
80
|
compileHttp(route);
|
|
81
|
+
if (config.match) route.match = normalizeMatch(config.match);
|
|
82
|
+
if(config.extension){assert(!config.middleware?.length&&!config.parameters?.length&&!config.env&&!config.secrets,'Extension handlers cannot declare guest middleware, parameters or bindings');assert(pattern.endsWith('/*')&&!names.length&&pattern!=='/*','Extension handler requires a non-root literal /* mount');}
|
|
83
|
+
if (config.match || config.conditional || config.extension || hasExtensionPolicy(loaded.document,config)) {
|
|
84
|
+
const cache = effectivePolicies(loaded.document,config).cache;
|
|
85
|
+
assert(!cache || cache.strategy === 'no-store', `${pattern}: conditional routing requires cache disabled or no-store`);
|
|
86
|
+
assert(!route.responseHeaders.some(([name,value]) => ['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control'].includes(name.toLowerCase()) && value !== 'no-store'), 'Conditional responses require no-store');
|
|
87
|
+
}
|
|
88
|
+
if (config.conditional) {
|
|
89
|
+
const matches = config.conditional.cases.map(item => normalizeMatch(item.match));
|
|
90
|
+
assertDisjointMatches(matches);
|
|
91
|
+
const compileBranch = async (branch ) => {
|
|
92
|
+
const entry = { ...branch, ...(config.parameters ? { parameters: config.parameters } : {}), ...(config.methods ? { methods: config.methods } : {}) };
|
|
93
|
+
const table = await compileRoutes({ ...loaded, routes: { [pattern]: entry } }, {}, {});
|
|
94
|
+
return table.exact.get(pattern) ?? [...table.byLength.values()].flat()[0] ;
|
|
95
|
+
};
|
|
96
|
+
route.conditionalRoutes = { cases: [] };
|
|
97
|
+
for (const [index,item] of config.conditional.cases.entries()) {
|
|
98
|
+
const { match: _match, ...branch } = item;
|
|
99
|
+
route.conditionalRoutes.cases.push({ match: matches[index] , route: await compileBranch(branch) });
|
|
100
|
+
}
|
|
101
|
+
if (config.conditional.fallback) route.conditionalRoutes.fallback = await compileBranch(config.conditional.fallback);
|
|
102
|
+
}
|
|
75
103
|
const seen = new Set ();
|
|
76
104
|
for (const param of config.parameters || []) {
|
|
77
105
|
const schema = param.schema, name = param.in === 'header' ? param.name.toLowerCase() : param.name;
|
|
@@ -101,6 +129,17 @@ export async function compileRoutes(loaded , bindings
|
|
|
101
129
|
assert(typeof value === 'string' && value.length, 'Missing required secret binding');
|
|
102
130
|
route.secrets[alias] = value;
|
|
103
131
|
}
|
|
132
|
+
const resolveHeaders=(headers ) => Object.fromEntries(Object.entries(headers||{}).map(([key,value])=>{if(typeof value==='string')return [key,value];assert(own(route.secrets,value.secret),'Unknown egress secret alias');return [key,route.secrets[value.secret] ];}));
|
|
133
|
+
if(config.proxy){
|
|
134
|
+
assert(!config.middleware?.length,'Proxy middleware is not supported; authorize requests with host policy before egress');
|
|
135
|
+
const cache=effectivePolicies(loaded.document,config).cache;
|
|
136
|
+
assert(!cache||cache.strategy==='no-store','Proxy routes require cache disabled or no-store');
|
|
137
|
+
assert(!route.responseHeaders.some(([name,value])=>['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control'].includes(name.toLowerCase())&&value!=='no-store'),'Proxy response caching must be no-store');
|
|
138
|
+
route.compiledProxy={...config.proxy,headers:resolveHeaders(config.proxy.headers)};validateProxy(route.compiledProxy);
|
|
139
|
+
for(const match of config.proxy.url.matchAll(/(?:\{|%7B)([A-Za-z_][A-Za-z0-9_]*)(?:\}|%7D)/gi))assert(names.includes(match[1] ), 'Proxy placeholder requires a declared path parameter');
|
|
140
|
+
assert(!Object.keys(config.response?.headers||{}).some(name=>name.toLowerCase()==='content-encoding'),'Proxy content encoding cannot be overridden');
|
|
141
|
+
}
|
|
142
|
+
if(config.signals){route.compiledSignals=config.signals.map(signal=>({...signal,headers:resolveHeaders(signal.headers)}));for(const signal of route.compiledSignals)validateSignal(signal);}
|
|
104
143
|
if (config.expires) {
|
|
105
144
|
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
145
|
route.expiresAt = Date.parse(config.expires);
|
|
@@ -141,7 +180,7 @@ export async function compileRoutes(loaded , bindings
|
|
|
141
180
|
route.function = { ...declaredFunction, source, export: declaredFunction.export || 'default' };
|
|
142
181
|
for (const ref of Object.values(declaredFunction.args || {})) referenceCheck(ref, route, true);
|
|
143
182
|
}
|
|
144
|
-
if (config.static) { route.prefix = pattern.slice(0, -1); mounts.push(route); }
|
|
183
|
+
if (config.static || config.extension) { route.prefix = pattern.slice(0, -1); mounts.push(route); }
|
|
145
184
|
else if (!names.length) exact.set(pattern, route);
|
|
146
185
|
else {
|
|
147
186
|
assert(dynamic.length < 1000, 'Maximum 1000 parameterized routes per snapshot');
|
|
@@ -160,6 +199,7 @@ export async function compileRoutes(loaded , bindings
|
|
|
160
199
|
if (!byLength.has(route.parts.length)) byLength.set(route.parts.length, []);
|
|
161
200
|
byLength.get(route.parts.length) .push(route);
|
|
162
201
|
}
|
|
202
|
+
for(const mount of mounts.filter(route=>route.extension)){const base=mount.parts.slice(0,-1);for(const candidate of [...exact.values(),...dynamic,...mounts]){if(candidate===mount)continue;const parts=candidate.parts;const shared=Math.min(base.length,parts.length-(candidate.prefix?1:0));const compatible=base.slice(0,shared).every((part,index)=>part===parts[index]||parameterName(parts[index] ));assert(!compatible||(!candidate.prefix&&parts.length<base.length),'Extension mount overlaps another route');}}
|
|
163
203
|
mounts.sort((a,b) => b.prefix .length - a.prefix .length);
|
|
164
204
|
assert(performance.now()<deadline, 'Route compilation deadline exceeded');
|
|
165
205
|
return { exact, byLength, mounts, modules: [...modules.keys()], count: exact.size + dynamic.length + mounts.length };
|
package/dist/runtime.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { prepareExtensions, effectiveExtensionPolicies, hasExtensionPolicy, extensionResponse } from './extensions.js';
|
|
2
|
+
|
|
3
|
+
import { EgressClient, EgressError } from './egress.js';
|
|
4
|
+
|
|
5
|
+
import { executeProxy } from './proxy.js';
|
|
6
|
+
import { SignalBroker } from './signals.js';
|
|
7
|
+
import { matchesRoute } from './conditions.js';
|
|
8
|
+
import { analyzeProjectCapabilities, assertTargetCompatibility } from './capabilities.js';
|
|
1
9
|
import { projectPlan, hasRedirect } from './readiness.js';
|
|
2
10
|
|
|
3
11
|
import { checkRequest, decorateResponse } from './http-policy.js';
|
|
@@ -6,7 +14,7 @@ import { loadDocument, loadBindings } from './config.js';
|
|
|
6
14
|
import { compileRoutes, parseTarget, matchRoute, contextFor, resolveValue, redirectLocation } from './router.js';
|
|
7
15
|
import { FunctionPool } from './functions.js';
|
|
8
16
|
|
|
9
|
-
import { prepareFunctionSnapshot, validatePolicy } from './policy.js';
|
|
17
|
+
import { prepareFunctionSnapshot, validatePolicy, authorizeEgress } from './policy.js';
|
|
10
18
|
|
|
11
19
|
import {openLinkStore} from './link-store.js';
|
|
12
20
|
|
|
@@ -35,11 +43,20 @@ import { applySite } from './site.js';
|
|
|
35
43
|
|
|
36
44
|
|
|
37
45
|
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
38
49
|
|
|
39
50
|
|
|
40
51
|
|
|
41
52
|
|
|
42
53
|
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
43
60
|
|
|
44
61
|
/** Per-request facts the host may read after handle() settles; never request text. */
|
|
45
62
|
|
|
@@ -71,11 +88,23 @@ export async function createRuntime(project , rawOptions
|
|
|
71
88
|
// route at the same path wins. The public origin, when the server knows
|
|
72
89
|
// it, is what absolute URLs in generated files are built from.
|
|
73
90
|
await applySite(loaded, { origin: options.origin, log: options.log });
|
|
91
|
+
if (options.only !== undefined) {
|
|
92
|
+
const only = options.only;
|
|
93
|
+
assert(Array.isArray(only) && only.every(pattern => typeof pattern === 'string'), 'Route restriction must be a string array');
|
|
94
|
+
// An unknown pattern is a caller mistake, not an empty selection: a silent
|
|
95
|
+
// miss would prerender a partial site that looks whole.
|
|
96
|
+
for (const pattern of only) assert(Object.hasOwn(loaded.routes, pattern), `Route restriction names unknown route ${pattern}`);
|
|
97
|
+
const kept = new Set(only);
|
|
98
|
+
for (const pattern of Object.keys(loaded.routes)) if (!kept.has(pattern)) delete loaded.routes[pattern];
|
|
99
|
+
}
|
|
100
|
+
assertTargetCompatibility(analyzeProjectCapabilities(loaded, options.target || 'node'));
|
|
74
101
|
const dynamicLinks=loaded.document.dynamicLinks===true;
|
|
75
102
|
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
103
|
const snapshot = await prepareFunctionSnapshot(loaded);
|
|
78
104
|
if (options.permissions) validatePolicy(options.permissions);
|
|
105
|
+
const egressGrants=authorizeEgress(loaded,snapshot.projectSha256,options.permissions);
|
|
106
|
+
const extensionPlan=prepareExtensions(loaded.document,loaded.routes,options.extensions,{origin:options.origin??'',target:options.target??'node',projectSha256:snapshot.projectSha256});
|
|
107
|
+
const bindings = await loadBindings(loaded.root, options.local, options.environment);
|
|
79
108
|
const compiled = await compileRoutes(loaded, bindings, options.permissions, snapshot.projectSha256);
|
|
80
109
|
const routes = [...compiled.mounts, ...compiled.exact.values(), ...[...compiled.byLength.values()].flat()];
|
|
81
110
|
const assets = await compileAssets(loaded.root, routes);
|
|
@@ -85,6 +114,9 @@ export async function createRuntime(project , rawOptions
|
|
|
85
114
|
// reloads: a new snapshot starts with empty counters and an empty cache.
|
|
86
115
|
const target = options.target || 'node';
|
|
87
116
|
const plugins = validatePlugins(options.plugins, target);
|
|
117
|
+
// Capture the operator declaration once, before activation hooks can mutate
|
|
118
|
+
// their plugin objects. This boundary applies to public guest routes too.
|
|
119
|
+
const credentialHeaders=new Set(plugins.flatMap(plugin=>plugin.credentialHeaders||[]).map(name=>name.toLowerCase()));
|
|
88
120
|
const shared = { target, log: options.log, routes: routes.length };
|
|
89
121
|
const anyPolicy = Boolean(loaded.document.policies) || routes.some(route => route.policies);
|
|
90
122
|
for (const route of routes) route.policy = anyPolicy ? await compilePolicies(loaded.document, route, { route, shared, target, root: loaded.root }) : null;
|
|
@@ -106,6 +138,19 @@ export async function createRuntime(project , rawOptions
|
|
|
106
138
|
let pool ;
|
|
107
139
|
try{pool=await new FunctionPool(routes, { root:loaded.root, snapshot, log:options.log, workers:options.workers, timeoutMs:options.timeoutMs, maxBytes:options.maxBytes }).start();}
|
|
108
140
|
catch(error){await ownedStore?.close();throw error;}
|
|
141
|
+
const proxyClient=new EgressClient({grantOrigins:egressGrants.proxy},options.egressDependencies);
|
|
142
|
+
const signalClient=new EgressClient({grantOrigins:egressGrants.signals,concurrency:8},options.egressDependencies);
|
|
143
|
+
let lastSignals={accepted:0,delivered:0,failed:0,dropped:0};
|
|
144
|
+
const signalBroker=new SignalBroker(signalClient,8,stats=>{for(const outcome of ['accepted','delivered','failed','dropped'] ){const count=stats[outcome]-lastSignals[outcome];if(count)sink({event:'signal',outcome,count});}lastSignals=stats;});
|
|
145
|
+
let extensionRegistry ;
|
|
146
|
+
try{extensionRegistry=await extensionPlan.activate();}
|
|
147
|
+
catch(error){await pool.close();await ownedStore?.close();await closePolicies(shared);await proxyClient.close();await signalClient.close();throw error;}
|
|
148
|
+
for(const name of extensionRegistry.credentialHeaders)credentialHeaders.add(name);
|
|
149
|
+
for(const route of routes)route.extensionPolicyNames=Object.keys(effectiveExtensionPolicies(loaded.document,route));
|
|
150
|
+
const privateRoutes=new Set(routes.filter(route=>route.extension||hasExtensionPolicy(loaded.document,route)).map(route=>route.pattern));
|
|
151
|
+
// Only an extension's own mount can serve its declared immutable assets.
|
|
152
|
+
const assetPrefixes=new Map(routes.filter(route=>route.extension).map(route=>[route.pattern,extensionRegistry.entries.get(route.extension ) .assetPrefixes]));
|
|
153
|
+
const assetContext=(method ,path ,pattern ) =>{const prefixes=assetPrefixes.get(pattern);return prefixes?.length?{method,path,prefixes}:undefined;};
|
|
109
154
|
let active = 0, closing = false, finish ;
|
|
110
155
|
// Response phase: cache store, throttle headers, security headers,
|
|
111
156
|
// compression, then operator plugins in reverse. A result produced by a
|
|
@@ -115,19 +160,23 @@ export async function createRuntime(project , rawOptions
|
|
|
115
160
|
// its status is not cacheable). A plugin short-circuit ran before any
|
|
116
161
|
// policy, so it skips every request-phase policy's response hook.
|
|
117
162
|
async function finishPolicies(policy , request , result , producer ) {
|
|
118
|
-
|
|
163
|
+
const confidential=privateRoutes.has(request.route),asset=assetContext(request.method,request.path,request.route);
|
|
164
|
+
let out = confidential?extensionResponse(result,asset):result;
|
|
119
165
|
for (const [module, state] of policy?.response || []) {
|
|
166
|
+
if(confidential&&module.name==='compression')continue;
|
|
120
167
|
if (producer === 'plugin' ? module.onRequest : module === producer) continue;
|
|
121
168
|
out = await module.onResponse?.(state, request, out) ?? out;
|
|
122
169
|
}
|
|
123
|
-
|
|
170
|
+
out=await pluginsResponse(plugins, request, out);
|
|
171
|
+
return confidential?extensionResponse(out,asset):out;
|
|
124
172
|
}
|
|
125
173
|
function policyInventory() {
|
|
126
174
|
return Object.fromEntries(routes.flatMap(route => route.policy && Object.keys(route.policy.describe).length ? [[route.pattern, route.policy.describe] ] : []));
|
|
127
175
|
}
|
|
128
176
|
const workers = () => ({ healthy: pool.slots.filter(slot => slot?.ready).length, slots: pool.size });
|
|
129
177
|
const testPlan = () => ({...projectPlan(compiled),dynamicLinks,policies:policyInventory()});
|
|
130
|
-
await activatePlugins(plugins, { testPlan, version: loaded.version + assets.digest, root: loaded.root, target });
|
|
178
|
+
try{await activatePlugins(plugins, { testPlan, version: loaded.version + assets.digest, root: loaded.root, target });}
|
|
179
|
+
catch(error){await Promise.all([extensionRegistry.close(),signalBroker.close(),signalClient.close(),proxyClient.close(),pool.close(),ownedStore?.close(),closePolicies(shared),closePlugins(plugins),sink.close()]);throw error;}
|
|
131
180
|
return {
|
|
132
181
|
get healthy() { return !closing && pool.healthy && Object.values(stores).every(store=>(store.readHealthy??store.healthy)!==false); },
|
|
133
182
|
assetWatch: assets.watch, version: loaded.version + assets.digest, count: compiled.count, root: loaded.root,
|
|
@@ -144,7 +193,7 @@ export async function createRuntime(project , rawOptions
|
|
|
144
193
|
},
|
|
145
194
|
requestLimit(target) {
|
|
146
195
|
const match = matchRoute(compiled, parseTarget(target));
|
|
147
|
-
return match?.route.request?.body?.maxBytes;
|
|
196
|
+
return match?.route.request?.body?.maxBytes ?? (match?.route.proxy||match?.route.extension?1048576:undefined);
|
|
148
197
|
},
|
|
149
198
|
async handle({ target, method = 'GET', headers = new Headers(), body, headerCounts, trace = {}, origin = 'http://localhost', client }) {
|
|
150
199
|
if (closing) throw new HttpError(503, 'Runtime unavailable');
|
|
@@ -160,20 +209,28 @@ export async function createRuntime(project , rawOptions
|
|
|
160
209
|
// Known from here on, so an error thrown by the route's own checks
|
|
161
210
|
// (disabled, expired) is answered with that route's security headers.
|
|
162
211
|
policy = route.policy;
|
|
212
|
+
const conditionRequest = { query: parsed.query, headers, method, origin, headerCounts };
|
|
213
|
+
if (route.match && !matchesRoute(route.match,conditionRequest)) throw new HttpError(404,'Not found');
|
|
163
214
|
if (route.enabled === false) throw new HttpError(404, 'Not found');
|
|
164
215
|
if (route.expiresAt && Date.now() >= route.expiresAt) throw new HttpError(410, 'Gone');
|
|
165
216
|
// Host policies and plugins run once the route is known and before
|
|
166
217
|
// its contract is checked: a denied agent or an exhausted budget is
|
|
167
218
|
// answered without reading a body or touching the sandbox.
|
|
168
|
-
|
|
219
|
+
const protectedRoute=privateRoutes.has(route.pattern);
|
|
220
|
+
const extensionRequest ={method,target,path:parsed.path,query:new URLSearchParams(parsed.query),headers:new Headers(headers),headerCounts:{...headerCounts},body:body??new Uint8Array(),origin:options.origin??origin,route:route.pattern,mount:route.extension?route.pattern.slice(0,-2):null,client:client??null};
|
|
221
|
+
if(protectedRoute&&(body?.byteLength??0)>Math.min(1048576,route.request?.body?.maxBytes??1048576))throw new HttpError(413,'Request body too large');
|
|
222
|
+
const authorize=async() =>{for(const name of Object.keys(effectiveExtensionPolicies(loaded.document,route))){const entry=extensionRegistry.entries.get(name) ;const result=await entry.instance.authorize (entry.policies.get(route.pattern) ,extensionRequest);if(result)return result;}return undefined;};
|
|
223
|
+
if (policy || plugins.length || protectedRoute) {
|
|
169
224
|
policyReq = policyRequest({ method, target, path: parsed.path, params: path, query: parsed.query, headers, headerCounts, client, origin, route });
|
|
170
225
|
trace.client = policyReq.client;
|
|
171
|
-
const early
|
|
172
|
-
|
|
226
|
+
if(!protectedRoute){const early=await pluginsRequest(plugins,policyReq);if(early)return await finishPolicies(policy,policyReq,early,'plugin');}
|
|
227
|
+
let authorized=false;
|
|
173
228
|
for (const [module, state] of policy?.request || []) {
|
|
229
|
+
if(protectedRoute&&module.name==='cache'&&!authorized){const denied=await authorize();authorized=true;if(denied)return await finishPolicies(policy,policyReq,denied);}
|
|
174
230
|
const result = await module.onRequest?.(state, policyReq);
|
|
175
231
|
if (result) return await finishPolicies(policy, policyReq, result, module);
|
|
176
232
|
}
|
|
233
|
+
if(protectedRoute){if(!authorized){const denied=await authorize();if(denied)return await finishPolicies(policy,policyReq,denied);}const early=await pluginsRequest(plugins,policyReq);if(early)return await finishPolicies(policy,policyReq,early,'plugin');}
|
|
177
234
|
}
|
|
178
235
|
if (!route.methods.includes(method)) {
|
|
179
236
|
const refused = { status: 405, headers: [['allow', route.methods.join(', ')]], body: Buffer.from('Method not allowed\n') };
|
|
@@ -182,10 +239,32 @@ export async function createRuntime(project , rawOptions
|
|
|
182
239
|
return policyReq ? await finishPolicies(policy, policyReq, refused) : refused;
|
|
183
240
|
}
|
|
184
241
|
checkRequest(route, body || Buffer.alloc(0), headers, headerCounts);
|
|
185
|
-
const finishResponse = async (result )
|
|
186
|
-
|
|
242
|
+
const finishResponse = async (result ) => {
|
|
243
|
+
const out = policyReq ? await finishPolicies(policy, policyReq, decorateResponse(route,result)) : decorateResponse(route,result);
|
|
244
|
+
if(method!=='HEAD'&&!trace.probe)for(const signal of route.compiledSignals||[])signalBroker.emit(signal,{route:route.pattern,status:out.status,method});
|
|
245
|
+
if (route.proxy || route.match || route.conditional) return { ...out, headers: [...out.headers.filter(([name]) => !['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control'].includes(name.toLowerCase())), ['cache-control','no-store']] };
|
|
246
|
+
return out;
|
|
247
|
+
};
|
|
248
|
+
// Policies, plugins and body checks retain the original request. Project
|
|
249
|
+
// inputs and the guest receive a separate, credential-free projection.
|
|
250
|
+
const guestHeaders=credentialHeaders.size?new Headers(headers):headers;
|
|
251
|
+
for(const name of credentialHeaders)guestHeaders.delete(name);
|
|
252
|
+
const context = contextFor(route, path, parsed.query, guestHeaders, headerCounts);
|
|
253
|
+
// A declared schema default must not recreate a withheld header entry.
|
|
254
|
+
for(const name of credentialHeaders)delete context.inputs.header[name];
|
|
187
255
|
let native ;
|
|
188
|
-
if
|
|
256
|
+
if(route.extension){native=extensionResponse(await extensionRegistry.entries.get(route.extension) .instance.handle(extensionRequest),assetContext(method,parsed.path,route.pattern));}
|
|
257
|
+
else if(route.compiledProxy){
|
|
258
|
+
try {const result=await executeProxy(proxyClient,route.compiledProxy,{method,url:origin+target,params:path,headers:Object.fromEntries(headers),...(body?{body}:{})});native={status:result.status,headers:Object.entries(result.headers),body:result.body};}
|
|
259
|
+
catch(error){throw new HttpError(error instanceof EgressError&&error.code==='timeout'?504:error instanceof EgressError&&['busy','closed','aborted'].includes(error.code)?503:502,'Proxy upstream unavailable');}
|
|
260
|
+
}
|
|
261
|
+
else if (route.conditionalRoutes) {
|
|
262
|
+
const selected = route.conditionalRoutes.cases.find(item => matchesRoute(item.match,conditionRequest))?.route ?? route.conditionalRoutes.fallback;
|
|
263
|
+
if (!selected) throw new HttpError(404,'Not found');
|
|
264
|
+
if (hasRedirect(selected)) native = { status: selected.redirect.status || 302, headers: [['location',redirectLocation(selected,context,parsed.query)]], body: Buffer.alloc(0) };
|
|
265
|
+
else native = selected.reply;
|
|
266
|
+
}
|
|
267
|
+
else if (hasRedirect(route)) native = { status: route.redirect.status || 302,
|
|
189
268
|
headers: [['location', redirectLocation(route, context, parsed.query)]], body: Buffer.alloc(0) };
|
|
190
269
|
else if(route.link){
|
|
191
270
|
// Resolution outcome for a trusted post-response observer. It records
|
|
@@ -212,9 +291,9 @@ export async function createRuntime(project , rawOptions
|
|
|
212
291
|
native = {status:error.status,headers:[['content-type','text/plain; charset=utf-8'],['cache-control','no-store']],body:Buffer.from(error.message+'\n')};
|
|
213
292
|
}
|
|
214
293
|
}
|
|
215
|
-
if (native && !route.middleware.length) return finishResponse(native);
|
|
294
|
+
if (native && !route.middleware.length) return await finishResponse(native);
|
|
216
295
|
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: [...
|
|
296
|
+
return await finishResponse(await pool.execute(route, { url: origin + target, method, headers: [...guestHeaders], body }, context, native));
|
|
218
297
|
} catch (error) {
|
|
219
298
|
if (policy !== undefined && error && typeof error === 'object') errorRoutes.set(error, policy);
|
|
220
299
|
if (policyReq) {
|
|
@@ -233,11 +312,13 @@ export async function createRuntime(project , rawOptions
|
|
|
233
312
|
},
|
|
234
313
|
async close() {
|
|
235
314
|
closing = true;
|
|
315
|
+
await Promise.all([signalBroker.close(),signalClient.close(),proxyClient.close()]);
|
|
236
316
|
if (active) await new Promise (resolve => { finish = resolve; });
|
|
237
317
|
await pool.close();
|
|
238
318
|
await ownedStore?.close();
|
|
239
319
|
await closePolicies(shared);
|
|
240
320
|
await closePlugins(plugins);
|
|
321
|
+
await extensionRegistry.close();
|
|
241
322
|
await sink.close();
|
|
242
323
|
},
|
|
243
324
|
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import {ConfigError} from './errors.js';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
const maxDepth=12;
|
|
8
|
+
let cached ;
|
|
9
|
+
function schema() {
|
|
10
|
+
cached??=JSON.parse(readFileSync(fileURLToPath(new URL('../schemas/urlcode.schema.json',import.meta.url)),'utf8')) ;
|
|
11
|
+
return cached;
|
|
12
|
+
}
|
|
13
|
+
const object=(value ) =>value!==null&&typeof value==='object'&&!Array.isArray(value);
|
|
14
|
+
const properties=(node ) =>object(node.properties)?node.properties:{};
|
|
15
|
+
function definition(root ,ref ) {
|
|
16
|
+
const defs=object(root.$defs)?root.$defs:{};
|
|
17
|
+
const name=ref.startsWith('#/$defs/')?ref.slice('#/$defs/'.length):undefined;
|
|
18
|
+
const found=name===undefined?undefined:defs[name];
|
|
19
|
+
if(!object(found))throw new ConfigError('Schema reference cannot be resolved');
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
/** Definitions that are their own dotted path stay summarized when nested, so `routes` does not inline the whole route object. */
|
|
23
|
+
const summarized ={'#/$defs/route':'route','#/$defs/policies':'policies'};
|
|
24
|
+
/** Inline every local `$ref` with a depth bound and a cycle guard. The bundled schema has no cycles; a cycle would surface as an annotation, not a crash. */
|
|
25
|
+
function inline(root ,node ,stack ) {
|
|
26
|
+
if(Array.isArray(node))return node.map(item=>inline(root,item,stack));
|
|
27
|
+
if(!object(node))return node;
|
|
28
|
+
if(typeof node.$ref==='string') {
|
|
29
|
+
const {$ref,...rest}=node;
|
|
30
|
+
if(stack.includes($ref)||stack.length>=maxDepth)return {...rest,$comment:`unresolved reference ${$ref}: ${stack.includes($ref)?'cycle':'depth limit'}`};
|
|
31
|
+
const path=summarized[$ref];
|
|
32
|
+
if(path!==undefined)return {type:'object',...rest,$comment:`${path} object; see urlcode schema ${path}`};
|
|
33
|
+
return {...inline(root,definition(root,$ref),[...stack,$ref]) ,...inline(root,rest,stack) };
|
|
34
|
+
}
|
|
35
|
+
return Object.fromEntries(Object.entries(node).map(([key,value])=>[key,inline(root,value,stack)]));
|
|
36
|
+
}
|
|
37
|
+
const resolved=(root ,node ) =>typeof node.$ref==='string'?{...definition(root,node.$ref),...Object.fromEntries(Object.entries(node).filter(([key])=>key!=='$ref'))}:node;
|
|
38
|
+
/** Top-level document keys first, then `route` itself and every route property (`redirect`, `function`, `middleware`, ...). */
|
|
39
|
+
export function schemaPathNames() {
|
|
40
|
+
const root=schema();
|
|
41
|
+
const route=definition(root,'#/$defs/route');
|
|
42
|
+
return [...new Set([...Object.keys(properties(root)),'route',...Object.keys(properties(route))])];
|
|
43
|
+
}
|
|
44
|
+
/** Only the fragment for a dotted path, with references resolved inline. No validation, defaults or authority. */
|
|
45
|
+
export function getSchemaFragment(path ) {
|
|
46
|
+
if(typeof path!=='string'||path.length>256||!/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/.test(path))throw new ConfigError('Schema path must be dotted property names; top-level names: '+schemaPathNames().join(', '));
|
|
47
|
+
const root=schema(),route=definition(root,'#/$defs/route');
|
|
48
|
+
const [first,...rest]=path.split('.');
|
|
49
|
+
let node ,pointer ;
|
|
50
|
+
if(first!==undefined&&object(properties(root)[first])){node=properties(root)[first] ;pointer='#/properties/'+first;}
|
|
51
|
+
else if(first==='route'){node=route;pointer='#/$defs/route';}
|
|
52
|
+
else if(first!==undefined&&object(properties(route)[first])){node=properties(route)[first] ;pointer='#/$defs/route/properties/'+first;}
|
|
53
|
+
else throw new ConfigError(`Unknown schema path; top-level names: ${schemaPathNames().join(', ')}`);
|
|
54
|
+
for(const segment of rest) {
|
|
55
|
+
const current=resolved(root,node);
|
|
56
|
+
const alternatives=[current,...(Array.isArray(current.oneOf)?current.oneOf.filter(object).map(item=>resolved(root,item)):[])];
|
|
57
|
+
const next=alternatives.map(item=>properties(item)[segment]).find(object);
|
|
58
|
+
if(!next)throw new ConfigError(`Unknown schema path segment; names under ${pointer}: ${[...new Set(alternatives.flatMap(item=>Object.keys(properties(item))))].join(', ')||'(none)'}`);
|
|
59
|
+
node=next;pointer+='/properties/'+segment;
|
|
60
|
+
}
|
|
61
|
+
return {format:1,path,pointer,schema:inline(root,resolved(root,node),[]) };
|
|
62
|
+
}
|
package/dist/signals.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { egressUrl, safeEgressHeaders } from './egress.js';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export function validateSignal(definition ) {if(!definition||typeof definition!=='object'||Object.keys(definition).some(key=>!['url','headers'].includes(key))) throw new Error('Invalid signal');egressUrl(definition.url);safeEgressHeaders(definition.headers||{});}
|
|
7
|
+
/** No queue, retries, ordering promise, or durable delivery. Counters contain no URL/header data. */
|
|
8
|
+
export class SignalBroker {
|
|
9
|
+
#onStats ; #client ; #concurrency ; #pending=new Set (); #controller=new AbortController();
|
|
10
|
+
#stats ={accepted:0,delivered:0,failed:0,dropped:0}; #closed=false;
|
|
11
|
+
constructor(client ,concurrency=8,onStats ) {if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>128) throw new Error('Invalid signal concurrency');this.#onStats=onStats;this.#client=client;this.#concurrency=concurrency;}
|
|
12
|
+
get stats() {return {...this.#stats};}
|
|
13
|
+
emit(definition ,event ) {
|
|
14
|
+
if(this.#closed||this.#pending.size>=this.#concurrency) {this.#stats.dropped++;this.#notify();return false;}
|
|
15
|
+
try {validateSignal(definition);} catch {this.#stats.dropped++;this.#notify();return false;}
|
|
16
|
+
if(!event.route.startsWith('/')||event.route.length>2048||!/^[A-Z]{1,16}$/.test(event.method)||!Number.isInteger(event.status)||event.status<100||event.status>599) {this.#stats.dropped++;this.#notify();return false;}
|
|
17
|
+
this.#stats.accepted++;this.#notify();
|
|
18
|
+
const body=Buffer.from(JSON.stringify({version:1,route:event.route,status:event.status,method:event.method}));
|
|
19
|
+
const pending=Promise.resolve().then(()=>this.#client.request({url:definition.url,method:'POST',headers:{...definition.headers,'content-type':'application/json'},body,signal:this.#controller.signal})).then(result=>{if(result.status>=200&&result.status<300)this.#stats.delivered++;else this.#stats.failed++;},()=>{this.#stats.failed++;}).finally(()=>{this.#pending.delete(pending);this.#notify();});
|
|
20
|
+
this.#pending.add(pending);return true;
|
|
21
|
+
}
|
|
22
|
+
#notify() {try {this.#onStats?.(this.stats);} catch { /* Observers cannot alter delivery. */ }}
|
|
23
|
+
async close() {this.#closed=true;this.#controller.abort();await Promise.allSettled([...this.#pending]);}
|
|
24
|
+
}
|
package/dist/site.js
CHANGED
|
Binary file
|