@jimhoyd/urlcode 0.3.0 → 0.4.0-alpha.2
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 +122 -0
- package/.claude/skills/urlcode-operations/SKILL.md +108 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/CONTRIBUTING.md +30 -2
- package/README.md +195 -255
- package/ROADMAP.md +143 -15
- package/SECURITY.md +31 -9
- package/dist/BUILD-MANIFEST.json +72 -47
- 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/build-static.js +134 -0
- package/dist/bulk.js +37 -0
- package/dist/capabilities.js +262 -0
- package/dist/capability-query.js +71 -0
- package/dist/catalog.js +105 -0
- package/dist/cli.js +165 -34
- package/dist/client-address.js +1 -1
- package/dist/compliance-rules/baseline.js +9 -17
- package/dist/compliance-rules/privacy.js +7 -18
- package/dist/compliance-rules/shared.js +0 -2
- package/dist/compliance-rules/strict.js +5 -5
- package/dist/compliance.js +6 -8
- package/dist/conditions.js +88 -0
- package/dist/config.js +69 -6
- package/dist/context.js +155 -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 +131 -0
- package/dist/extensions.js +231 -0
- package/dist/function-sources.js +49 -5
- package/dist/function-worker.js +3 -1
- package/dist/functions.js +84 -13
- package/dist/guest-api.js +29 -3
- package/dist/index.js +40 -6
- package/dist/init-with.js +165 -0
- package/dist/interchange-cli.js +42 -0
- package/dist/interchange.js +189 -0
- package/dist/manifest.js +109 -0
- package/dist/match.js +2 -2
- package/dist/mcp-authoring.js +147 -0
- package/dist/mcp.js +97 -0
- package/dist/observability.js +7 -21
- package/dist/operator-host.js +29 -0
- package/dist/plugins.js +12 -0
- package/dist/policies/agents.js +2 -2
- package/dist/policies/cache.js +8 -3
- package/dist/policies/compression.js +2 -1
- package/dist/policies/security.js +0 -0
- package/dist/policies.js +1 -1
- package/dist/policy.js +56 -15
- 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 +34 -11
- package/dist/recipes.js +41 -0
- package/dist/route-diff.js +106 -0
- package/dist/router.js +45 -7
- package/dist/runtime.js +164 -64
- package/dist/sandbox.js +48 -0
- package/dist/scaffold.js +0 -0
- package/dist/schema-query.js +62 -0
- package/dist/scripts/operational-drills.js +12 -54
- package/dist/server.js +3 -29
- package/dist/signals.js +24 -0
- package/dist/site.js +0 -0
- package/dist/tooling.js +96 -0
- package/dist/trusted-functions.js +210 -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/build-static.d.ts +43 -0
- package/dist/types/bulk.d.ts +27 -0
- package/dist/types/capabilities.d.ts +64 -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/shared.d.ts +0 -2
- package/dist/types/compliance-rules/strict.d.ts +0 -5
- package/dist/types/compliance.d.ts +0 -3
- package/dist/types/conditions.d.ts +19 -0
- package/dist/types/config.d.ts +21 -2
- package/dist/types/context.d.ts +66 -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 +177 -0
- package/dist/types/function-sources.d.ts +9 -0
- package/dist/types/functions.d.ts +48 -5
- package/dist/types/guest-api.d.ts +1 -0
- package/dist/types/index.d.ts +36 -6
- 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/manifest.d.ts +79 -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 +3 -14
- 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 -2
- package/dist/types/provider-verification.d.ts +53 -0
- package/dist/types/proxy.d.ts +21 -0
- package/dist/types/readiness.d.ts +10 -3
- package/dist/types/recipes.d.ts +30 -0
- package/dist/types/route-diff.d.ts +27 -0
- package/dist/types/router.d.ts +2 -1
- package/dist/types/runtime.d.ts +11 -27
- package/dist/types/sandbox.d.ts +12 -0
- package/dist/types/scaffold.d.ts +0 -2
- package/dist/types/schema-query.d.ts +12 -0
- package/dist/types/server.d.ts +1 -4
- 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/trusted-functions.d.ts +29 -0
- package/dist/types/types.d.ts +71 -7
- 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 +37 -5
- package/dist/typescript-authoring.js +142 -0
- package/dist/vercel.js +4 -3
- package/dist/verify-deployment.js +270 -0
- package/docs/AI-AUTHORING.md +232 -15
- package/docs/AWS.md +4 -4
- package/docs/BEST-PRACTICES.md +3 -2
- package/docs/BULK.md +79 -0
- package/docs/CAPABILITIES.md +192 -0
- package/docs/CAPACITY.md +129 -32
- package/docs/CI.md +142 -0
- package/docs/CLOUDFLARE.md +1 -2
- package/docs/COMPLIANCE.md +6 -9
- package/docs/CONDITIONS.md +74 -0
- package/docs/DEPLOYMENT-CHECKS.md +108 -0
- package/docs/EGRESS.md +125 -0
- package/docs/EXTENSIONS.md +398 -0
- package/docs/FRAMEWORK.md +198 -0
- package/docs/FUNCTION-SECURITY.md +129 -32
- package/docs/INSTALL.md +45 -12
- package/docs/INTERCHANGE.md +134 -0
- package/docs/LOAD-TESTING.md +4 -4
- package/docs/MIDDLEWARE-EXAMPLES.md +75 -0
- package/docs/MIDDLEWARE.md +29 -16
- package/docs/MONITORING.md +2 -19
- package/docs/NEXT-PHASE-PLAN.md +98 -0
- package/docs/NEXT-STEPS.md +634 -0
- package/docs/OBSERVABILITY.md +11 -18
- package/docs/OPEN-DECISIONS.md +212 -0
- package/docs/OPERATIONAL-PROOF.md +30 -31
- package/docs/OPERATIONS.md +29 -35
- package/docs/PLUGINS.md +37 -0
- package/docs/POLICIES.md +23 -309
- package/docs/PRERENDER.md +41 -1
- package/docs/PROJECT-DIRECTION.md +75 -8
- package/docs/PROVIDER-VERIFICATION.md +84 -0
- package/docs/READINESS.md +21 -1
- package/docs/README.md +87 -34
- package/docs/RECIPES.md +99 -0
- package/docs/RELEASE-READINESS.md +57 -35
- package/docs/RELEASE-SECURITY.md +116 -7
- package/docs/RESILIENCE.md +16 -15
- package/docs/ROUTING.md +8 -10
- package/docs/SANDBOX-REVIEW.md +19 -6
- package/docs/SCAFFOLDING.md +0 -2
- package/docs/SECURITY-AUDIT.md +41 -1
- package/docs/SPECIFICATION.md +150 -29
- package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +287 -0
- package/docs/SPIKE-BUSINESS-SUITE.md +1021 -0
- package/docs/SPIKE-CORE-LAYERING.md +337 -0
- package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +209 -0
- package/docs/SPIKE-EXTENSION-MODEL.md +419 -0
- package/docs/SPIKE-EXTENSIONS.md +6 -0
- package/docs/SPIKE-LAMBDA-COMPILE.md +201 -0
- package/docs/SPIKE-MONOREPO.md +322 -0
- package/docs/STANDARDS.md +150 -142
- package/docs/STARTERS.md +21 -1
- package/docs/STATIC.md +94 -0
- package/docs/TOOLING.md +295 -0
- package/docs/TUNNELS.md +0 -3
- package/docs/TYPESCRIPT-AUTHORING.md +82 -0
- package/docs/TYPESCRIPT.md +25 -4
- package/docs/USABILITY-REVIEW.md +129 -0
- package/docs/VERCEL.md +4 -5
- package/docs/VERSION-ALIGNMENT.md +205 -0
- package/docs/YAML-GUIDE.md +15 -479
- package/docs/YAML-REFERENCE.md +143 -22
- package/docs/policies/agents.md +1 -1
- package/docs/policies/cache.md +13 -0
- 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/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 +29 -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 +20 -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/monitoring/example.yaml +8 -0
- package/examples/prerender/README.md +2 -2
- 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 +3084 -0
- package/llms.txt +61 -21
- package/package.json +36 -7
- package/packaging/claude-plugin/.claude-plugin/plugin.json +19 -0
- package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +122 -0
- package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +108 -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 +29 -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 +20 -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 +8 -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 +20 -0
- package/recipes/webhook-receiver/functions/receive.mjs +16 -0
- package/recipes/webhook-receiver/recipe.yaml +27 -0
- package/recipes/webhook-receiver/tests/requests.json +59 -0
- package/recipes/webhook-receiver/urlcode.yaml +23 -0
- package/schemas/recipe.schema.json +139 -0
- package/schemas/urlcode.schema.json +659 -110
- package/skills/urlcode/SKILL.md +119 -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/starters/default/urlcode.yaml +0 -1
- package/dist/link-api.js +0 -136
- package/dist/link-cli.js +0 -141
- package/dist/link-events.js +0 -76
- package/dist/link-records.js +0 -31
- package/dist/link-store-worker.js +0 -150
- package/dist/link-store.js +0 -250
- package/dist/management-policy.js +0 -41
- package/dist/sqlite-version.js +0 -6
- package/dist/types/link-api.d.ts +0 -30
- package/dist/types/link-cli.d.ts +0 -36
- package/dist/types/link-events.d.ts +0 -27
- package/dist/types/link-records.d.ts +0 -11
- package/dist/types/link-store-worker.d.ts +0 -1
- package/dist/types/link-store.d.ts +0 -130
- package/dist/types/management-policy.d.ts +0 -9
- package/dist/types/sqlite-version.d.ts +0 -1
- package/docs/DYNAMIC-LINKS.md +0 -561
- package/docs/MANAGEMENT-SECURITY.md +0 -82
- package/examples/live-links/README.md +0 -11
- package/examples/live-links/tests/requests.json +0 -6
- package/examples/live-links/urlcode.yaml +0 -16
package/dist/examples.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import {basename} from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {loadDocument,parseYaml} from './config.js';
|
|
5
|
+
import {applySite} from './site.js';
|
|
6
|
+
import {routeCapabilities} from './capabilities.js';
|
|
7
|
+
import {readMetadata,searchMetadata,searchTerms} from './catalog.js';
|
|
8
|
+
|
|
9
|
+
import {assert} from './errors.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
/** example.yaml, the same schema as recipe.yaml; `name` repeats `id`. */
|
|
13
|
+
|
|
14
|
+
/** One cookbook route in the generated tag index (examples/cookbook/route-index.json). */
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
// Fixed package data, like the recipe catalog: names come from here, metadata from each example.yaml.
|
|
24
|
+
export const exampleNames=['assets','aws','cloudflare','compliance','conditions','cookbook','egress','extensions','monitoring','prerender','provider-conformance','tunnel','vercel'] ;
|
|
25
|
+
export const routeIndexFile='route-index.json';
|
|
26
|
+
const examplesRoot=fileURLToPath(new URL('../examples/',import.meta.url));
|
|
27
|
+
const root=(name )=>examplesRoot+name+'/';
|
|
28
|
+
async function metadata(name ) {const value=await readMetadata(root(name),name,'example.yaml');return {name,...value};}
|
|
29
|
+
export async function listExamples() {
|
|
30
|
+
const result =[];
|
|
31
|
+
for(const name of exampleNames)result.push(await metadata(name));
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
const handlerNames=['extension','proxy','conditional','redirect','function','page','static','download','respond'] ;
|
|
35
|
+
function handlerOf(route ) {return handlerNames.find(name=>route[name]!==undefined)??'unknown';}
|
|
36
|
+
/**
|
|
37
|
+
* Derives the per-route tag index of one project from its loaded routes: handler,
|
|
38
|
+
* methods, capabilities, policy names and middleware module names. The file each
|
|
39
|
+
* route comes from is read from the entry and its includes; routes that neither
|
|
40
|
+
* declares were generated by `site`.
|
|
41
|
+
*/
|
|
42
|
+
export async function buildRouteIndex(project ,label ) {
|
|
43
|
+
const loaded=await loadDocument(project);await applySite(loaded,{});
|
|
44
|
+
const origin=new Map ();
|
|
45
|
+
for(const file of ['urlcode.yaml',...(loaded.document.includes??[])]){
|
|
46
|
+
const parsed=parseYaml(await readFile(project+'/'+file,'utf8')) ;
|
|
47
|
+
for(const path of Object.keys(parsed.routes??{}))origin.set(path,file);
|
|
48
|
+
}
|
|
49
|
+
const entries =Object.entries(loaded.routes).map(([path,route])=>{
|
|
50
|
+
const handler=handlerOf(route),file=origin.get(path)??'site';
|
|
51
|
+
const methods=route.methods??['GET','HEAD'];
|
|
52
|
+
const tags=new Set ([handler,...(file==='site'?['site']:[]),...methods.map(method=>method.toLowerCase())]);
|
|
53
|
+
for(const capability of routeCapabilities(route,loaded.document))if(capability!=='methods'&&capability!=='enabled')tags.add(capability);
|
|
54
|
+
for(const module of route.middleware??[])tags.add(basename(module.source).replace(/\.[cm]?js$/,''));
|
|
55
|
+
if(route.enabled===false)tags.add('disabled');
|
|
56
|
+
if(route.expires)tags.add('expiring');
|
|
57
|
+
return {path,file,handler,methods,tags:[...tags],description:route.description??''};
|
|
58
|
+
});
|
|
59
|
+
return {format:1,project:label,routes:entries.length,entries};
|
|
60
|
+
}
|
|
61
|
+
export async function readRouteIndex(name ) {
|
|
62
|
+
const value =JSON.parse(await readFile(root(name)+routeIndexFile,'utf8'));
|
|
63
|
+
assert(typeof value==='object'&&value!==null&&(value ).format===1&&Array.isArray((value ).entries),'Malformed route index');
|
|
64
|
+
return value ;
|
|
65
|
+
}
|
|
66
|
+
function matchRoutes(index ,terms ) {
|
|
67
|
+
const scored=index.entries.map(entry=>{
|
|
68
|
+
const text=[entry.path,entry.description].join(' ').toLowerCase();
|
|
69
|
+
let score=0;
|
|
70
|
+
for(const term of terms){if(entry.tags.includes(term))score+=4;else if(entry.tags.some(tag=>tag.includes(term)))score+=2;else if(text.includes(term))score+=1;}
|
|
71
|
+
return {entry,score};
|
|
72
|
+
}).filter(item=>item.score>0).sort((a,b)=>b.score-a.score);
|
|
73
|
+
return scored.map(item=>item.entry);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Searches example.yaml files and the cookbook's per-route index locally. Results
|
|
77
|
+
* are ordered smallest runnable example first, so `best` is the least code that
|
|
78
|
+
* demonstrates the match; cookbook hits carry their matching routes.
|
|
79
|
+
*/
|
|
80
|
+
export async function searchExamples(text ) {
|
|
81
|
+
const terms=searchTerms(text),examples=await listExamples();
|
|
82
|
+
const indexes=new Map ();
|
|
83
|
+
for(const example of examples)if(example.id==='cookbook')indexes.set(example.id,await readRouteIndex(example.id));
|
|
84
|
+
const routeText=(example )=>(indexes.get(example.id)?.entries??[]).flatMap(entry=>[entry.path,entry.description,...entry.tags]);
|
|
85
|
+
const hits =searchMetadata(examples,text,routeText);
|
|
86
|
+
const results=hits.map(hit=>({...hit.entry,score:hit.score,matched:hit.matched,matchedRoutes:indexes.has(hit.entry.id)?matchRoutes(indexes.get(hit.entry.id) ,terms):[]}))
|
|
87
|
+
.sort((a,b)=>Number(b.runnable!==false)-Number(a.runnable!==false)||(a.routes??0)-(b.routes??0)||b.score-a.score);
|
|
88
|
+
const first=results.find(result=>result.runnable!==false);
|
|
89
|
+
let best =null;
|
|
90
|
+
if(first){const {score:_score,matched:_matched,matchedRoutes,...summary}=first;best={...summary,...(matchedRoutes[0]?{route:matchedRoutes[0]}:{})};}
|
|
91
|
+
return {query:text,count:results.length,best,results};
|
|
92
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {ConfigError} from './errors.js';
|
|
2
|
+
import {capabilityTargets, normalizeCapabilityTarget} from './capabilities.js';
|
|
3
|
+
|
|
4
|
+
import {explainRoute,explainProject} from './tooling.js';
|
|
5
|
+
|
|
6
|
+
import {buildManifest,renderManifest} from './manifest.js';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const table=(rows ) =>{const widths=rows[0] .map((_,i)=>Math.max(...rows.map(row=>row[i] .length)));return rows.map(row=>row.map((cell,i)=>cell.padEnd(widths[i] )).join(' ').trimEnd()).join('\n')+'\n';};
|
|
11
|
+
const targetsOf=(target ) =>target===undefined?[...capabilityTargets]:[normalizeCapabilityTarget(target)];
|
|
12
|
+
function summary(explanation ,targets ) {
|
|
13
|
+
const handler=explanation.handler,detail=handler.kind==='function'?`${handler.source }#${handler.export }`:handler.kind==='redirect'?`${handler.status } ${handler.url }`:handler.kind==='extension'?handler.name :handler.kind==='page'||handler.kind==='download'?handler.file :handler.kind==='static'?handler.directory :handler.kind==='proxy'?handler.url :handler.kind==='respond'?String(handler.status):'';
|
|
14
|
+
const support=targets.map(target=>explanation.targets[target].compatible?target:`${target}:refused`).join(',');
|
|
15
|
+
return [explanation.path,explanation.methods.join(','),`${handler.kind}${detail?` ${detail}`:''}`,explanation.state,String(explanation.middleware.length),explanation.policies.names.join(',')||'-',explanation.cache.outcome,support].join('\t');
|
|
16
|
+
}
|
|
17
|
+
function detail(explanation ,targets ) {
|
|
18
|
+
const lines =[`route: ${explanation.path}`];
|
|
19
|
+
if(explanation.description)lines.push(`description: ${explanation.description}`);
|
|
20
|
+
if(explanation.generated)lines.push(`generated: site.${explanation.generated}`);
|
|
21
|
+
lines.push(`state: ${explanation.state}${explanation.expires?` (expires ${explanation.expires})`:''}`,`methods: ${explanation.methods.join(', ')}`,`handler: ${JSON.stringify(explanation.handler)}`);
|
|
22
|
+
lines.push(`middleware: ${explanation.middleware.length?explanation.middleware.map(item=>`${item.source}#${item.export}`).join(' -> '):'none'}`);
|
|
23
|
+
lines.push(`inputs: ${explanation.inputs.parameters.length?explanation.inputs.parameters.map(p=>`${p.in}:${p.name}${p.required?'':'?'}`).join(', '):'none'}${explanation.inputs.body?`; body ${JSON.stringify(explanation.inputs.body)}`:''}`);
|
|
24
|
+
lines.push(`policies: ${explanation.policies.names.length?explanation.policies.names.join(', '):'none'}`);
|
|
25
|
+
for(const [name,entry] of Object.entries(explanation.policies.inventory))lines.push(` ${name}: ${JSON.stringify(entry)}`);
|
|
26
|
+
for(const [name,entry] of Object.entries(explanation.policies.extensions))lines.push(` extensions.${name}: requires ${JSON.stringify(entry.requirement)}${entry.provider?entry.provider.registered?` (provider registered, revision ${entry.provider.revisionMatch?'matches':'differs'}, requirement ${entry.provider.requirementValid===false?'invalid':'valid'})`:' (no provider in host file)':''}`);
|
|
27
|
+
lines.push(`cache: ${explanation.cache.outcome}${explanation.cache.cacheControl?` (${explanation.cache.cacheControl})`:''}${explanation.cache.forcedNoStore?' forced':''}; ${explanation.cache.reason}`);
|
|
28
|
+
const env=Object.entries(explanation.bindings.env).map(([alias,ref])=>`${alias}=${'env' in ref?`$${ref.env}`:'literal'}`),secrets=Object.entries(explanation.bindings.secrets).map(([alias,ref])=>`${alias}=secret:${ref.secret}`);
|
|
29
|
+
lines.push(`bindings: ${[...env,...secrets].join(', ')||'none'}`);
|
|
30
|
+
if(explanation.egress.proxy||explanation.egress.signals)lines.push(`egress: ${[explanation.egress.proxy?`proxy ${explanation.egress.proxy}`:'',...(explanation.egress.signals??[]).map(item=>`signal ${item}`)].filter(Boolean).join(', ')}`);
|
|
31
|
+
if(explanation.responseHeaders.length)lines.push(`response headers: ${explanation.responseHeaders.map(([name,value])=>`${name}: ${value}`).join('; ')}`);
|
|
32
|
+
lines.push(`capabilities: ${explanation.capabilities.join(', ')}`);
|
|
33
|
+
for(const target of targets){const support=explanation.targets[target];lines.push(`target ${target}: ${support.compatible?'supported':support.issues.map(issue=>`${issue.capability} ${issue.support} (${issue.reason})`).join('; ')}`);}
|
|
34
|
+
lines.push(`note: ${explanation.note}`);
|
|
35
|
+
return lines.join('\n')+'\n';
|
|
36
|
+
}
|
|
37
|
+
/** Returns the process exit code: 1 when a requested route does not exist. */
|
|
38
|
+
export async function runExplainCommand(command ,route ,options ,print ) {
|
|
39
|
+
const base={...(options.origin===undefined?{}:{origin:options.origin}),...(options.extensions===undefined?{}:{extensions:options.extensions})};
|
|
40
|
+
if(command==='manifest'){
|
|
41
|
+
if(route!==undefined)throw new ConfigError('manifest takes no route argument');
|
|
42
|
+
const manifest=await buildManifest(options.project,base);
|
|
43
|
+
if(options.json){print(renderManifest(manifest));return 0;}
|
|
44
|
+
const lines=[`revision: ${manifest.revision}`,`urlcode: ${manifest.urlcode}`,`files: ${manifest.files.join(', ')}`,`routes: ${manifest.routeCount}`,`capabilities: ${manifest.capabilities.join(', ')}`,
|
|
45
|
+
`extensions: ${Object.keys(manifest.extensions).join(', ')||'none'}`,`recipes: ${manifest.recipes.map(recipe=>recipe.id).join(', ')||'none'}`,
|
|
46
|
+
`external: env ${manifest.external.env.join(', ')||'-'}; secrets ${manifest.external.secrets.join(', ')||'-'}; proxy ${manifest.external.egress.proxy.join(', ')||'-'}; signals ${manifest.external.egress.signals.join(', ')||'-'}`,
|
|
47
|
+
`functions: ${manifest.functions.map(item=>`${item.source}#${item.export}`).join(', ')||'none'}`,`middleware: ${manifest.middleware.map(item=>`${item.source}#${item.export}`).join(', ')||'none'}`,
|
|
48
|
+
`targets: ${Object.entries(manifest.targets).map(([target,support])=>`${target} ${support.compatible?'supported':`${support.issues} issue${support.issues===1?'':'s'}`}`).join('; ')}`,'Use --json for the full manifest.'];
|
|
49
|
+
print(lines.join('\n')+'\n');return 0;
|
|
50
|
+
}
|
|
51
|
+
const targets=targetsOf(options.target);
|
|
52
|
+
if(route===undefined){
|
|
53
|
+
const report=await explainProject(options.project,base);
|
|
54
|
+
if(options.json){print(report);return 0;}
|
|
55
|
+
print(table([['route','methods','handler','state','mw','policies','cache','targets'],...report.routes.map(item=>summary(item,targets).split('\t'))]));return 0;
|
|
56
|
+
}
|
|
57
|
+
if(!route.startsWith('/'))throw new ConfigError('Provide an absolute route path such as /docs');
|
|
58
|
+
const explanation=await explainRoute(options.project,route,base);
|
|
59
|
+
if(!explanation.matched){
|
|
60
|
+
if(options.json)print(explanation);else print(`no route selects ${route}${explanation.nearest.length?`; nearest: ${explanation.nearest.join(', ')}`:''}\n`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
print(options.json?explanation:detail(explanation,targets));return 0;
|
|
64
|
+
}
|
package/dist/explain.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {relative} from 'node:path';
|
|
2
|
+
import Ajv from 'ajv/dist/2020.js';
|
|
3
|
+
import {analyzeCompiledCapabilities,capabilityTargets,routeCapabilities} from './capabilities.js';
|
|
4
|
+
|
|
5
|
+
import {effectiveExtensionPolicies} from './extensions.js';
|
|
6
|
+
|
|
7
|
+
import {effectivePolicies} from './policies.js';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
// Effective route behavior read from the compiled IR (config → router →
|
|
14
|
+
// policies), never from request execution. Everything here is safe to print:
|
|
15
|
+
// binding values are replaced by their names, module paths are made
|
|
16
|
+
// project-relative and secrets never appear.
|
|
17
|
+
|
|
18
|
+
const handlerNames=['extension','proxy','conditional','redirect','function','page','static','download','respond'] ;
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
const relativeSource=(root ,source ) =>relative(root,source).split('\\').join('/');
|
|
43
|
+
function origin(url ) {try{return new URL(url).origin;}catch{return url;}}
|
|
44
|
+
|
|
45
|
+
function handlerOf(route ,root ) {
|
|
46
|
+
const kind=handlerNames.find(key=>route[key]);
|
|
47
|
+
switch(kind){
|
|
48
|
+
case 'extension':return {kind,name:route.extension};
|
|
49
|
+
case 'proxy':return {kind,url:route.proxy .url,...(route.proxy .query?{query:route.proxy .query}:{}),...(route.proxy .requestHeaders?{requestHeaders:route.proxy .requestHeaders}:{}),...(route.proxy .responseHeaders?{responseHeaders:route.proxy .responseHeaders}:{})};
|
|
50
|
+
case 'conditional':{
|
|
51
|
+
const branch=(entry )=>entry.redirect?{redirect:{url:entry.redirect.url,status:entry.redirect.status??302}}:{respond:{status:entry.reply?.status??200}};
|
|
52
|
+
return {kind,cases:(route.conditionalRoutes?.cases??[]).map(item=>({match:item.match,...branch(item.route)})),...(route.conditionalRoutes?.fallback?{fallback:branch(route.conditionalRoutes.fallback)}:{})};
|
|
53
|
+
}
|
|
54
|
+
case 'redirect':return {kind,url:route.redirect .url,status:route.redirect .status??302,...(route.redirect .query?{query:route.redirect .query}:{})};
|
|
55
|
+
case 'function':return {kind,source:relativeSource(root,route.function .source),export:route.function .export,...(route.function .args?{args:route.function .args}:{}),sandbox:route.sandbox===true,...(route.sandboxReason?{sandboxReason:route.sandboxReason}:{})};
|
|
56
|
+
case 'page':return {kind,file:route.page .file,...(route.page .contentType?{contentType:route.page .contentType}:{})};
|
|
57
|
+
case 'static':return {kind,directory:route.static .directory,...(route.static .index?{index:route.static .index}:{})};
|
|
58
|
+
case 'download':return {kind,file:route.download .file,...(route.download .filename?{filename:route.download .filename}:{}),...(route.download .contentType?{contentType:route.download .contentType}:{})};
|
|
59
|
+
case 'respond':return {kind,status:route.reply?.status??route.respond .status??200};
|
|
60
|
+
default:return {kind:'none'};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function cacheOf(route ,chain ,extensionPolicies ) {
|
|
64
|
+
const forced=route.extension?'extension mount':extensionPolicies.length?'extension-protected route':route.proxy?'proxy':route.match||route.conditional?'conditional routing':undefined;
|
|
65
|
+
if(forced)return {outcome:'no-store',cacheControl:'no-store',forcedNoStore:true,reason:`The runtime replaces every cache header on this ${forced} with no-store`};
|
|
66
|
+
const policy=chain?.describe.cache;
|
|
67
|
+
if(policy){const cacheControl=policy.cacheControl;return {outcome:policy.strategy??'policy',...(cacheControl===undefined?{}:{cacheControl}),forcedNoStore:false,reason:policy.target==='delegated'?'policies.cache is delegated to the provider on this target':'policies.cache compiled for this route'};}
|
|
68
|
+
const header=route.responseHeaders.find(([name])=>name.toLowerCase()==='cache-control');
|
|
69
|
+
if(header)return {outcome:'explicit response header',cacheControl:header[1],forcedNoStore:false,reason:'response.headers declares Cache-Control'};
|
|
70
|
+
const asset=route.page?.cacheControl??route.download?.cacheControl??route.static?.cacheControl;
|
|
71
|
+
if(asset)return {outcome:'asset handler',cacheControl:asset,forcedNoStore:false,reason:'the asset declaration sets cacheControl'};
|
|
72
|
+
return {outcome:'none',forcedNoStore:false,reason:'no cache policy or Cache-Control header is declared'};
|
|
73
|
+
}
|
|
74
|
+
function providerOf(name ,requirement ,options ) {
|
|
75
|
+
if(!options.extensions)return undefined;
|
|
76
|
+
const registration=options.extensions.find(entry=>entry.name===name);
|
|
77
|
+
if(!registration)return {registered:false};
|
|
78
|
+
let requirementValid =null;
|
|
79
|
+
if(requirement)try{requirementValid=registration.policySchema?Boolean(new Ajv.default({strict:false,allErrors:false}).compile(registration.policySchema)(requirement)):false;}catch{requirementValid=false;}
|
|
80
|
+
return {registered:true,version:registration.version,targets:[...registration.targets],revisionMatch:options.projectSha256===undefined?false:registration.projectSha256===options.projectSha256,requirementValid};
|
|
81
|
+
}
|
|
82
|
+
function targetsOf(loaded ,route ,options ) {
|
|
83
|
+
const table={exact:new Map([[route.pattern,route]]),byLength:new Map(),mounts:[],modules:[],count:1};
|
|
84
|
+
const result={} ;
|
|
85
|
+
for(const target of capabilityTargets){
|
|
86
|
+
const report=analyzeCompiledCapabilities(loaded.document,table,target,options.extensions);
|
|
87
|
+
const issues=report.issues.filter(issue=>issue.path===route.pattern).map(({capability,support,reason})=>({capability,support,reason}));
|
|
88
|
+
result[target]={compatible:issues.length===0,issues};
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
export function routeState(route ,now ) {return route.enabled===false?'disabled':route.expiresAt&&now>=route.expiresAt?'expired':'active';}
|
|
93
|
+
/** Describe one compiled route. `chain` is the policy chain compiled for it, when the project declares policies. */
|
|
94
|
+
export function explainCompiledRoute(loaded ,route ,chain ,options ={}) {
|
|
95
|
+
const root=loaded.root,declared=loaded.routes[route.pattern];
|
|
96
|
+
const extensionRequirements=effectiveExtensionPolicies(loaded.document,route),extensionNames=Object.keys(extensionRequirements).sort();
|
|
97
|
+
const extensions ={};
|
|
98
|
+
for(const name of extensionNames){const provider=providerOf(name,extensionRequirements[name],options);extensions[name]={requirement:extensionRequirements[name] ,...(provider?{provider}:{})};}
|
|
99
|
+
const env ={};
|
|
100
|
+
for(const [alias,ref]of Object.entries(declared?.env??{}))env[alias]=ref.env?{env:ref.env}:{literal:true};
|
|
101
|
+
const secrets ={};
|
|
102
|
+
for(const [alias,ref]of Object.entries(declared?.secrets??{}))secrets[alias]={secret:ref.secret};
|
|
103
|
+
const inventory=chain?.describe??{};
|
|
104
|
+
const names=[...Object.keys(effectivePolicies(loaded.document,route)),...extensionNames.map(name=>`extensions.${name}`)];
|
|
105
|
+
const handler=handlerOf(route,root);
|
|
106
|
+
if(handler.kind==='extension'){const provider=providerOf(route.extension ,undefined,options);if(provider)handler.provider=provider;}
|
|
107
|
+
const egress ={...(route.proxy?{proxy:origin(route.proxy.url)}:{}),...(route.signals?.length?{signals:[...new Set(route.signals.map(signal=>origin(signal.url)))]}:{})};
|
|
108
|
+
return {
|
|
109
|
+
matched:true,path:route.pattern,...(route.description?{description:route.description}:{}),...(route.generated?{generated:route.generated}:{}),
|
|
110
|
+
state:routeState(route,options.now??Date.now()),enabled:route.enabled!==false,...(route.expires?{expires:route.expires}:{}),
|
|
111
|
+
methods:[...route.methods],conditional:Boolean(route.match||route.conditional),handler,
|
|
112
|
+
middleware:route.middleware.map(item=>({source:relativeSource(root,item.source),export:item.export})),
|
|
113
|
+
inputs:{parameters:route.parameters.map(({name,in:location,required,schema})=>({name,in:location,required,schema})),...(route.request?.body?{body:route.request.body}:{})},
|
|
114
|
+
policies:{names,inventory,extensions},
|
|
115
|
+
cache:cacheOf(route,chain,extensionNames),
|
|
116
|
+
bindings:{env,secrets},egress,
|
|
117
|
+
responseHeaders:route.responseHeaders.map(([name,value])=>[name,value]),
|
|
118
|
+
capabilities:routeCapabilities(route,loaded.document),targets:targetsOf(loaded,route,options),
|
|
119
|
+
note:'Derived from the compiled configuration; request conditions, parameter values and handler execution are not evaluated.',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// Edit distance between a requested path and each pattern, so a typo points
|
|
123
|
+
// at the route the author probably meant.
|
|
124
|
+
function distance(a ,b ) {
|
|
125
|
+
const previous=Array.from({length:b.length+1},(_,i)=>i);
|
|
126
|
+
for(let i=1;i<=a.length;i++){let diagonal=previous[0] ;previous[0]=i;for(let j=1;j<=b.length;j++){const temp=previous[j] ;previous[j]=Math.min(previous[j] +1,previous[j-1] +1,diagonal+(a[i-1]===b[j-1]?0:1));diagonal=temp;}}
|
|
127
|
+
return previous[b.length] ;
|
|
128
|
+
}
|
|
129
|
+
export function nearestRoutes(target ,patterns ,limit=3) {
|
|
130
|
+
return [...patterns].map(pattern=>({pattern,score:distance(target.toLowerCase(),pattern.toLowerCase())})).sort((a,b)=>a.score-b.score||(a.pattern<b.pattern?-1:1)).slice(0,limit).map(item=>item.pattern);
|
|
131
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import Ajv from 'ajv/dist/2020.js';
|
|
2
|
+
import { assert, HttpError } from './errors.js';
|
|
3
|
+
import { loadDocument } from './config.js';
|
|
4
|
+
import { prepareFunctionSnapshot } from './policy.js';
|
|
5
|
+
import { validateHeaderName, validateHeaderValue } from './header-validation.js';
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `root` is the project's resolved absolute directory (the same value
|
|
13
|
+
* `loadDocument()` computes and `functionFile()`/router.ts resolves
|
|
14
|
+
* `function`/`middleware` `source` entries against). It is not the process's
|
|
15
|
+
* `cwd()`: `--project`/`--host-file` are independent paths, a server can be
|
|
16
|
+
* started from any working directory, and the JS API can load a project
|
|
17
|
+
* programmatically with no relationship to `cwd()` at all. An extension that
|
|
18
|
+
* resolves project-relative paths of its own (for example, an alternative
|
|
19
|
+
* middleware source it loads by convention rather than through core's native
|
|
20
|
+
* `middleware:` array) must resolve them against this field, never `cwd()`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
/** Trusted operator code only. YAML declares names/configuration, never modules. */
|
|
48
|
+
/**
|
|
49
|
+
* Content-hashed assets under `<mount><prefix>/` may be cached publicly. The
|
|
50
|
+
* extension owns the hashed filename; the runtime only relaxes its no-store floor
|
|
51
|
+
* for a GET/HEAD 200/304 that carries a strong ETag, sets no cookie and does not
|
|
52
|
+
* vary on credentials. Everything else under the mount stays no-store.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
/** `urlcode init --with <name>` contract: what core hands `@jimhoyd/urlcode-<name>`'s `scaffold` export. Nothing is written by `scaffold`. */
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
/** What the runtime knows about the request when it applies the privacy floor. */
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
const namePattern=/^[a-z][a-z0-9-]{0,63}$/;
|
|
108
|
+
const cacheHeaders=new Set(['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control']);
|
|
109
|
+
const segmentPattern=/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
|
|
110
|
+
export const immutableCacheControl='public, max-age=31536000, immutable';
|
|
111
|
+
/** A normalized absolute path prefix: literal segments only, no dot segments, no trailing slash. */
|
|
112
|
+
function validateAssetPrefix(prefix ,name ) {
|
|
113
|
+
assert(typeof prefix==='string'&&prefix.length>=2&&prefix.length<=256&&prefix.startsWith('/')&&!prefix.endsWith('/'),`Extension ${name} immutableAssets.prefix must be a normalized absolute path`);
|
|
114
|
+
assert(prefix.slice(1).split('/').every(segment=>segmentPattern.test(segment)&&segment!=='.'&&segment!=='..'),`Extension ${name} immutableAssets.prefix must contain literal path segments only`);
|
|
115
|
+
return prefix;
|
|
116
|
+
}
|
|
117
|
+
function frozen (value ) {if(value&&typeof value==='object'){for(const child of Object.values(value))frozen(child);Object.freeze(value);}return value;}
|
|
118
|
+
/** Inspection alone grants nothing; operators must explicitly pin the returned revision. */
|
|
119
|
+
export async function inspectExtensionRevision(project ) {return (await prepareFunctionSnapshot(await loadDocument(project))).projectSha256;}
|
|
120
|
+
export function effectiveExtensionPolicies(document ,route ) {
|
|
121
|
+
const project=document.policies??{},local=route.policies??{};
|
|
122
|
+
const layers=[project.profile?document.profiles?.[project.profile]:undefined,project,local.profile?document.profiles?.[local.profile]:undefined,local];
|
|
123
|
+
const result =Object.create(null) ;
|
|
124
|
+
for(const layer of layers){if(layer?.extensions===false){for(const key of Object.keys(result))delete result[key];continue;}
|
|
125
|
+
for(const [name,value]of Object.entries(layer?.extensions??{})) {if(value===false)delete result[name];else result[name]={...result[name],...value};}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
export function hasExtensionPolicy(document ,route ) {return Object.keys(effectiveExtensionPolicies(document,route)).length>0;}
|
|
130
|
+
/**
|
|
131
|
+
* Whether a route naming these `policies.extensions` names must be treated as
|
|
132
|
+
* confidential (forced no-store, no compression, extension response caps).
|
|
133
|
+
* `names` with no entries is never confidential. Without a `registrations`
|
|
134
|
+
* list to consult (a build path with no operator host loaded), every name is
|
|
135
|
+
* treated as sensitive: the safe default this can only relax, never weaken.
|
|
136
|
+
* A name whose registration is missing, or declares no `cacheSensitive` (or
|
|
137
|
+
* `true`), counts as sensitive; only an explicit `cacheSensitive: false`
|
|
138
|
+
* excuses it, and one sensitive name among several makes the whole route
|
|
139
|
+
* confidential.
|
|
140
|
+
*/
|
|
141
|
+
export function isSensitiveExtensionPolicy(names ,registrations ) {
|
|
142
|
+
if(!names.length)return false;
|
|
143
|
+
if(!registrations)return true;
|
|
144
|
+
const byName=new Map(registrations.map(registration=>[registration.name,registration.cacheSensitive]));
|
|
145
|
+
return names.some(name=>byName.get(name)!==false);
|
|
146
|
+
}
|
|
147
|
+
export function prepareExtensions(document ,routes ,registrations ,context ) {
|
|
148
|
+
assert(registrations===undefined||Array.isArray(registrations)&®istrations.length<=16,'Extensions must be an array of at most 16 operator registrations');
|
|
149
|
+
const provided=new Map (),entries=new Map (),credentialHeaders=new Set ();
|
|
150
|
+
for(const registration of registrations??[]){
|
|
151
|
+
assert(registration&&typeof registration==='object'&&typeof registration.name==='string'&&namePattern.test(registration.name),'Invalid extension registration');
|
|
152
|
+
assert(!provided.has(registration.name),'Duplicate extension provider');
|
|
153
|
+
assert(registration.version==='1'&&typeof registration.activate==='function','Invalid extension version or activation hook');
|
|
154
|
+
assert(Array.isArray(registration.targets)&®istration.targets.every(target=>['node','aws','vercel'].includes(target)),'Extension targets must be node, aws or vercel');
|
|
155
|
+
assert(typeof registration.projectSha256==='string'&&/^[a-f0-9]{64}$/.test(registration.projectSha256),'Extension requires an explicit operator revision pin');
|
|
156
|
+
provided.set(registration.name,registration);
|
|
157
|
+
}
|
|
158
|
+
const declarations=document.extensions??{};
|
|
159
|
+
if(Object.keys(declarations).length){let origin ;try{origin=new URL(context.origin);}catch{throw new Error('Extensions require an explicit operator origin');}assert(['http:','https:'].includes(origin.protocol)&&!origin.username&&!origin.password&&origin.origin===context.origin,'Extensions require an explicit canonical HTTP(S) operator origin');}
|
|
160
|
+
for(const route of Object.values(routes)){
|
|
161
|
+
if(route.extension)assert(Object.hasOwn(declarations,route.extension),'Extension route has no declaration');
|
|
162
|
+
for(const name of Object.keys(effectiveExtensionPolicies(document,route)))assert(Object.hasOwn(declarations,name),'Extension policy has no declaration');
|
|
163
|
+
}
|
|
164
|
+
const preparations =[];
|
|
165
|
+
for(const [name,declaration]of Object.entries(declarations)){
|
|
166
|
+
const registration=provided.get(name);assert(registration,`Missing operator extension: ${name}`);
|
|
167
|
+
assert(registration.projectSha256===context.projectSha256,`Extension revision pin mismatch: ${name}`);
|
|
168
|
+
assert(registration.targets.includes(context.target),`Extension ${name} refuses target ${context.target}`);
|
|
169
|
+
assert(declaration.version===registration.version,`Extension contract version mismatch: ${name}`);
|
|
170
|
+
const config=structuredClone(declaration.config);
|
|
171
|
+
const ajv=new Ajv.default({strict:true,allErrors:false});
|
|
172
|
+
assert(ajv.compile(registration.schema)(config),`Invalid extension configuration: ${name}`);
|
|
173
|
+
const policyValidator=registration.policySchema?ajv.compile(registration.policySchema):undefined;
|
|
174
|
+
const policies=new Map ();
|
|
175
|
+
for(const [path,route]of Object.entries(routes)){const policy=effectiveExtensionPolicies(document,route)[name];if(policy){assert(policyValidator&&policyValidator(policy),`Invalid extension policy: ${name} at ${path}`);policies.set(path,frozen(structuredClone(policy)));}}
|
|
176
|
+
const declaredHeaders=registration.credentialHeaders??[];
|
|
177
|
+
assert(Array.isArray(declaredHeaders)&&declaredHeaders.length<=64,'Invalid extension credential headers');
|
|
178
|
+
for(const header of declaredHeaders){assert(typeof header==='string'&&header.length<=128,'Invalid extension credential header');validateHeaderName(header);credentialHeaders.add(header.toLowerCase());}
|
|
179
|
+
// Session and bearer credentials never cross into application guests.
|
|
180
|
+
credentialHeaders.add('cookie');credentialHeaders.add('authorization');
|
|
181
|
+
const mounts=Object.entries(routes).filter(([,route])=>route.extension===name).map(([path])=>path.endsWith('/*')?path.slice(0,-2):path);
|
|
182
|
+
const assetPrefixes=registration.immutableAssets===undefined?[]:mounts.map(mount=>mount+validateAssetPrefix((registration.immutableAssets ).prefix,name)+'/');
|
|
183
|
+
preparations.push({name,registration,config:frozen(config),policies,mounts,assetPrefixes});
|
|
184
|
+
}
|
|
185
|
+
return {async activate(){
|
|
186
|
+
try{for(const {name,registration,config,policies,mounts,assetPrefixes}of preparations){
|
|
187
|
+
const instance=await registration.activate(config,frozen({...context,mounts}));
|
|
188
|
+
if(instance&&typeof instance==='object')entries.set(name,{instance,policies,assetPrefixes});
|
|
189
|
+
assert(instance&&typeof instance.handle==='function'&&(!policies.size||typeof instance.authorize==='function'||typeof instance.middleware==='function'),`Extension ${name} lacks a required handler, authorization hook or middleware hook`);
|
|
190
|
+
entries.set(name,{instance,policies,assetPrefixes});
|
|
191
|
+
}}catch(error){for(const entry of [...entries.values()].reverse())try{await entry.instance.close?.();}catch{/* Keep the activation failure. */}throw error;}
|
|
192
|
+
return {entries,credentialHeaders:[...credentialHeaders],async close(){for(const entry of [...entries.values()].reverse())try{await entry.instance.close?.();}catch{/* Operators own extension lifecycle diagnostics. */}}};
|
|
193
|
+
}};
|
|
194
|
+
}
|
|
195
|
+
const header=(headers ,name ) =>headers.filter(([key])=>key.toLowerCase()===name).map(([,value])=>value);
|
|
196
|
+
const maxAge=(value ) =>{const match=/(?:^|[\s,])max-age\s*=\s*"?(\d+)/i.exec(value);return match?Number(match[1]):undefined;};
|
|
197
|
+
/**
|
|
198
|
+
* The runtime decides; the extension cannot opt in from a response alone. The
|
|
199
|
+
* declared prefix, the GET/HEAD method, a 200/304 status, one strong ETag, no
|
|
200
|
+
* Set-Cookie and no Vary on Cookie/Authorization are all required. A stricter
|
|
201
|
+
* Cache-Control the extension set (no-store, no-cache, private or a shorter
|
|
202
|
+
* max-age) is preserved.
|
|
203
|
+
*/
|
|
204
|
+
export function immutableAssetResponse(result ,asset ) {
|
|
205
|
+
if(!asset||!asset.prefixes.some(prefix=>asset.path.startsWith(prefix)))return false;
|
|
206
|
+
if(asset.method!=='GET'&&asset.method!=='HEAD')return false;
|
|
207
|
+
if(result.status!==200&&result.status!==304)return false;
|
|
208
|
+
const etag=header(result.headers,'etag');
|
|
209
|
+
if(etag.length!==1||!/^"[!#-~]+"$/.test(etag[0] ))return false;
|
|
210
|
+
if(header(result.headers,'set-cookie').length)return false;
|
|
211
|
+
if(header(result.headers,'vary').some(value=>value.split(',').some(field=>['cookie','authorization','*'].includes(field.trim().toLowerCase()))))return false;
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
function assetCacheControl(result ) {
|
|
215
|
+
const declared=header(result.headers,'cache-control');
|
|
216
|
+
if(declared.length!==1)return immutableCacheControl;
|
|
217
|
+
const value=declared[0] ,lower=value.toLowerCase();
|
|
218
|
+
if(/(?:^|[\s,])(?:no-store|no-cache|private)(?:$|[\s,=])/.test(lower))return value;
|
|
219
|
+
const age=maxAge(value);
|
|
220
|
+
return age!==undefined&&age<31536000?value:immutableCacheControl;
|
|
221
|
+
}
|
|
222
|
+
/** Mandatory privacy floor after trusted response hooks, with bounded output. */
|
|
223
|
+
export function extensionResponse(result ,asset ) {
|
|
224
|
+
assert(result&&Number.isInteger(result.status)&&result.status>=200&&result.status<=599&&Array.isArray(result.headers),'Invalid extension response');
|
|
225
|
+
if(result.body&&Buffer.byteLength(result.body)>1048576)throw new HttpError(502,'Extension response exceeds limit');
|
|
226
|
+
let bytes=0;assert(result.headers.length<=256,'Extension response has too many headers');
|
|
227
|
+
for(const [name,value]of result.headers){validateHeaderName(name);validateHeaderValue(name,value);bytes+=Buffer.byteLength(name)+Buffer.byteLength(value)+4;}
|
|
228
|
+
if(bytes>16384)throw new HttpError(502,'Extension response headers exceed limit');
|
|
229
|
+
const cacheControl=immutableAssetResponse(result,asset)?assetCacheControl(result):'no-store';
|
|
230
|
+
return {...result,headers:[...result.headers.filter(([name])=>!cacheHeaders.has(name.toLowerCase())),['cache-control',cacheControl]]};
|
|
231
|
+
}
|
package/dist/function-sources.js
CHANGED
|
@@ -17,10 +17,43 @@ import { assert } from './errors.js';
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
/** Snapshot budgets: what one set of guest modules may cost. Deliberate bounds
|
|
21
|
+
* of the sandbox contract (docs/FUNCTION-SECURITY.md), not tuning knobs. */
|
|
22
|
+
export const MODULE_LIMIT = 128;
|
|
23
|
+
export const MODULE_BYTE_LIMIT = 1048576;
|
|
24
|
+
export const TOTAL_BYTE_LIMIT = 4194304;
|
|
25
|
+
|
|
20
26
|
export function routeFunctions (route ) { return [...(route.middleware || []), ...(route.function ? [route.function] : [])]; }
|
|
21
27
|
|
|
28
|
+
// Trusted (unsandboxed) routes are never bundled into a QuickJS module snapshot:
|
|
29
|
+
// they run through Node's own module resolution, with no restriction on bare
|
|
30
|
+
// specifiers, dynamic import or dependency count/size. For revision pinning
|
|
31
|
+
// (docs/SPIKE-DEFAULT-TRUST-MODEL.md, "trust-declaration integrity"), the
|
|
32
|
+
// project hash must still change when a trusted function/middleware's own
|
|
33
|
+
// source changes, so an operator's env/secret grant is invalidated the moment
|
|
34
|
+
// the code that could use it is edited. This only hashes each entry file's own
|
|
35
|
+
// bytes, not its transitive import graph the way the sandboxed collector does:
|
|
36
|
+
// a change to a helper module a trusted entry imports, without touching the
|
|
37
|
+
// entry file itself, does not by itself change the project hash. Documented as
|
|
38
|
+
// a known limitation in docs/FUNCTION-SECURITY.md.
|
|
39
|
+
export async function collectTrustedSources(routes , root ) {
|
|
40
|
+
const sources = Object.create(null);
|
|
41
|
+
for (const definition of routes.flatMap(routeFunctions)) {
|
|
42
|
+
const name = '/' + relative(root, definition.source).split(sep).join('/');
|
|
43
|
+
if (Object.hasOwn(sources, name)) continue;
|
|
44
|
+
sources[name] = await readFile(definition.source, 'utf8');
|
|
45
|
+
}
|
|
46
|
+
return sources;
|
|
47
|
+
}
|
|
48
|
+
|
|
22
49
|
// Parse and snapshot source without ever importing project code into Node.
|
|
23
|
-
|
|
50
|
+
// Route-shaped callers go through `collectFunctionSources` below; a
|
|
51
|
+
// route-independent caller (the public sandbox primitive, src/sandbox.ts)
|
|
52
|
+
// calls this directly with its own explicit `{source, export}` list, so the
|
|
53
|
+
// module-allowlist walk and per-module/total byte budgets apply identically
|
|
54
|
+
// either way — there is exactly one collector, not a route-shaped one and a
|
|
55
|
+
// second generic one.
|
|
56
|
+
export async function collectSourcesFor(definitions , root ) {
|
|
24
57
|
await init;
|
|
25
58
|
const sources = Object.create(null), dependencies = Object.create(null);
|
|
26
59
|
const entries = [], names = new Map (), seenEntries = new Set ();
|
|
@@ -28,11 +61,17 @@ export async function collectFunctionSources(routes , root
|
|
|
28
61
|
async function collect(file ) {
|
|
29
62
|
const name = '/' + relative(root,file).split(sep).join('/');
|
|
30
63
|
if (Object.hasOwn(sources,name)) return name;
|
|
31
|
-
|
|
64
|
+
// Name the module that crossed the budget and the counts against their
|
|
65
|
+
// limits: the bare limit alone reads as a sandbox fault, when it usually
|
|
66
|
+
// means the project has outgrown what one snapshot holds. Prerendering
|
|
67
|
+
// splits a large site across snapshots rather than inheriting the bound as
|
|
68
|
+
// a page ceiling. See docs/PRERENDER.md#function-budgets.
|
|
69
|
+
assert(Object.keys(sources).length < MODULE_LIMIT, `Function module limit exceeded: ${name} is module ${Object.keys(sources).length + 1}, over the limit of ${MODULE_LIMIT} modules per snapshot`);
|
|
32
70
|
const info = await stat(file);
|
|
33
|
-
assert(info.size <=
|
|
71
|
+
assert(info.size <= MODULE_BYTE_LIMIT, `Function source limit exceeded: ${name} is ${info.size} bytes, over the per-module limit of ${MODULE_BYTE_LIMIT} bytes`);
|
|
72
|
+
assert(bytes + info.size <= TOTAL_BYTE_LIMIT, `Function source limit exceeded: ${name} (${info.size} bytes) brings the snapshot to ${bytes + info.size} bytes, over the total limit of ${TOTAL_BYTE_LIMIT} bytes`);
|
|
34
73
|
const code = await readFile(file,'utf8'); bytes += Buffer.byteLength(code);
|
|
35
|
-
assert(bytes <=
|
|
74
|
+
assert(bytes <= TOTAL_BYTE_LIMIT, `Function source limit exceeded: ${name} brings the snapshot to ${bytes} bytes, over the total limit of ${TOTAL_BYTE_LIMIT} bytes`);
|
|
36
75
|
sources[name] = code; const deps = dependencies[name] = [];
|
|
37
76
|
const [imports] = parse(code);
|
|
38
77
|
for (const item of imports) {
|
|
@@ -44,7 +83,7 @@ export async function collectFunctionSources(routes , root
|
|
|
44
83
|
}
|
|
45
84
|
return name;
|
|
46
85
|
}
|
|
47
|
-
for (const definition of
|
|
86
|
+
for (const definition of definitions) {
|
|
48
87
|
const name = await collect(definition.source);
|
|
49
88
|
names.set(definition.source,name);
|
|
50
89
|
const key = name + ":" + definition.export;
|
|
@@ -52,3 +91,8 @@ export async function collectFunctionSources(routes , root
|
|
|
52
91
|
}
|
|
53
92
|
return { sources,dependencies,entries,names };
|
|
54
93
|
}
|
|
94
|
+
/** Route-shaped convenience over `collectSourcesFor`: every existing caller (policy.ts,
|
|
95
|
+
* prerender.ts, typescript-authoring.ts) keeps working unchanged from `FunctionRoute[]`. */
|
|
96
|
+
export async function collectFunctionSources(routes , root ) {
|
|
97
|
+
return collectSourcesFor(routes.flatMap(routeFunctions), root);
|
|
98
|
+
}
|