@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/observability.js
CHANGED
|
@@ -12,16 +12,13 @@ import { assert } from './errors.js';
|
|
|
12
12
|
// URL, query string, header, body, client address, User-Agent string, binding
|
|
13
13
|
// or secret; `route` is always the configured pattern.
|
|
14
14
|
export const events = Object.freeze({
|
|
15
|
+
signal: Object.freeze(['event','outcome','count']),
|
|
15
16
|
request: Object.freeze(['event', 'requestId', 'status', 'durationMs', 'method', 'route']),
|
|
16
17
|
reload: Object.freeze(['event', 'status', 'version', 'routes']),
|
|
17
18
|
watch: Object.freeze(['event', 'status']),
|
|
18
19
|
function_worker: Object.freeze(['event', 'status', 'slot', 'attempt', 'delayMs']),
|
|
19
|
-
link_store_worker: Object.freeze(['event', 'status', 'readOnly', 'attempt', 'delayMs']),
|
|
20
|
-
link_request: Object.freeze(['event', 'requestId', 'collection', 'route', 'code', 'method', 'status', 'outcome', 'durationMs']),
|
|
21
|
-
link_observer: Object.freeze(['event', 'status', 'reason', 'dropped', 'queued', 'delivered', 'failed', 'timedOut', 'closed']),
|
|
22
20
|
logs_dropped: Object.freeze(['event', 'count']),
|
|
23
21
|
observer: Object.freeze(['event', 'status', 'name']),
|
|
24
|
-
management_request: Object.freeze(['event', 'timestamp', 'requestId', 'collection', 'action', 'authenticated', 'principal', 'status', 'outcome', 'durationMs']),
|
|
25
22
|
throttle: Object.freeze(['event', 'route', 'outcome', 'remaining']),
|
|
26
23
|
agents: Object.freeze(['event', 'route', 'list', 'outcome']),
|
|
27
24
|
cache: Object.freeze(['event', 'route', 'outcome']),
|
|
@@ -46,9 +43,8 @@ export const events = Object.freeze({
|
|
|
46
43
|
|
|
47
44
|
|
|
48
45
|
|
|
49
|
-
|
|
50
46
|
|
|
51
|
-
|
|
47
|
+
|
|
52
48
|
|
|
53
49
|
|
|
54
50
|
|
|
@@ -83,14 +79,13 @@ export function validateObservers(observers = []) {
|
|
|
83
79
|
return observers ; // every entry was just checked
|
|
84
80
|
}
|
|
85
81
|
|
|
86
|
-
export const SNAPSHOT_VERSION =
|
|
82
|
+
export const SNAPSHOT_VERSION = 2;
|
|
87
83
|
const statusClasses = ['2xx', '3xx', '4xx', '5xx'];
|
|
88
84
|
const MAX_ROUTES = 10000;
|
|
89
85
|
const outcomes = {
|
|
90
86
|
throttle: ['allowed', 'exceeded'],
|
|
91
87
|
agents: ['denied', 'reported'],
|
|
92
88
|
cache: ['hit', 'stale', 'miss', 'store'],
|
|
93
|
-
link_request: ['completed', 'aborted', 'missing', 'disabled', 'expired', 'invalid_code', 'invalid_record', 'unavailable'],
|
|
94
89
|
};
|
|
95
90
|
const zeroed = (keys ) => Object.fromEntries(keys.map(key => [key, 0]));
|
|
96
91
|
|
|
@@ -106,10 +101,8 @@ export function createMetrics() {
|
|
|
106
101
|
const reloads = { ok: 0, rejected: 0 };
|
|
107
102
|
const watch = { failed: 0 };
|
|
108
103
|
const functionWorkers = { started: 0, restarts: 0 };
|
|
109
|
-
const linkStoreWorkers = { started: 0, restarts: 0 };
|
|
110
104
|
const policies = { throttle: zeroed(outcomes.throttle), agents: zeroed(outcomes.agents), cache: zeroed(outcomes.cache) };
|
|
111
|
-
const
|
|
112
|
-
const linkObserver = { failed: 0, dropped: 0 };
|
|
105
|
+
const signals = zeroed(['accepted','delivered','failed','dropped']);
|
|
113
106
|
let logsDropped = 0, observerErrors = 0;
|
|
114
107
|
const count = (table , key ) => { if (typeof key === 'string' && Object.hasOwn(table, key)) table[key] ++; };
|
|
115
108
|
function countRequest(target , status , route ) {
|
|
@@ -129,14 +122,12 @@ export function createMetrics() {
|
|
|
129
122
|
if (!event || typeof event !== 'object') return;
|
|
130
123
|
const record = event ; // any object is read as a record; unknown keys are ignored
|
|
131
124
|
switch (record.event) {
|
|
125
|
+
case 'signal': if(typeof record.outcome==='string'&&Object.hasOwn(signals,record.outcome)&&Number.isSafeInteger(record.count)&&Number(record.count)>0)signals[record.outcome] +=Number(record.count);break;
|
|
132
126
|
case 'request': countRequest(context.probe ? health : requests, record.status, context.probe ? null : (record.route ?? context.route)); break;
|
|
133
127
|
case 'reload': count(reloads, record.status); break;
|
|
134
128
|
case 'watch': if (record.status === 'failed') watch.failed++; break;
|
|
135
129
|
case 'function_worker': if (record.status === 'restarting') functionWorkers.restarts++; else if (record.status === 'started') functionWorkers.started++; break;
|
|
136
|
-
case 'link_store_worker': if (record.status === 'restarting') linkStoreWorkers.restarts++; else if (record.status === 'started') linkStoreWorkers.started++; break;
|
|
137
130
|
case 'throttle': case 'agents': case 'cache': count(policies[record.event], record.outcome); break;
|
|
138
|
-
case 'link_request': count(linkRequests, record.outcome); break;
|
|
139
|
-
case 'link_observer': if (record.status === 'failed') linkObserver.failed++; else if (record.status === 'dropped' || record.status === 'closed') linkObserver.dropped = Math.max(linkObserver.dropped, Number(record.dropped) || 0); break;
|
|
140
131
|
case 'logs_dropped': logsDropped += Number(record.count) || 0; break;
|
|
141
132
|
case 'observer': if (record.status === 'failed') observerErrors++; break;
|
|
142
133
|
default: break;
|
|
@@ -156,10 +147,8 @@ export function createMetrics() {
|
|
|
156
147
|
reloads: { ...reloads },
|
|
157
148
|
watch: { ...watch },
|
|
158
149
|
functionWorkers: { ...functionWorkers, healthySlots: 0, slots: 0 },
|
|
159
|
-
linkStoreWorkers: { ...linkStoreWorkers },
|
|
160
150
|
policies: { throttle: { ...policies.throttle }, agents: { ...policies.agents }, cache: { ...policies.cache } },
|
|
161
|
-
|
|
162
|
-
linkObserver: { ...linkObserver },
|
|
151
|
+
signals: {...signals},
|
|
163
152
|
logsDropped,
|
|
164
153
|
observers: { errors: observerErrors },
|
|
165
154
|
...extra,
|
|
@@ -226,13 +215,10 @@ export function renderPrometheus(snapshot ) {
|
|
|
226
215
|
metric('function_worker_restarts_total', 'counter', 'Function worker replacements scheduled.', [[{}, snapshot.functionWorkers?.restarts]]);
|
|
227
216
|
metric('function_worker_healthy_slots', 'gauge', 'Function worker slots ready to serve.', [[{}, snapshot.functionWorkers?.healthySlots]]);
|
|
228
217
|
metric('function_worker_slots', 'gauge', 'Function worker slots configured.', [[{}, snapshot.functionWorkers?.slots]]);
|
|
229
|
-
metric('link_store_worker_restarts_total', 'counter', 'Link-store worker replacements scheduled.', [[{}, snapshot.linkStoreWorkers?.restarts]]);
|
|
230
218
|
metric('throttle_total', 'counter', 'Throttle policy decisions.', byKey(snapshot.policies?.throttle, 'outcome'));
|
|
231
219
|
metric('agents_total', 'counter', 'Agents policy decisions.', byKey(snapshot.policies?.agents, 'outcome'));
|
|
232
220
|
metric('cache_total', 'counter', 'Cache policy outcomes.', byKey(snapshot.policies?.cache, 'outcome'));
|
|
233
|
-
metric('
|
|
234
|
-
metric('link_observer_failures_total', 'counter', 'Link event collector failures.', [[{}, snapshot.linkObserver?.failed]]);
|
|
235
|
-
metric('link_observer_dropped_total', 'counter', 'Link events dropped under overload.', [[{}, snapshot.linkObserver?.dropped]]);
|
|
221
|
+
metric('signals_total','counter','Best-effort webhook outcomes.',Object.entries(snapshot.signals||{}).map(([outcome,value])=>[{outcome},value]));
|
|
236
222
|
metric('logs_dropped_total', 'counter', 'Log records the JSON logger shed.', [[{}, snapshot.logsDropped]]);
|
|
237
223
|
metric('observer_errors_total', 'counter', 'Observer hooks that threw or rejected.', [[{}, snapshot.observers?.errors]]);
|
|
238
224
|
metric('uptime_seconds', 'gauge', 'Seconds since the process started serving.', [[{}, snapshot.uptimeSeconds]]);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative, sep, extname } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { assert } from './errors.js';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
/** Explicitly loaded operator code. Never discovered in application directories. */
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export async function loadOperatorHost(file , project ) {
|
|
14
|
+
if (file === undefined) return {};
|
|
15
|
+
assert(isAbsolute(file) && ['.mjs', '.js'].includes(extname(file)), 'Host file must be an absolute ES module path (.mjs or .js)');
|
|
16
|
+
const root = await realpath(project), path = await realpath(file), rel = relative(root, path);
|
|
17
|
+
assert(isAbsolute(rel) || rel === '..' || rel.startsWith('..' + sep), 'Host file must be outside the application project');
|
|
18
|
+
const info = await stat(path);
|
|
19
|
+
assert(info.isFile() && info.size <= 1048576, 'Host file must be a regular file of at most 1 MiB');
|
|
20
|
+
const module = await import(pathToFileURL(path).href) ;
|
|
21
|
+
const host = module.default;
|
|
22
|
+
assert(host !== null && typeof host === 'object' && !Array.isArray(host), 'Host file must default-export an operator configuration object');
|
|
23
|
+
assert(Object.keys(host).every(key => ['extensions', 'plugins', 'close'].includes(key)), 'Unknown operator host setting');
|
|
24
|
+
const result = host ;
|
|
25
|
+
assert(result.extensions === undefined || Array.isArray(result.extensions), 'Host extensions must be an array');
|
|
26
|
+
assert(result.plugins === undefined || Array.isArray(result.plugins), 'Host plugins must be an array');
|
|
27
|
+
assert(result.close === undefined || typeof result.close === 'function', 'Host close must be a function');
|
|
28
|
+
return result;
|
|
29
|
+
}
|
package/dist/plugins.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateHeaderName } from './header-validation.js';
|
|
1
2
|
import { assert, ConfigError } from './errors.js';
|
|
2
3
|
import { targets as knownTargets } from './policies.js';
|
|
3
4
|
|
|
@@ -21,6 +22,8 @@ import { targets as knownTargets } from './policies.js';
|
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
|
|
26
|
+
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
|
|
@@ -41,6 +44,15 @@ export function validatePlugins(plugins = [], target = 'node')
|
|
|
41
44
|
assert(typeof plugin.version === 'string' && plugin.version.length <= 64, `Plugin "${plugin.name}" needs a version string`);
|
|
42
45
|
assert(Array.isArray(plugin.targets) && plugin.targets.every(t => (knownTargets ).includes(t)), `Plugin "${plugin.name}" must list its supported targets`);
|
|
43
46
|
if (!(plugin.targets ).includes(target)) throw new ConfigError(`Plugin "${plugin.name}" does not support the ${target} target`);
|
|
47
|
+
if(plugin.credentialHeaders!==undefined){
|
|
48
|
+
assert(Array.isArray(plugin.credentialHeaders)&&plugin.credentialHeaders.length<=64,'Plugin credentialHeaders must contain at most 64 header names');
|
|
49
|
+
const headers=new Set ();
|
|
50
|
+
for(const name of plugin.credentialHeaders){
|
|
51
|
+
assert(typeof name==='string'&&name.length<=128,'Invalid plugin credential header name');
|
|
52
|
+
validateHeaderName(name);
|
|
53
|
+
assert(!headers.has(name.toLowerCase()),'Duplicate plugin credential header name');headers.add(name.toLowerCase());
|
|
54
|
+
}
|
|
55
|
+
}
|
|
44
56
|
for (const hook of hookNames) assert(plugin[hook] === undefined || typeof plugin[hook] === 'function', `Plugin "${plugin.name}" hook ${hook} must be a function`);
|
|
45
57
|
assert(hookNames.some(hook => plugin[hook]), `Plugin "${plugin.name}" declares no hooks`);
|
|
46
58
|
}
|
package/dist/policies/agents.js
CHANGED
|
@@ -35,8 +35,8 @@ export function targets() { return { node
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
export const bundledLists = Object.freeze(Object.keys(bundled));
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
const MAX_PATTERN_BYTES = 256;
|
|
39
|
+
const MAX_REPEAT = 64;
|
|
40
40
|
|
|
41
41
|
// The pattern subset: anchors, literals, `.`, escapes, character classes,
|
|
42
42
|
// groups, alternation, and quantifiers on a single atom only. Nothing here
|
package/dist/policies/cache.js
CHANGED
|
@@ -202,9 +202,14 @@ function revalidate(state , req , result
|
|
|
202
202
|
if (result.status !== 200 || (req.method !== 'GET' && req.method !== 'HEAD')) return result;
|
|
203
203
|
let headers = result.headers, etag = header(headers, 'etag');
|
|
204
204
|
if (!etag) {
|
|
205
|
-
// A strong validator over the representation. A
|
|
206
|
-
//
|
|
207
|
-
//
|
|
205
|
+
// A strong validator over the representation. A trusted function's HEAD
|
|
206
|
+
// answer is now measured the same way its GET answer is (the body is
|
|
207
|
+
// read to determine its real length even though HEAD never puts it on
|
|
208
|
+
// the wire — see #139), so it has real bytes to hash here and gets a
|
|
209
|
+
// validator that agrees with GET's. Only a HEAD answer that genuinely
|
|
210
|
+
// has no body to measure (e.g. one carrying a native reply with no
|
|
211
|
+
// body) skips computing one, rather than hashing zero bytes and
|
|
212
|
+
// asserting a validator for a representation it never measured.
|
|
208
213
|
const body = bodyOf(result);
|
|
209
214
|
if (req.method === 'HEAD' && !body.length) return result;
|
|
210
215
|
etag = '"' + createHash('sha256').update(body).digest('hex') + '"'; headers = [...headers, ['etag', etag]];
|
|
@@ -26,7 +26,8 @@ export const phases = ['response'];
|
|
|
26
26
|
|
|
27
27
|
const DYNAMIC_LIMIT = 1048576; // bytes; above this a body is never compressed on the request path
|
|
28
28
|
const PRECOMPRESSED_BUDGET = 64 * 1024 * 1024; // aggregate bytes of asset variants per runtime
|
|
29
|
-
|
|
29
|
+
/** The media types compressed when `types` is not declared; exported so the deployment verifier reads the same table. */
|
|
30
|
+
export const DEFAULT_TYPES = ['text/*','application/json','application/javascript','application/xml','image/svg+xml','application/manifest+json','application/ld+json'];
|
|
30
31
|
const SUFFIX = { br:'-br', gzip:'-gz', deflate:'-df', zstd:'-zs' };
|
|
31
32
|
const BODYLESS = new Set([204,205,304]);
|
|
32
33
|
|
|
Binary file
|
package/dist/policies.js
CHANGED
|
@@ -72,7 +72,7 @@ export function effectivePolicies(document , routeConfig
|
|
|
72
72
|
layers.push(route);
|
|
73
73
|
const effective = {};
|
|
74
74
|
for (const layer of layers) for (const [key, value] of Object.entries(layer)) {
|
|
75
|
-
if (key === 'profile') continue;
|
|
75
|
+
if (key === 'profile' || key === 'extensions') continue;
|
|
76
76
|
if (value === false) { delete effective[key]; continue; }
|
|
77
77
|
effective[key] = { ...(effective[key] || {}), ...value };
|
|
78
78
|
}
|
package/dist/policy.js
CHANGED
|
@@ -1,28 +1,52 @@
|
|
|
1
|
+
import { egressUrl } from './egress.js';
|
|
1
2
|
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
2
3
|
import { relative, isAbsolute, sep } from 'node:path';
|
|
3
4
|
import { createHash } from 'node:crypto';
|
|
4
5
|
import { functionFile } from './config.js';
|
|
5
|
-
import { collectFunctionSources, routeFunctions } from './function-sources.js';
|
|
6
|
-
|
|
6
|
+
import { collectFunctionSources, collectTrustedSources, routeFunctions } from './function-sources.js';
|
|
7
|
+
|
|
7
8
|
import { assert } from './errors.js';
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
|
|
12
|
+
|
|
10
13
|
/** The operator's binding grants: which env and secret names each route may read, pinned to a project hash. */
|
|
11
|
-
|
|
14
|
+
|
|
12
15
|
/** The function snapshot plus the hash operator grants are pinned to. */
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
export async function prepareFunctionSnapshot(loaded ) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
19
|
+
// Only `sandbox: true` routes are bundled into the QuickJS module snapshot
|
|
20
|
+
// FunctionPool loads (docs/SPIKE-DEFAULT-TRUST-MODEL.md): a trusted route's
|
|
21
|
+
// module never needs to satisfy the sandbox's relative-static-import-only
|
|
22
|
+
// rule or its per-module/total byte budgets, since it runs through Node's
|
|
23
|
+
// own module resolution, not the WASM guest.
|
|
24
|
+
const sandboxed = [];
|
|
25
|
+
const trusted = [];
|
|
26
|
+
const resolveOne = async (definition ) =>
|
|
27
|
+
({source:await functionFile(loaded.root,definition.source),export:definition.export || 'default'});
|
|
28
|
+
for (const [pattern,route] of Object.entries(loaded.routes)) {
|
|
29
|
+
if (!routeFunctions(route).length) continue;
|
|
30
|
+
const middleware = await Promise.all((route.middleware || []).map(resolveOne));
|
|
31
|
+
const fn = route.function ? await resolveOne(route.function) : undefined;
|
|
32
|
+
if (route.sandbox) { for (const definition of [...middleware, ...(fn ? [fn] : [])]) sandboxed.push({pattern,function:definition}); }
|
|
33
|
+
else trusted.push({middleware, function: fn});
|
|
34
|
+
}
|
|
35
|
+
const collected = await collectFunctionSources(sandboxed,loaded.root);
|
|
36
|
+
const trustedSources = await collectTrustedSources(trusted,loaded.root);
|
|
37
|
+
// The hash operator grants pin to still covers trusted routes' own source, so
|
|
38
|
+
// an env/secret grant is invalidated when the trusted code that could use it
|
|
39
|
+
// changes, even though that code never enters collectFunctionSources's
|
|
40
|
+
// sandbox-shaped snapshot (see collectTrustedSources for what this does not
|
|
41
|
+
// catch: changes to a helper module a trusted entry imports but does not
|
|
42
|
+
// itself change).
|
|
43
|
+
const sources = Object.fromEntries(Object.entries({...collected.sources,...trustedSources}).sort(([a],[b])=>a < b ? -1 : a > b ? 1 : 0));
|
|
22
44
|
// Generated site routes carry no bindings and depend on the origin, so they
|
|
23
|
-
// stay out of the hash that operator grants are pinned to.
|
|
45
|
+
// stay out of the hash that operator grants are pinned to. Declared site and
|
|
46
|
+
// inherited policy/profile behavior are included: changing a pre-egress
|
|
47
|
+
// restriction must require a fresh operator review even if routes are unchanged.
|
|
24
48
|
const declared = Object.fromEntries(Object.entries(loaded.routes).filter(([,route])=>!route.generated));
|
|
25
|
-
const snapshot = { ...collected, projectSha256: createHash('sha256').update(JSON.stringify({routes:declared,...(loaded.document.
|
|
49
|
+
const snapshot = { ...collected, projectSha256: createHash('sha256').update(JSON.stringify({...(loaded.document.extensions?{extensions:loaded.document.extensions}:{}),routes:declared,...(loaded.document.policies?{policies:loaded.document.policies}:{}),...(loaded.document.profiles?{profiles:loaded.document.profiles}:{}),...(loaded.document.site?{site:loaded.document.site}:{}),sources})).digest('hex') };
|
|
26
50
|
return snapshot;
|
|
27
51
|
}
|
|
28
52
|
export function validatePolicy(value ) {
|
|
@@ -33,8 +57,13 @@ export function validatePolicy(value ) {
|
|
|
33
57
|
assert(routes && typeof routes === 'object' && !Array.isArray(routes), 'Policy requires route grants');
|
|
34
58
|
for (const [path,grant] of Object.entries(routes)) {
|
|
35
59
|
assert(path.startsWith('/') && grant && typeof grant === 'object' && !Array.isArray(grant), 'Invalid route grant');
|
|
36
|
-
assert(Object.keys(grant).every(k=>['env','secrets'].includes(k)), 'Unsupported policy capability');
|
|
37
|
-
|
|
60
|
+
assert(Object.keys(grant).every(k=>['env','secrets','egress'].includes(k)), 'Unsupported policy capability');
|
|
61
|
+
const record=grant ;
|
|
62
|
+
if(record.egress!==undefined) {
|
|
63
|
+
assert(record.egress&&typeof record.egress==='object'&&!Array.isArray(record.egress)&&Object.keys(record.egress).every(key=>['proxy','signals'].includes(key)),'Invalid egress grant');
|
|
64
|
+
for(const origins of Object.values(record.egress)){assert(Array.isArray(origins)&&origins.length<=64,'Invalid egress origins');for(const origin of origins){const url=egressUrl(origin);assert(url.origin===origin,'Egress grants must be exact HTTPS origins');}}
|
|
65
|
+
}
|
|
66
|
+
for (const list of [record.env,record.secrets].filter(value=>value!==undefined)) assert(Array.isArray(list) && list.length <= 64 && list.every(n=>typeof n === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(n)), 'Invalid binding grant');
|
|
38
67
|
}
|
|
39
68
|
return value ; // every field was just checked
|
|
40
69
|
}
|
|
@@ -47,9 +76,21 @@ export async function loadOperatorPolicy(file , project
|
|
|
47
76
|
return validatePolicy(JSON.parse(await readFile(path,'utf8')));
|
|
48
77
|
}
|
|
49
78
|
export function requestedPermissions(loaded , snapshot ) {
|
|
50
|
-
return {version:1,projectSha256:snapshot.projectSha256,routes:Object.fromEntries(Object.entries(loaded.routes).flatMap(([path,route])
|
|
79
|
+
return {version:1,projectSha256:snapshot.projectSha256,routes:Object.fromEntries(Object.entries(loaded.routes).flatMap(([path,route]) => {
|
|
51
80
|
const env = Object.values(route.env || {}).flatMap(ref=>ref.env ? [ref.env] : []);
|
|
52
81
|
const secrets = Object.values(route.secrets || {}).map(ref=>ref.secret);
|
|
53
|
-
|
|
82
|
+
const egress ={...(route.proxy?{proxy:[egressUrl(route.proxy.url).origin]}:{}),...(route.signals?.length?{signals:[...new Set(route.signals.map(signal=>egressUrl(signal.url).origin))]}:{})};
|
|
83
|
+
return env.length || secrets.length || Object.keys(egress).length ? [[path,{env:[...new Set(env)],secrets:[...new Set(secrets)],...(Object.keys(egress).length?{egress}:{})}]] : [];
|
|
54
84
|
}))};
|
|
55
85
|
}
|
|
86
|
+
|
|
87
|
+
/** Called before resources or credentials are activated. Grants never come from YAML. */
|
|
88
|
+
export function authorizeEgress(loaded ,projectSha256 ,policy ) {
|
|
89
|
+
const proxy=new Set (),signals=new Set ();
|
|
90
|
+
for(const [path,route] of Object.entries(loaded.routes))for(const [purpose,urls]of [['proxy',route.proxy?[route.proxy.url]:[]],['signals',route.signals?.map(signal=>signal.url)||[]]] )for(const destination of urls){
|
|
91
|
+
const origin=egressUrl(destination).origin;
|
|
92
|
+
assert(policy?.projectSha256===projectSha256&&policy.routes[path]?.egress?.[purpose]?.includes(origin),'Egress denied by revision-pinned operator policy');
|
|
93
|
+
(purpose==='proxy'?proxy:signals).add(origin);
|
|
94
|
+
}
|
|
95
|
+
assert(proxy.size<=64&&signals.size<=64,'Runtime egress origins exceed limit');return {proxy:[...proxy],signals:[...signals]};
|
|
96
|
+
}
|
package/dist/prerender.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import {mkdir, writeFile} from 'node:fs/promises';
|
|
2
2
|
import {resolve, relative, isAbsolute, join, dirname, sep} from 'node:path';
|
|
3
3
|
import {createRuntime} from './runtime.js';
|
|
4
|
+
import {loadDocument, functionFile} from './config.js';
|
|
5
|
+
import {applySite} from './site.js';
|
|
6
|
+
import {collectFunctionSources, routeFunctions, MODULE_LIMIT, TOTAL_BYTE_LIMIT} from './function-sources.js';
|
|
4
7
|
import {ConfigError, assert} from './errors.js';
|
|
5
8
|
|
|
6
9
|
|
|
@@ -80,6 +83,55 @@ export async function assertNativeProject(project , {allow = ['page', 'st
|
|
|
80
83
|
} finally { await runtime.close(); }
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
// Which routes can share one function snapshot. A guest snapshot is bounded in
|
|
87
|
+
// module count and source bytes (docs/FUNCTION-SECURITY.md), and those bounds
|
|
88
|
+
// are not relaxed for build-time content: a site whose pages each compile to
|
|
89
|
+
// their own module therefore cannot be rendered in a single pass past 127
|
|
90
|
+
// modules, whatever their size. Rather than making that the caller's problem,
|
|
91
|
+
// the helper measures each route's module closure up front — reading sources
|
|
92
|
+
// only, never building a snapshot — and packs routes greedily into passes that
|
|
93
|
+
// stay inside the budgets. Shared modules (a template middleware) are counted
|
|
94
|
+
// once per pass, so they are paid for in every pass that needs them.
|
|
95
|
+
async function planPasses(source ) {
|
|
96
|
+
const loaded = await loadDocument(source);
|
|
97
|
+
// Site conventions become routes before compilation, so they must be named
|
|
98
|
+
// here too: `only` is matched against the expanded route set.
|
|
99
|
+
await applySite(loaded, {log: () => {}});
|
|
100
|
+
const patterns = Object.keys(loaded.routes);
|
|
101
|
+
assert(patterns.length, 'Source project declares no routes');
|
|
102
|
+
const cost = new Map ();
|
|
103
|
+
for (const pattern of patterns) {
|
|
104
|
+
const route = loaded.routes[pattern] ;
|
|
105
|
+
const definitions = [];
|
|
106
|
+
for (const definition of routeFunctions(route))
|
|
107
|
+
definitions.push({function: {source: await functionFile(loaded.root, definition.source), export: definition.export || 'default'}});
|
|
108
|
+
// One route at a time: the same collector the snapshot uses, so a route
|
|
109
|
+
// whose own closure cannot fit fails here with the collector's message.
|
|
110
|
+
const collected = await collectFunctionSources(definitions, loaded.root);
|
|
111
|
+
cost.set(pattern, Object.entries(collected.sources).map(([name, code]) => [name, Buffer.byteLength(code)]));
|
|
112
|
+
}
|
|
113
|
+
const passes = [];
|
|
114
|
+
let current = [], modules = new Map (), bytes = 0;
|
|
115
|
+
for (const pattern of patterns) {
|
|
116
|
+
const own = cost.get(pattern) ;
|
|
117
|
+
const fits = () => {
|
|
118
|
+
const added = own.filter(([name]) => !modules.has(name));
|
|
119
|
+
// `collect` admits a module only while the count is below the limit, so a
|
|
120
|
+
// pass holds at most MODULE_LIMIT - 1 modules.
|
|
121
|
+
return modules.size + added.length <= MODULE_LIMIT - 1 && bytes + added.reduce((total, [, size]) => total + size, 0) <= TOTAL_BYTE_LIMIT;
|
|
122
|
+
};
|
|
123
|
+
if (current.length && !fits()) {
|
|
124
|
+
passes.push(current);
|
|
125
|
+
current = []; modules = new Map(); bytes = 0;
|
|
126
|
+
}
|
|
127
|
+
assert(fits(), `Route ${pattern} needs ${own.length} modules and ${own.reduce((total, [, size]) => total + size, 0)} bytes of source, more than one snapshot can hold`);
|
|
128
|
+
for (const [name, size] of own) if (!modules.has(name)) { modules.set(name, size); bytes += size; }
|
|
129
|
+
current.push(pattern);
|
|
130
|
+
}
|
|
131
|
+
if (current.length) passes.push(current);
|
|
132
|
+
return passes;
|
|
133
|
+
}
|
|
134
|
+
|
|
83
135
|
export async function prerenderPages(project , output , {
|
|
84
136
|
origin = 'http://localhost', fileName = pageFileName, ignoreUnrenderable = false,
|
|
85
137
|
maxPages = 500, maxPageBytes = 512 * 1024, maxTotalBytes = 32 * 1024 * 1024, log = () => {},
|
|
@@ -97,49 +149,56 @@ export async function prerenderPages(project , output , {
|
|
|
97
149
|
assert(!overlaps(source, directory) && !overlaps(directory, source), 'Source and output directories must not overlap');
|
|
98
150
|
|
|
99
151
|
const pages = [];
|
|
100
|
-
let bytes = 0;
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
`${route.path} rendered ${
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
152
|
+
let bytes = 0, targetCount = 0;
|
|
153
|
+
const renderable = (route ) => route.handler === 'function' && route.state === 'active' && route.methods.includes('GET');
|
|
154
|
+
// Case-insensitive, and shared across passes: on macOS and Windows two names
|
|
155
|
+
// differing only in case are one file, so the second render would silently
|
|
156
|
+
// replace the first.
|
|
157
|
+
const taken = new Map ();
|
|
158
|
+
const passes = await planPasses(source);
|
|
159
|
+
if (passes.length > 1) log({event: 'prerender-passes', passes: passes.length});
|
|
160
|
+
for (const only of passes) {
|
|
161
|
+
// One runtime per pass, each holding only its own pass's function snapshot.
|
|
162
|
+
const runtime = await createRuntime(source, {log: () => {}, only});
|
|
163
|
+
try {
|
|
164
|
+
const inventory = runtime.testPlan().inventory;
|
|
165
|
+
const targets = inventory.filter(renderable);
|
|
166
|
+
// Silently skipping a route publishes an incomplete site that looks whole.
|
|
167
|
+
if (!ignoreUnrenderable) {
|
|
168
|
+
const skipped = inventory.filter(route => !renderable(route));
|
|
169
|
+
assert(!skipped.length,
|
|
170
|
+
`Source project has routes this build would not render: ${skipped.map(route => `${route.path} (${route.handler}, ${route.state})`).join(', ')}. Pass ignoreUnrenderable to allow it`);
|
|
171
|
+
}
|
|
172
|
+
targetCount += targets.length;
|
|
173
|
+
assert(targetCount <= maxPages, `Source project page count ${targetCount} exceeds maxPages ${maxPages}`);
|
|
174
|
+
for (const route of targets) {
|
|
175
|
+
assertLiteralRoutePath(route.path);
|
|
176
|
+
const file = fileName(route.path);
|
|
177
|
+
assert(typeof file === 'string' && FILE.test(file) && !PROTECTED.has(file), `Unsafe output filename ${file} for route ${route.path}`);
|
|
178
|
+
const key = file.toLowerCase();
|
|
179
|
+
assert(!taken.has(key), `Routes ${taken.get(key)} and ${route.path} both render ${file}`);
|
|
180
|
+
taken.set(key, route.path);
|
|
181
|
+
const result = await runtime.handle({target: route.path, method: 'GET', origin});
|
|
182
|
+
assert(result.status === 200, `${route.path} rendered ${result.status}; expected 200`);
|
|
183
|
+
const type = result.headers.find(([name]) => name.toLowerCase() === 'content-type')?.[1] ?? '';
|
|
184
|
+
assert(/^text\/html\s*(?:;|$)/i.test(type), `${route.path} rendered ${type || 'no content type'}; expected text/html`);
|
|
185
|
+
// The response body is bytes. Keeping it as a Buffer is what preserves a
|
|
186
|
+
// multi-byte character exactly, through the file and its fixture alike.
|
|
187
|
+
const body = typeof result.body === 'string' ? Buffer.from(result.body) : Buffer.from(result.body ?? []);
|
|
188
|
+
assert(body.length > 0 && body.length <= maxPageBytes,
|
|
189
|
+
`${route.path} rendered ${body.length} bytes; expected 1..${maxPageBytes} (maxPageBytes)`);
|
|
190
|
+
bytes += body.length;
|
|
191
|
+
assert(bytes <= maxTotalBytes, `Rendered pages exceed the total budget of ${maxTotalBytes} bytes (maxTotalBytes)`);
|
|
192
|
+
pages.push({path: route.path, file, bytes: body.length, body});
|
|
193
|
+
log({event: 'prerendered', path: route.path, file, bytes: body.length});
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
// The runtime owns worker threads. Close it whether or not the render
|
|
197
|
+
// succeeded, so a failing build exits instead of hanging.
|
|
198
|
+
await runtime.close();
|
|
137
199
|
}
|
|
138
|
-
} finally {
|
|
139
|
-
// The runtime owns worker threads. Close it whether or not the render
|
|
140
|
-
// succeeded, so a failing build exits instead of hanging.
|
|
141
|
-
await runtime.close();
|
|
142
200
|
}
|
|
201
|
+
assert(targetCount, 'Source project has no active GET function routes to prerender');
|
|
143
202
|
|
|
144
203
|
// Nothing is written until every page has rendered, and the directory itself
|
|
145
204
|
// must be new: a failed or partial build never damages an existing artifact.
|
package/dist/project-tests.js
CHANGED
|
@@ -5,12 +5,12 @@ import { startServer } from './server.js';
|
|
|
5
5
|
import { readCases, hit } from './readiness.js';
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
export async function runProjectTests(project , { log = () => {}, permissions,
|
|
11
|
+
export async function runProjectTests(project , { log = () => {}, permissions, origin, extensions, plugins } = {}) {
|
|
12
12
|
const root = await realpath(project), cases = await readCases(root);
|
|
13
|
-
const app = await startServer({ project, port: 0, local: true, log, permissions,
|
|
13
|
+
const app = await startServer({ project, port: 0, local: true, log, permissions, origin, extensions, plugins });
|
|
14
14
|
const agent = new Agent({keepAlive:true,maxSockets:1}); let failed = 0;
|
|
15
15
|
try {
|
|
16
16
|
for (const [i,test] of cases.entries()) {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { request } from 'node:https';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
const MAX_BYTES=64*1024;
|
|
17
|
+
const prefix='/urlcode-conformance';
|
|
18
|
+
/** Synthetic read-only routes, except a POST to a declared constant response. */
|
|
19
|
+
export function providerConformanceCases() {
|
|
20
|
+
return [
|
|
21
|
+
{id:'redirect-query-drop',path:`${prefix}/redirect?ignored=secret`,method:'GET',headers:{},expected:{status:302,headers:{location:'https://example.test/destination'},body:''}},
|
|
22
|
+
{id:'redirect-head',path:`${prefix}/redirect`,method:'HEAD',headers:{},expected:{status:302,headers:{location:'https://example.test/destination'},body:''}},
|
|
23
|
+
{id:'path-component',path:`${prefix}/people/A%20B`,method:'GET',headers:{},expected:{status:307,headers:{location:'https://example.test/people/A%20B'},body:''}},
|
|
24
|
+
{id:'query-validation',path:`${prefix}/query?n=7&ignored=yes`,method:'GET',headers:{},expected:{status:302,headers:{location:'https://example.test/query?n=7'},body:''}},
|
|
25
|
+
{id:'query-invalid',path:`${prefix}/query?n=not-a-number`,method:'GET',headers:{},expected:{status:400}},
|
|
26
|
+
{id:'query-duplicate',path:`${prefix}/query?n=1&n=2`,method:'GET',headers:{},expected:{status:400}},
|
|
27
|
+
{id:'constant-response',path:`${prefix}/hello`,method:'GET',headers:{},expected:{status:200,headers:{'content-type':'text/plain; charset=utf-8','x-conformance':'v1'},body:'provider conformance v1'}},
|
|
28
|
+
{id:'response-head',path:`${prefix}/hello`,method:'HEAD',headers:{},expected:{status:200,body:''}},
|
|
29
|
+
{id:'method-refusal',path:`${prefix}/hello`,method:'POST',headers:{},body:'',expected:{status:405,headers:{allow:'GET, HEAD'}}},
|
|
30
|
+
{id:'body-accepted',path:`${prefix}/body`,method:'POST',headers:{'content-type':'text/plain'},body:'small',expected:{status:200,body:'accepted'}},
|
|
31
|
+
{id:'body-limit',path:`${prefix}/body`,method:'POST',headers:{'content-type':'text/plain'},body:'x'.repeat(33),expected:{status:413}},
|
|
32
|
+
{id:'missing-route',path:`${prefix}/missing`,method:'GET',headers:{},expected:{status:404}},
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
function optionsChecked(target ,options ) {
|
|
36
|
+
if(!['self-hosted','aws','vercel','cloudflare'].includes(target))throw new Error('Unknown verification target');
|
|
37
|
+
const timeout=options.timeoutMs??3000;
|
|
38
|
+
if(!Number.isInteger(timeout)||timeout<50||timeout>10000)throw new Error('Timeout must be 50–10000 ms');
|
|
39
|
+
for(const value of [options.release,options.gitCommit])if(value!==undefined&&(typeof value!=='string'||value.length>256||/[\u0000-\u001f\u007f]/u.test(value)))throw new Error('Release identity must be at most 256 printable characters');
|
|
40
|
+
return timeout;
|
|
41
|
+
}
|
|
42
|
+
async function run(target ,transport ,options ,origin ) {
|
|
43
|
+
const timeout=optionsChecked(target,options),cases=providerConformanceCases(),findings =[];
|
|
44
|
+
const deadline=performance.now()+60000;let requests=0;
|
|
45
|
+
for(const probe of cases){
|
|
46
|
+
const failures =[];let status =null;
|
|
47
|
+
const controller=new AbortController();let timer ;
|
|
48
|
+
try{
|
|
49
|
+
const remaining=deadline-performance.now();if(remaining<=0)throw new Error('suite deadline');
|
|
50
|
+
requests++;
|
|
51
|
+
const answer=await Promise.race([transport(probe,controller.signal),new Promise ((_,reject)=>{timer=setTimeout(()=>{reject(new Error('request deadline'));controller.abort();},Math.min(timeout,remaining));})]);
|
|
52
|
+
status=answer.status;
|
|
53
|
+
if(Buffer.byteLength(answer.body)>MAX_BYTES)throw new Error('response limit');
|
|
54
|
+
if(answer.status!==probe.expected.status)failures.push('status differs');
|
|
55
|
+
const headers=Object.fromEntries(Object.entries(answer.headers).map(([name,value])=>[name.toLowerCase(),value]));
|
|
56
|
+
for(const [name,value]of Object.entries(probe.expected.headers??{}))if(headers[name]!==value)failures.push(`header ${name} differs`);
|
|
57
|
+
if(probe.expected.body!==undefined&&answer.body!==probe.expected.body)failures.push('body differs');
|
|
58
|
+
}catch{failures.push('transport, deadline or response-limit failure');}
|
|
59
|
+
finally{clearTimeout(timer);controller.abort();}
|
|
60
|
+
findings.push({case:probe.id,pass:failures.length===0,expectedStatus:probe.expected.status,observedStatus:status,failures});
|
|
61
|
+
}
|
|
62
|
+
return {schemaVersion:1,fixtureVersion:1,target,origin,evidence:origin===null?'local-adapter':'deployment-http',providerVerification:origin===null?'unverified':'observed',timestamp:new Date().toISOString(),release:options.release??null,gitCommit:options.gitCommit??null,fixtureSha256:createHash('sha256').update(JSON.stringify(cases)).digest('hex'),pass:findings.every(f=>f.pass),requests,findings,notes:[
|
|
63
|
+
origin===null?'Local adapter replay is not evidence of a deployed provider.':'HTTP observations apply only to the supplied origin and timestamp; provider identity and ownership are asserted by the caller, not attested.',
|
|
64
|
+
'Release and Git identities are caller-supplied labels, not independently verified deployment identity.',
|
|
65
|
+
'No provisioning, credentials, policy guarantees, soak, recovery or independent security assessment is included.',
|
|
66
|
+
'Duplicate raw header counts and malformed-path normalization can differ at provider ingress; this common suite does not prove those transports equivalent.'
|
|
67
|
+
]};
|
|
68
|
+
}
|
|
69
|
+
/** Replay against a caller-supplied local adapter; never label this a provider deployment. */
|
|
70
|
+
export async function runProviderConformance(target ,transport ,options ={}) {
|
|
71
|
+
return run(target,transport,options,null);
|
|
72
|
+
}
|
|
73
|
+
function httpsTransport(origin ) {
|
|
74
|
+
return (probe,signal)=>new Promise((resolve,reject)=>{
|
|
75
|
+
let settled=false;
|
|
76
|
+
const fail=()=>{if(!settled){settled=true;reject(new Error('HTTPS probe failed'));}};
|
|
77
|
+
const req=request(new URL(probe.path,origin),{method:probe.method,headers:{'user-agent':'URLCode-provider-conformance/1','accept-encoding':'identity',...probe.headers},agent:false,maxHeaderSize:16384,signal},res=>{
|
|
78
|
+
const chunks =[];let size=0;
|
|
79
|
+
res.on('error',fail);res.on('aborted',fail);
|
|
80
|
+
res.on('data',(chunk )=>{size+=chunk.length;if(size>MAX_BYTES){fail();res.destroy();req.destroy();}else chunks.push(chunk);});
|
|
81
|
+
res.on('end',()=>{if(settled)return;settled=true;const headers ={};for(const [name,value]of Object.entries(res.headers))if(value!==undefined)headers[name]=Array.isArray(value)?value.join(', '):value;resolve({status:res.statusCode??0,headers,body:Buffer.concat(chunks).toString('utf8')});});
|
|
82
|
+
});
|
|
83
|
+
req.on('error',fail);req.end(probe.body);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Probe only an explicitly supplied, operator-owned deployment of the fixture. Redirects are never followed. */
|
|
87
|
+
export async function verifyProviderDeployment(target ,origin ,options ={}) {
|
|
88
|
+
optionsChecked(target,options);
|
|
89
|
+
const url=new URL(origin);
|
|
90
|
+
if(url.protocol!=='https:'||url.username||url.password||url.pathname!=='/'||url.search||url.hash||url.origin!==origin.replace(/\/$/,''))throw new Error('Provide an explicit HTTPS origin without credentials, path, query or fragment');
|
|
91
|
+
return run(target,httpsTransport(url),options,url.origin);
|
|
92
|
+
}
|