@jimhoyd/urlcode 0.3.0 → 0.4.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/urlcode-authoring/SKILL.md +106 -0
- package/.claude/skills/urlcode-operations/SKILL.md +114 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/CONTRIBUTING.md +30 -2
- package/README.md +157 -230
- package/ROADMAP.md +85 -2
- package/SECURITY.md +8 -1
- package/dist/BUILD-MANIFEST.json +62 -32
- package/dist/adapters.js +4 -23
- package/dist/agent-lists.js +1 -1
- package/dist/agents-guide.js +113 -0
- package/dist/authoring-files.js +60 -0
- package/dist/authoring.js +11 -1
- package/dist/aws.js +4 -3
- package/dist/build-cloudflare.js +11 -24
- package/dist/bulk.js +37 -0
- package/dist/capabilities.js +208 -0
- package/dist/capability-query.js +72 -0
- package/dist/catalog.js +105 -0
- package/dist/cli.js +154 -15
- package/dist/client-address.js +1 -1
- package/dist/compliance-rules/baseline.js +8 -8
- package/dist/compliance-rules/privacy.js +3 -3
- package/dist/compliance-rules/strict.js +5 -5
- package/dist/conditions.js +88 -0
- package/dist/config.js +60 -4
- package/dist/context.js +156 -0
- package/dist/ecosystem-cli.js +88 -0
- package/dist/egress.js +98 -0
- package/dist/examples.js +92 -0
- package/dist/explain-cli.js +64 -0
- package/dist/explain.js +132 -0
- package/dist/extensions.js +172 -0
- package/dist/function-sources.js +15 -3
- package/dist/index.js +37 -0
- package/dist/init-with.js +165 -0
- package/dist/interchange-cli.js +42 -0
- package/dist/interchange.js +189 -0
- package/dist/link-cli.js +1 -1
- package/dist/management-policy.js +0 -1
- package/dist/manifest.js +111 -0
- package/dist/match.js +2 -2
- package/dist/mcp-authoring.js +147 -0
- package/dist/mcp.js +97 -0
- package/dist/observability.js +6 -0
- package/dist/operator-host.js +29 -0
- package/dist/plugins.js +12 -0
- package/dist/policies/agents.js +2 -2
- package/dist/policies/compression.js +2 -1
- package/dist/policies/security.js +0 -0
- package/dist/policies.js +1 -1
- package/dist/policy.js +29 -7
- package/dist/prerender.js +100 -41
- package/dist/project-tests.js +3 -3
- package/dist/provider-verification.js +92 -0
- package/dist/proxy.js +44 -0
- package/dist/readiness.js +5 -5
- package/dist/recipes.js +41 -0
- package/dist/route-diff.js +106 -0
- package/dist/router.js +42 -2
- package/dist/runtime.js +95 -14
- package/dist/schema-query.js +62 -0
- package/dist/signals.js +24 -0
- package/dist/site.js +0 -0
- package/dist/tooling.js +96 -0
- package/dist/types/adapters.d.ts +7 -4
- package/dist/types/agent-lists.d.ts +0 -1
- package/dist/types/agents-guide.d.ts +17 -0
- package/dist/types/authoring-files.d.ts +10 -0
- package/dist/types/aws.d.ts +3 -1
- package/dist/types/build-cloudflare.d.ts +1 -0
- package/dist/types/bulk.d.ts +27 -0
- package/dist/types/capabilities.d.ts +55 -0
- package/dist/types/capability-query.d.ts +24 -0
- package/dist/types/catalog.d.ts +65 -0
- package/dist/types/client-address.d.ts +0 -1
- package/dist/types/compliance-rules/baseline.d.ts +1 -9
- package/dist/types/compliance-rules/privacy.d.ts +1 -4
- package/dist/types/compliance-rules/strict.d.ts +0 -5
- package/dist/types/conditions.d.ts +19 -0
- package/dist/types/config.d.ts +20 -2
- package/dist/types/context.d.ts +65 -0
- package/dist/types/ecosystem-cli.d.ts +17 -0
- package/dist/types/egress.d.ts +46 -0
- package/dist/types/examples.d.ts +50 -0
- package/dist/types/explain-cli.d.ts +11 -0
- package/dist/types/explain.d.ts +95 -0
- package/dist/types/extensions.d.ts +122 -0
- package/dist/types/function-sources.d.ts +5 -0
- package/dist/types/index.d.ts +33 -0
- package/dist/types/init-with.d.ts +30 -0
- package/dist/types/interchange-cli.d.ts +16 -0
- package/dist/types/interchange.d.ts +42 -0
- package/dist/types/link-cli.d.ts +2 -1
- package/dist/types/management-policy.d.ts +0 -1
- package/dist/types/manifest.d.ts +81 -0
- package/dist/types/match.d.ts +1 -0
- package/dist/types/mcp-authoring.d.ts +92 -0
- package/dist/types/mcp.d.ts +12 -0
- package/dist/types/observability.d.ts +2 -0
- package/dist/types/operator-host.d.ts +8 -0
- package/dist/types/plugins.d.ts +2 -0
- package/dist/types/policies/agents.d.ts +0 -2
- package/dist/types/policies/compression.d.ts +2 -0
- package/dist/types/policies/security.d.ts +0 -1
- package/dist/types/policy.d.ts +15 -4
- package/dist/types/project-tests.d.ts +3 -1
- package/dist/types/provider-verification.d.ts +53 -0
- package/dist/types/proxy.d.ts +21 -0
- package/dist/types/readiness.d.ts +1 -1
- package/dist/types/recipes.d.ts +30 -0
- package/dist/types/route-diff.d.ts +27 -0
- package/dist/types/runtime.d.ts +11 -0
- package/dist/types/schema-query.d.ts +12 -0
- package/dist/types/signals.d.ts +25 -0
- package/dist/types/site.d.ts +0 -1
- package/dist/types/tooling.d.ts +115 -0
- package/dist/types/types.d.ts +57 -0
- package/dist/types/typescript-authoring.d.ts +12 -0
- package/dist/types/vercel.d.ts +3 -1
- package/dist/types/verify-deployment.d.ts +47 -0
- package/dist/types.js +21 -2
- package/dist/typescript-authoring.js +104 -0
- package/dist/vercel.js +4 -3
- package/dist/verify-deployment.js +270 -0
- package/docs/AI-AUTHORING.md +130 -8
- package/docs/BULK.md +79 -0
- package/docs/CAPABILITIES.md +179 -0
- package/docs/CAPACITY.md +1 -1
- package/docs/CI.md +142 -0
- package/docs/CONDITIONS.md +74 -0
- package/docs/DEPLOYMENT-CHECKS.md +108 -0
- package/docs/DYNAMIC-LINKS.md +18 -518
- package/docs/EGRESS.md +125 -0
- package/docs/EXTENSIONS.md +226 -0
- package/docs/FRAMEWORK.md +182 -0
- package/docs/INSTALL.md +45 -7
- package/docs/INTERCHANGE.md +134 -0
- package/docs/MIDDLEWARE-EXAMPLES.md +75 -0
- package/docs/MIDDLEWARE.md +2 -0
- package/docs/NEXT-PHASE-PLAN.md +90 -0
- package/docs/NEXT-STEPS.md +415 -0
- package/docs/OBSERVABILITY.md +4 -2
- package/docs/OPERATIONAL-PROOF.md +4 -1
- package/docs/OPERATIONS.md +6 -3
- package/docs/PLUGINS.md +37 -0
- package/docs/POLICIES.md +12 -309
- package/docs/PRERENDER.md +40 -0
- package/docs/PROJECT-DIRECTION.md +42 -0
- package/docs/PROVIDER-VERIFICATION.md +84 -0
- package/docs/READINESS.md +21 -1
- package/docs/README.md +82 -31
- package/docs/RECIPES.md +99 -0
- package/docs/RELEASE-READINESS.md +11 -9
- package/docs/RELEASE-SECURITY.md +27 -4
- package/docs/SECURITY-AUDIT.md +1 -1
- package/docs/SPECIFICATION.md +95 -8
- package/docs/SPIKE-BUSINESS-SUITE.md +1013 -0
- package/docs/SPIKE-EXTENSION-MODEL.md +419 -0
- package/docs/SPIKE-EXTENSIONS.md +1 -0
- package/docs/SPIKE-LAMBDA-COMPILE.md +199 -0
- package/docs/STANDARDS.md +150 -142
- package/docs/STARTERS.md +21 -1
- package/docs/TOOLING.md +291 -0
- package/docs/TYPESCRIPT-AUTHORING.md +67 -0
- package/docs/TYPESCRIPT.md +1 -1
- package/docs/USABILITY-REVIEW.md +123 -0
- package/docs/YAML-GUIDE.md +18 -479
- package/docs/YAML-REFERENCE.md +127 -16
- package/docs/links/cli.md +110 -0
- package/docs/links/limits.md +175 -0
- package/docs/links/management-api.md +80 -0
- package/docs/links/pools.md +75 -0
- package/docs/links/setup.md +135 -0
- package/docs/policies/agents.md +1 -1
- package/docs/policies/contract.md +52 -0
- package/docs/policies/hardened.md +56 -0
- package/docs/policies/interoperability.md +169 -0
- package/docs/policies/operations.md +45 -0
- package/docs/yaml/assets.md +36 -0
- package/docs/yaml/conditions.md +20 -0
- package/docs/yaml/functions.md +160 -0
- package/docs/yaml/links.md +30 -0
- package/docs/yaml/middleware.md +29 -0
- package/docs/yaml/organization.md +74 -0
- package/docs/yaml/policies.md +37 -0
- package/docs/yaml/redirects.md +64 -0
- package/docs/yaml/responses.md +57 -0
- package/docs/yaml/site.md +24 -0
- package/examples/assets/example.yaml +17 -0
- package/examples/aws/example.yaml +20 -0
- package/examples/cloudflare/example.yaml +19 -0
- package/examples/compliance/example.yaml +11 -0
- package/examples/conditions/README.md +12 -0
- package/examples/conditions/example.yaml +19 -0
- package/examples/conditions/tests/requests.json +13 -0
- package/examples/conditions/urlcode.yaml +24 -0
- package/examples/cookbook/README.md +8 -4
- package/examples/cookbook/example.yaml +17 -0
- package/examples/cookbook/functions/catalog.mjs +3 -0
- package/examples/cookbook/functions/fail.mjs +4 -0
- package/examples/cookbook/functions/items.mjs +3 -0
- package/examples/cookbook/functions/profile.mjs +3 -0
- package/examples/cookbook/functions/resource.mjs +3 -0
- package/examples/cookbook/functions/status.mjs +3 -0
- package/examples/cookbook/middleware/auth.mjs +48 -0
- package/examples/cookbook/middleware/body.mjs +15 -0
- package/examples/cookbook/middleware/bucket.mjs +19 -0
- package/examples/cookbook/middleware/cors.mjs +21 -0
- package/examples/cookbook/middleware/debug.mjs +13 -0
- package/examples/cookbook/middleware/envelope.mjs +11 -0
- package/examples/cookbook/middleware/errors.mjs +11 -0
- package/examples/cookbook/middleware/etag.mjs +18 -0
- package/examples/cookbook/middleware/locale.mjs +16 -0
- package/examples/cookbook/middleware/maintenance.mjs +10 -0
- package/examples/cookbook/middleware/methods.mjs +15 -0
- package/examples/cookbook/middleware/negotiate.mjs +20 -0
- package/examples/cookbook/middleware/referer.mjs +12 -0
- package/examples/cookbook/middleware/request-id.mjs +16 -0
- package/examples/cookbook/route-index.json +676 -0
- package/examples/cookbook/routes/middleware.yaml +126 -0
- package/examples/cookbook/tests/requests.json +526 -0
- package/examples/cookbook/urlcode.yaml +1 -0
- package/examples/egress/README.md +22 -0
- package/examples/egress/example.yaml +19 -0
- package/examples/egress/urlcode.yaml +19 -0
- package/examples/extensions/README.md +7 -0
- package/examples/extensions/example.yaml +21 -0
- package/examples/extensions/urlcode.yaml +25 -0
- package/examples/live-links/example.yaml +21 -0
- package/examples/monitoring/example.yaml +8 -0
- package/examples/prerender/example.yaml +16 -0
- package/examples/provider-conformance/README.md +12 -0
- package/examples/provider-conformance/example.yaml +14 -0
- package/examples/provider-conformance/urlcode.yaml +34 -0
- package/examples/tunnel/example.yaml +8 -0
- package/examples/vercel/example.yaml +19 -0
- package/llms-full.txt +2709 -0
- package/llms.txt +48 -19
- package/package.json +29 -7
- package/packaging/claude-plugin/.claude-plugin/plugin.json +19 -0
- package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +106 -0
- package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +114 -0
- package/recipes/authenticated-json-api/README.md +51 -0
- package/recipes/authenticated-json-api/functions/profile.mjs +5 -0
- package/recipes/authenticated-json-api/recipe.yaml +34 -0
- package/recipes/authenticated-json-api/tests/requests.json +39 -0
- package/recipes/authenticated-json-api/urlcode.yaml +12 -0
- package/recipes/contact-form/README.md +25 -0
- package/recipes/contact-form/functions/contact.mjs +17 -0
- package/recipes/contact-form/recipe.yaml +33 -0
- package/recipes/contact-form/tests/requests.json +47 -0
- package/recipes/contact-form/urlcode.yaml +18 -0
- package/recipes/cors-api/README.md +16 -0
- package/recipes/cors-api/functions/items.mjs +3 -0
- package/recipes/cors-api/middleware/cors.mjs +21 -0
- package/recipes/cors-api/recipe.yaml +26 -0
- package/recipes/cors-api/tests/requests.json +65 -0
- package/recipes/cors-api/urlcode.yaml +12 -0
- package/recipes/health-page/README.md +13 -0
- package/recipes/health-page/recipe.yaml +23 -0
- package/recipes/health-page/tests/requests.json +36 -0
- package/recipes/health-page/urlcode.yaml +19 -0
- package/recipes/json-api/README.md +6 -0
- package/recipes/json-api/functions/echo.mjs +3 -0
- package/recipes/json-api/recipe.yaml +25 -0
- package/recipes/json-api/tests/requests.json +34 -0
- package/recipes/json-api/urlcode.yaml +12 -0
- package/recipes/middleware/README.md +34 -0
- package/recipes/middleware/functions/catalog.mjs +3 -0
- package/recipes/middleware/functions/fail.mjs +4 -0
- package/recipes/middleware/functions/items.mjs +3 -0
- package/recipes/middleware/functions/profile.mjs +3 -0
- package/recipes/middleware/functions/resource.mjs +3 -0
- package/recipes/middleware/functions/status.mjs +3 -0
- package/recipes/middleware/middleware/auth.mjs +48 -0
- package/recipes/middleware/middleware/body.mjs +15 -0
- package/recipes/middleware/middleware/bucket.mjs +19 -0
- package/recipes/middleware/middleware/cors.mjs +21 -0
- package/recipes/middleware/middleware/debug.mjs +13 -0
- package/recipes/middleware/middleware/envelope.mjs +11 -0
- package/recipes/middleware/middleware/errors.mjs +11 -0
- package/recipes/middleware/middleware/etag.mjs +18 -0
- package/recipes/middleware/middleware/locale.mjs +16 -0
- package/recipes/middleware/middleware/maintenance.mjs +10 -0
- package/recipes/middleware/middleware/methods.mjs +15 -0
- package/recipes/middleware/middleware/negotiate.mjs +20 -0
- package/recipes/middleware/middleware/referer.mjs +12 -0
- package/recipes/middleware/middleware/request-id.mjs +16 -0
- package/recipes/middleware/public/guide.txt +1 -0
- package/recipes/middleware/recipe.yaml +50 -0
- package/recipes/middleware/tests/requests.json +528 -0
- package/recipes/middleware/urlcode.yaml +127 -0
- package/recipes/protected-download/README.md +22 -0
- package/recipes/protected-download/files/report.txt +1 -0
- package/recipes/protected-download/recipe.yaml +31 -0
- package/recipes/protected-download/tests/requests.json +32 -0
- package/recipes/protected-download/urlcode.yaml +15 -0
- package/recipes/redirect/README.md +7 -0
- package/recipes/redirect/recipe.yaml +25 -0
- package/recipes/redirect/tests/requests.json +19 -0
- package/recipes/redirect/urlcode.yaml +9 -0
- package/recipes/static-plus-api/README.md +15 -0
- package/recipes/static-plus-api/functions/info.mjs +3 -0
- package/recipes/static-plus-api/public/assets/index.html +3 -0
- package/recipes/static-plus-api/public/assets/site.css +1 -0
- package/recipes/static-plus-api/public/index.html +8 -0
- package/recipes/static-plus-api/recipe.yaml +29 -0
- package/recipes/static-plus-api/tests/requests.json +56 -0
- package/recipes/static-plus-api/urlcode.yaml +23 -0
- package/recipes/typescript/README.md +7 -0
- package/recipes/typescript/functions/hello.ts +5 -0
- package/recipes/typescript/recipe.yaml +23 -0
- package/recipes/typescript/tests/requests.json +18 -0
- package/recipes/typescript/urlcode.yaml +5 -0
- package/recipes/webhook-receiver/README.md +16 -0
- package/recipes/webhook-receiver/functions/receive.mjs +16 -0
- package/recipes/webhook-receiver/recipe.yaml +26 -0
- package/recipes/webhook-receiver/tests/requests.json +59 -0
- package/recipes/webhook-receiver/urlcode.yaml +16 -0
- package/schemas/recipe.schema.json +138 -0
- package/schemas/urlcode.schema.json +656 -80
- package/skills/urlcode/SKILL.md +98 -0
- package/starters/default/.github/workflows/urlcode.yml +23 -0
- package/starters/default/.mcp.json +12 -0
- package/starters/default/AGENTS.md +79 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { hasExtensionPolicy } from './extensions.js';
|
|
2
|
+
import { ConfigError } from './errors.js';
|
|
3
|
+
import { effectivePolicies, registry } from './policies.js';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export const capabilityTargets = ['self-hosted', 'cloudflare', 'aws', 'vercel'] ;
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
export const capabilityNames = ['extension','policies.extensions','proxy', 'signals', 'conditional', 'conditions', 'redirect', 'respond', 'page', 'static', 'download', 'function', 'middleware', 'link', 'dynamicLinks', 'parameters', 'methods', 'enabled', 'expires', 'request.body', 'response.headers', 'bindings', 'policies.agents', 'policies.security', 'policies.cache', 'policies.compression', 'policies.throttle'] ;
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export function normalizeCapabilityTarget(target ) {
|
|
24
|
+
if (target === 'node') return 'self-hosted';
|
|
25
|
+
if ((capabilityTargets ).includes(target)) return target ;
|
|
26
|
+
throw new ConfigError('Unknown capability target; use self-hosted, cloudflare, aws or vercel');
|
|
27
|
+
}
|
|
28
|
+
const internalTarget = (target ) => target === 'self-hosted' ? 'node' : target;
|
|
29
|
+
const deployment = (target ) => target === 'self-hosted' ? 'local-runtime' : 'unverified';
|
|
30
|
+
const policyNames = Object.keys(registry) ;
|
|
31
|
+
|
|
32
|
+
// Policy modules remain the authority for configuration-dependent support.
|
|
33
|
+
function decision(capability , target , policies ) {
|
|
34
|
+
if(capability==='extension'||capability==='policies.extensions')return target==='cloudflare'?{support:'refused',reason:'Operator extensions have no Worker artifact lowering'}:{support:'native',reason:'Requires an operator registry and exact revision pin at activation'};
|
|
35
|
+
const policy = policyNames.find(name => capability === `policies.${name}`);
|
|
36
|
+
if (policy) {
|
|
37
|
+
if (policy === 'throttle' && !policies && (target === 'aws' || target === 'vercel')) {
|
|
38
|
+
return { support: 'conditional', reason: 'Only partition: route is implemented; counters are per instance' };
|
|
39
|
+
}
|
|
40
|
+
const module = registry[policy];
|
|
41
|
+
const support = (module.targets(policies?.[policy]) )[internalTarget(target)] ?? 'unknown';
|
|
42
|
+
return { support, reason: support === 'refused' ? `${capability} cannot be compiled or enforced by this target`
|
|
43
|
+
: support === 'delegated' ? 'Delegated to the provider; exact compression settings and deployment behavior are unverified'
|
|
44
|
+
: support === 'unknown' ? 'No implementation evidence for this target'
|
|
45
|
+
: 'Implemented by the existing policy module; configuration validation still applies' };
|
|
46
|
+
}
|
|
47
|
+
if (target !== 'self-hosted') {
|
|
48
|
+
const reason = ['proxy','signals'].includes(capability) ? 'bounded egress currently requires the self-hosted Node lifecycle'
|
|
49
|
+
: target === 'cloudflare' && ['conditional','conditions'].includes(capability) ? 'conditional routing has no Worker artifact lowering yet'
|
|
50
|
+
: capability === 'function' ? 'isolated functions need worker threads and the WASM engine'
|
|
51
|
+
: capability === 'middleware' ? 'declares middleware that needs the sandbox'
|
|
52
|
+
: capability === 'link' || capability === 'dynamicLinks' ? 'stored live links need a durable writable store'
|
|
53
|
+
: target === 'cloudflare' && ['page', 'static', 'download'].includes(capability) ? 'assets need a static-asset binding'
|
|
54
|
+
: target === 'cloudflare' && capability === 'bindings' ? 'env and secret bindings would have to be baked into the artifact'
|
|
55
|
+
: undefined;
|
|
56
|
+
if (reason) return { support: 'refused', reason };
|
|
57
|
+
}
|
|
58
|
+
return { support: target === 'cloudflare' ? 'compiled' : 'native', reason: target === 'self-hosted'
|
|
59
|
+
? 'Implemented and tested in the local runtime'
|
|
60
|
+
: 'Implemented with local adapter tests; provider deployment unverified; transport normalization limits apply' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Catalog, not a claim that every configuration or provider deployment works. */
|
|
64
|
+
export function getCapabilities(target ) {
|
|
65
|
+
const selected = target === undefined ? [...capabilityTargets] : [normalizeCapabilityTarget(target)];
|
|
66
|
+
return { format: 1, targets: selected.map(target => ({ target, deployment: deployment(target) })),
|
|
67
|
+
capabilities: capabilityNames.map(capability => ({ capability,
|
|
68
|
+
targets: Object.fromEntries(selected.map(target => [target, decision(capability, target)])) })) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A safe projection shared by declaration preflight and the existing compiled IR. No values escape. */
|
|
72
|
+
export function routeCapabilities(route , document ) {
|
|
73
|
+
const result = [];
|
|
74
|
+
if(route.extension)result.push('extension');
|
|
75
|
+
if(hasExtensionPolicy(document,route))result.push('policies.extensions');
|
|
76
|
+
if(route.proxy)result.push('proxy');
|
|
77
|
+
if(route.signals?.length)result.push('signals');
|
|
78
|
+
if (route.match) result.push('conditions');
|
|
79
|
+
if (route.conditional) {
|
|
80
|
+
result.push('conditional');
|
|
81
|
+
for (const branch of [...route.conditional.cases, ...(route.conditional.fallback ? [route.conditional.fallback] : [])]) {
|
|
82
|
+
if (branch.redirect && !result.includes('redirect')) result.push('redirect');
|
|
83
|
+
if (branch.respond && !result.includes('respond')) result.push('respond');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const name of ['redirect', 'respond', 'page', 'static', 'download', 'function', 'link'] ) if (route[name]) result.push(name);
|
|
87
|
+
if (route.middleware?.length) result.push('middleware');
|
|
88
|
+
if (route.parameters?.length) result.push('parameters');
|
|
89
|
+
// Defaults are still semantics required by every route.
|
|
90
|
+
result.push('methods', 'enabled');
|
|
91
|
+
if (route.expires) result.push('expires');
|
|
92
|
+
if (route.request?.body) result.push('request.body');
|
|
93
|
+
if (Object.keys(route.response?.headers ?? {}).length) result.push('response.headers');
|
|
94
|
+
if (Object.keys(route.env ?? {}).length || Object.keys(route.secrets ?? {}).length) result.push('bindings');
|
|
95
|
+
const policies = effectivePolicies(document, route);
|
|
96
|
+
for (const name of policyNames) if (policies[name]) result.push(`policies.${name}`);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function analyze(document , routes , requestedTarget ) {
|
|
101
|
+
const target = normalizeCapabilityTarget(requestedTarget);
|
|
102
|
+
const requirements = [];
|
|
103
|
+
if (Object.keys(document.extensions??{}).length) requirements.push({path:'(project)',capability:'extension',...decision('extension',target)});
|
|
104
|
+
if (document.dynamicLinks) requirements.push({ path: '(project)', capability: 'dynamicLinks', ...decision('dynamicLinks', target) });
|
|
105
|
+
for (const [path, route] of routes) {
|
|
106
|
+
const policies = effectivePolicies(document, route);
|
|
107
|
+
for (const capability of routeCapabilities(route, document)) requirements.push({ path, capability, ...decision(capability, target, policies) });
|
|
108
|
+
}
|
|
109
|
+
const issues = requirements.filter(item => item.support === 'refused' || item.support === 'unknown' || item.support === 'conditional');
|
|
110
|
+
return { target, deployment: deployment(target), compatible: issues.length === 0, requirements, issues };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Preflight only: call after schema validation/site expansion, before resolving bindings. */
|
|
114
|
+
export function analyzeProjectCapabilities(loaded , target ) {
|
|
115
|
+
return analyze(loaded.document, Object.entries(loaded.routes), target);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Analyze normalized route semantics without exporting validators, resources or resolved secrets. */
|
|
119
|
+
export function analyzeCompiledCapabilities(document , compiled , target ) {
|
|
120
|
+
function* entries() {
|
|
121
|
+
for (const route of compiled.exact.values()) yield [route.pattern, route];
|
|
122
|
+
for (const bucket of compiled.byLength.values()) for (const route of bucket) yield [route.pattern, route];
|
|
123
|
+
for (const route of compiled.mounts) yield [route.pattern, route];
|
|
124
|
+
}
|
|
125
|
+
return analyze(document, entries(), target);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function assertTargetCompatibility(report ) {
|
|
129
|
+
if (report.compatible) return;
|
|
130
|
+
throw new ConfigError(`Cannot activate/build project for ${report.target}:\n` + report.issues.map(issue =>
|
|
131
|
+
`${issue.path}\n capability: ${issue.capability}\n unsupported by target: ${report.target}\n ${issue.reason}`).join('\n'));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function formatCapabilities(catalog ) {
|
|
135
|
+
return ['Capability'.padEnd(24) + catalog.targets.map(({ target }) => target.padEnd(16)).join(''),
|
|
136
|
+
...catalog.capabilities.map(row => row.capability.padEnd(24) + catalog.targets.map(({ target }) => (row.targets[target]?.support ?? 'unknown').padEnd(16)).join('')),
|
|
137
|
+
'', 'Provider deployments: unverified. native/compiled describe local implementation tests.',
|
|
138
|
+
'conditional requires configuration analysis; delegated relies on the provider (unverified).',
|
|
139
|
+
'See docs/CAPABILITIES.md for transport limits and programmatic project analysis.', ''].join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
const policyDetail = (name , summary , constraints ) => ({ kind: 'policy', summary, schema: [`policies.${name}`], constraints:
|
|
153
|
+
['Route policies merge over project/profile policies; `false` removes one', ...constraints], grants: [] });
|
|
154
|
+
/** Static facts about each catalog entry. Per-target support stays in `decision`; recipe and cookbook usage is scanned by `getCapability`. */
|
|
155
|
+
export const capabilityDetails = {
|
|
156
|
+
extension: { kind: 'project', summary: 'Route delegated to a versioned logical extension declared under top-level `extensions`.', schema: ['extension', 'extensions'],
|
|
157
|
+
constraints: ['Extension names match ^[a-z][a-z0-9-]{0,63}$; at most 16 project extensions', 'Each declaration needs `version: "1"` and a `config` object', 'Never loads project code; the registry is operator host code'],
|
|
158
|
+
grants: ['Operator extension registry loaded with --host-file, pinned to the exact project revision'] },
|
|
159
|
+
'policies.extensions': { kind: 'policy', summary: 'Extension-provided policy attached through `policies.extensions`.', schema: ['policies.extensions'],
|
|
160
|
+
constraints: ['Names must reference declared top-level `extensions`', 'Refused on Cloudflare: no Worker artifact lowering'], grants: ['Operator extension registry loaded with --host-file'] },
|
|
161
|
+
proxy: { kind: 'egress', summary: 'Bounded HTTPS proxy to one declared upstream URL.', schema: ['proxy'],
|
|
162
|
+
constraints: ['`url` at most 8192 characters; headers at most 32, string values at most 4096 characters or `{secret}` references', 'Forwarded query keys and request headers are explicit allowlists (at most 32 each)', 'Self-hosted only: bounded egress requires the Node lifecycle'],
|
|
163
|
+
grants: ['External operator policy (--policy) with a revision-pinned exact-origin grant for the route', 'Secret bindings referenced by headers must be granted separately'] },
|
|
164
|
+
signals: { kind: 'egress', summary: 'Fire-and-forget HTTPS notifications after a route reply.', schema: ['signals'],
|
|
165
|
+
constraints: ['1 to 8 signals per route; each `url` at most 8192 characters with at most 32 headers', 'Self-hosted only: bounded egress requires the Node lifecycle'],
|
|
166
|
+
grants: ['External operator policy (--policy) with a revision-pinned exact-origin grant per signal destination'] },
|
|
167
|
+
conditional: { kind: 'routing', summary: 'Disjoint request-condition cases selecting a redirect or respond reply.', schema: ['conditional'],
|
|
168
|
+
constraints: ['1 to 16 cases plus an optional fallback; cases must be disjoint', 'Each case replies with `redirect` or `respond`, never a handler needing assets or code', 'Refused on Cloudflare until artifact lowering exists'], grants: [] },
|
|
169
|
+
conditions: { kind: 'routing', summary: 'Route-level `match` on query, headers or cookies.', schema: ['match'],
|
|
170
|
+
constraints: ['At least one of query, headers, cookies; at most 16 entries each with values up to 1024 characters', 'Refused on Cloudflare until artifact lowering exists'], grants: [] },
|
|
171
|
+
redirect: { kind: 'handler', summary: 'HTTP redirect to a URL template with optional query passing or mapping.', schema: ['redirect'],
|
|
172
|
+
constraints: ['`url` required, at most 8192 characters', 'status one of 301, 302, 303, 307, 308', '`query.pass` is `false` or an explicit list; `query.map` maps from path, query or header inputs'], grants: [] },
|
|
173
|
+
respond: { kind: 'handler', summary: 'Static text or JSON reply with a status code.', schema: ['respond'],
|
|
174
|
+
constraints: ['status 200 to 599', '`text` at most 1 MiB; `text` and `json` are mutually exclusive'], grants: [] },
|
|
175
|
+
page: { kind: 'handler', summary: 'Serve one HTML file from the project.', schema: ['page'],
|
|
176
|
+
constraints: ['`file` required, project-relative, 1 to 1024 characters', 'Fixed `cacheControl` choices; `contentType` must be a media type', 'Refused on Cloudflare: assets need a static-asset binding'], grants: [] },
|
|
177
|
+
static: { kind: 'handler', summary: 'Serve a project directory as a static mount.', schema: ['static'],
|
|
178
|
+
constraints: ['`directory` required, project-relative, 1 to 1024 characters; `index` must be a .html name', 'Refused on Cloudflare: assets need a static-asset binding'], grants: [] },
|
|
179
|
+
download: { kind: 'handler', summary: 'Serve a project file as an attachment.', schema: ['download'],
|
|
180
|
+
constraints: ['`file` required, project-relative, 1 to 1024 characters; `filename` at most 255 characters', 'Refused on Cloudflare: assets need a static-asset binding'], grants: [] },
|
|
181
|
+
function: { kind: 'handler', summary: 'Sandboxed project function producing the reply.', schema: ['function'],
|
|
182
|
+
constraints: ['`source` at most 1024 characters, project-relative; `export` defaults to the default export', '`args` are literals, `{from: path|query|header}` inputs or `{env}` references', 'Self-hosted only: needs worker threads and the WASM engine; no network or filesystem in the guest'], grants: [] },
|
|
183
|
+
middleware: { kind: 'middleware', summary: 'Sandboxed modules run before the handler.', schema: ['middleware'],
|
|
184
|
+
constraints: ['At most 16 entries, each with a project-relative `source` and optional `export`', 'Self-hosted only: needs the sandbox'], grants: [] },
|
|
185
|
+
link: { kind: 'handler', summary: 'Live stored short link resolved from an operator collection.', schema: ['link', 'dynamicLinks'],
|
|
186
|
+
constraints: ['`collection` matches ^[A-Za-z][A-Za-z0-9_-]{0,63}$; `code` comes from a path parameter', 'Entry urlcode.yaml must set `dynamicLinks: true`', 'Self-hosted only: needs a durable writable store'],
|
|
187
|
+
grants: ['--link-store collection=/absolute/file binding owned by the operator'] },
|
|
188
|
+
dynamicLinks: { kind: 'project', summary: 'Entry-point opt-in for live stored-link routes.', schema: ['dynamicLinks'],
|
|
189
|
+
constraints: ['Entry urlcode.yaml only; ignored defaults to false', 'Does not enable guest storage or management access'], grants: ['--link-store binding when any link route is declared'] },
|
|
190
|
+
parameters: { kind: 'request', summary: 'Validated path, query and header inputs.', schema: ['parameters'],
|
|
191
|
+
constraints: ['Names match ^[A-Za-z_][A-Za-z0-9_-]*$ and `in` is path, query or header', 'Schema types: string, integer, number, boolean, array; length bounds up to 8192'], grants: [] },
|
|
192
|
+
methods: { kind: 'routing', summary: 'Allowed HTTP methods; defaults to GET and HEAD.', schema: ['methods'],
|
|
193
|
+
constraints: ['Unique subset of GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS with at least one entry'], grants: [] },
|
|
194
|
+
enabled: { kind: 'routing', summary: 'Route on/off switch; disabled routes are still validated.', schema: ['enabled'], constraints: ['Boolean; defaults to true'], grants: [] },
|
|
195
|
+
expires: { kind: 'routing', summary: 'Timestamp after which the route stops matching.', schema: ['expires'], constraints: ['UTC timestamp YYYY-MM-DDTHH:MM:SS[.mmm]Z; expired routes are still validated'], grants: [] },
|
|
196
|
+
'request.body': { kind: 'request', summary: 'Request body admission limits and format.', schema: ['request.body'],
|
|
197
|
+
constraints: ['`maxBytes` 0 to 1048576; up to 16 lowercase `contentTypes`', '`format` text or json'], grants: [] },
|
|
198
|
+
'response.headers': { kind: 'request', summary: 'Static response headers added to the reply.', schema: ['response.headers'],
|
|
199
|
+
constraints: ['At most 64 headers; values up to 4096 characters or lists of at most 16', 'Cloudflare coalesces duplicate headers'], grants: [] },
|
|
200
|
+
bindings: { kind: 'binding', summary: 'Route `env` literals/references and `secrets` references.', schema: ['env', 'secrets'],
|
|
201
|
+
constraints: ['Names match ^[A-Za-z_][A-Za-z0-9_]*$', '`env` entries are `{value}` literals or `{env}` references; `secrets` entries are `{secret}` references', 'Refused on Cloudflare: bindings would be baked into the artifact'],
|
|
202
|
+
grants: ['External operator policy (--policy) granting each referenced env/secret name; values never enter the project'] },
|
|
203
|
+
'policies.agents': policyDetail('agents', 'Agent allow/deny rules by bundled list name.', ['List names come from the bundled agent lists']),
|
|
204
|
+
'policies.security': policyDetail('security', 'Security response headers.', ['Fixed header set with validated values']),
|
|
205
|
+
'policies.cache': policyDetail('cache', 'Host cache strategy for route replies.', ['Refused on Cloudflare: no cache enforcement in the artifact']),
|
|
206
|
+
'policies.compression': policyDetail('compression', 'Response compression.', ['Delegated on AWS, Vercel and Cloudflare; exact settings are unverified']),
|
|
207
|
+
'policies.throttle': policyDetail('throttle', 'Per-instance request quota per window.', ['`quota` and `window` are required', 'Only `partition: route` is implemented on serverless targets; `client` is refused there; counters are per instance']),
|
|
208
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {readdirSync,readFileSync,statSync} from 'node:fs';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import {parse} from 'yaml';
|
|
4
|
+
import {capabilityDetails,capabilityNames,capabilityTargets,formatCapabilities,getCapabilities,routeCapabilities} from './capabilities.js';
|
|
5
|
+
|
|
6
|
+
import {ConfigError} from './errors.js';
|
|
7
|
+
import {getSchemaFragment} from './schema-query.js';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
const root=(...parts )=>fileURLToPath(new URL('../'+parts.join('/'),import.meta.url));
|
|
21
|
+
const object=(value ) =>value!==null&&typeof value==='object'&&!Array.isArray(value);
|
|
22
|
+
function yamlFiles(directory ) {
|
|
23
|
+
const out =[];
|
|
24
|
+
for(const entry of readdirSync(directory,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))) {
|
|
25
|
+
if(entry.name.startsWith('.')||entry.name==='node_modules')continue;
|
|
26
|
+
const file=directory+'/'+entry.name;
|
|
27
|
+
if(entry.isDirectory())out.push(...yamlFiles(file));else if(/\.ya?ml$/.test(entry.name))out.push(file);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/** Bundled examples are fixed package content; they are read as data and never validated or activated here. */
|
|
32
|
+
function usage(base ,files ,name ) {
|
|
33
|
+
const result =[];
|
|
34
|
+
for(const file of files) {
|
|
35
|
+
let document ;try{document=parse(readFileSync(file,'utf8'));}catch{continue;}
|
|
36
|
+
if(!object(document))continue;
|
|
37
|
+
const routes =[];
|
|
38
|
+
if(name==='dynamicLinks'&&document.dynamicLinks===true)routes.push('(project)');
|
|
39
|
+
if(name==='extension'&&object(document.extensions)&&Object.keys(document.extensions).length)routes.push('(project)');
|
|
40
|
+
for(const [path,route] of Object.entries(object(document.routes)?document.routes:{})) {
|
|
41
|
+
if(!object(route))continue;
|
|
42
|
+
try{if(routeCapabilities(route ,document ).includes(name))routes.push(path);}catch{/* unparsable example route: not a use */}
|
|
43
|
+
}
|
|
44
|
+
if(routes.length)result.push({file:file.slice(base.length+1),routes});
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
export function capabilityNameList() {return capabilityNames;}
|
|
49
|
+
/** One catalog entry with its schema fragments and bundled usage. No project, credentials or network are read. */
|
|
50
|
+
export function getCapability(name ) {
|
|
51
|
+
if(typeof name!=='string'||!(capabilityNames ).includes(name))throw new ConfigError('Unknown capability; valid names: '+capabilityNames.join(', '));
|
|
52
|
+
const capability=name ,detail=capabilityDetails[capability];
|
|
53
|
+
const catalog=getCapabilities();
|
|
54
|
+
const targets=Object.fromEntries(capabilityTargets.map(target=>[target,catalog.capabilities.find(row=>row.capability===capability) .targets[target] ])) ;
|
|
55
|
+
const refused=capabilityTargets.flatMap(target=>targets[target].support==='refused'?[{target,reason:targets[target].reason}]:[]);
|
|
56
|
+
const recipesRoot=root('recipes'),cookbookRoot=root('examples','cookbook');
|
|
57
|
+
const recipes=usage(recipesRoot,readdirSync(recipesRoot,{withFileTypes:true}).filter(entry=>entry.isDirectory()).map(entry=>recipesRoot+'/'+entry.name+'/urlcode.yaml').filter(file=>{try{return statSync(file).isFile();}catch{return false;}}),capability);
|
|
58
|
+
const cookbook=usage(cookbookRoot,yamlFiles(cookbookRoot),capability);
|
|
59
|
+
return {format:1,name:capability,...detail,schemaFragments:detail.schema.map(getSchemaFragment),targets,refused,recipes,cookbook};
|
|
60
|
+
}
|
|
61
|
+
export function formatCapability(entry ) {
|
|
62
|
+
const list=(items )=>items.length?items.map(item=>' - '+item):[' (none)'];
|
|
63
|
+
const usageLines=(items )=>items.length?items.map(item=>` - ${item.file}: ${item.routes.join(', ')}`):[' (none)'];
|
|
64
|
+
return [`${entry.name} (${entry.kind})`,entry.summary,'','Schema: '+entry.schema.join(', ')+' (urlcode schema <path>)',
|
|
65
|
+
...entry.schemaFragments.map(fragment=>` ${fragment.pointer}\n`+JSON.stringify(fragment.schema,null,2).split('\n').map(line=>' '+line).join('\n')),
|
|
66
|
+
'','Constraints:',...list(entry.constraints),'','Required grants:',...list(entry.grants),'','Targets:',
|
|
67
|
+
...capabilityTargets.map(target=>` ${target.padEnd(12)}${entry.targets[target].support.padEnd(12)}${entry.targets[target].reason}`),
|
|
68
|
+
'','Unsupported:',...(entry.refused.length?entry.refused.map(item=>` - ${item.target}: ${item.reason}`):[' (none)']),
|
|
69
|
+
'','Recipes:',...usageLines(entry.recipes),'','Cookbook routes:',...usageLines(entry.cookbook),
|
|
70
|
+
'','Provider deployments: unverified; see docs/CAPABILITIES.md.',''].join('\n');
|
|
71
|
+
}
|
|
72
|
+
export {formatCapabilities};
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import Ajv from 'ajv/dist/2020.js';
|
|
4
|
+
import {loadDocument,parseYaml} from './config.js';
|
|
5
|
+
import {applySite} from './site.js';
|
|
6
|
+
import {analyzeProjectCapabilities,capabilityTargets} from './capabilities.js';
|
|
7
|
+
|
|
8
|
+
import {readAuthoringFile,authoringPath} from './authoring-files.js';
|
|
9
|
+
import {assert} from './errors.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The metadata shape shared by recipes/NAME/recipe.yaml and examples/NAME/example.yaml
|
|
13
|
+
* (schemas/recipe.schema.json). The catalog entries are fixed package data: names
|
|
14
|
+
* come from a list in code, never from a directory scan, a registry or an argument.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
/** The fields the capability preflight determines; `npm run check` refuses hand-written values that differ. */
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
const schemaFile=fileURLToPath(new URL('../schemas/recipe.schema.json',import.meta.url));
|
|
30
|
+
|
|
31
|
+
let validator ;
|
|
32
|
+
async function validate() {
|
|
33
|
+
if(!validator){
|
|
34
|
+
const schema =JSON.parse(await readFile(schemaFile,'utf8'));
|
|
35
|
+
validator=new Ajv.default({allErrors:false,strict:true,strictRequired:false}).compile(schema ) ;
|
|
36
|
+
}
|
|
37
|
+
return validator;
|
|
38
|
+
}
|
|
39
|
+
export const metadataFiles={recipe:'recipe.yaml',example:'example.yaml'} ;
|
|
40
|
+
/** Reads and schema-validates one metadata file; the id must equal the directory name, and file lists stay authoring-safe paths. */
|
|
41
|
+
export async function readMetadata(root ,id ,file ) {
|
|
42
|
+
const text=(await readAuthoringFile(root,file,65536)).toString('utf8');
|
|
43
|
+
const value =parseYaml(text),valid=await validate();
|
|
44
|
+
const first=valid(value)?undefined:valid.errors?.[0];
|
|
45
|
+
assert(!first,`${id}/${file} does not match schemas/recipe.schema.json: ${first?.instancePath||'/'} ${first?.message??''}`);
|
|
46
|
+
const metadata=value ;
|
|
47
|
+
assert(metadata.id===id,`${id}/${file} declares id ${metadata.id}`);
|
|
48
|
+
for(const path of metadata.files){try{authoringPath(path);}catch{assert(false,`${id}/${file} lists ${path}, which is not an authoring-safe path`);}}
|
|
49
|
+
return metadata;
|
|
50
|
+
}
|
|
51
|
+
const strength ={compatible:0,conditional:1,unknown:2,refused:3};
|
|
52
|
+
/** Preflight only: loads the project, expands site routes and asks the capability analysis for every target. No bindings, code or activation. */
|
|
53
|
+
export async function deriveMetadata(root ) {
|
|
54
|
+
const loaded=await loadDocument(root);await applySite(loaded,{});
|
|
55
|
+
const capabilities=new Set ();
|
|
56
|
+
const targets={} ;
|
|
57
|
+
for(const target of capabilityTargets){
|
|
58
|
+
const report=analyzeProjectCapabilities(loaded,target);
|
|
59
|
+
let verdict ='compatible';
|
|
60
|
+
for(const issue of report.issues){const support=issue.support ;if(strength[support]>strength[verdict])verdict=support;}
|
|
61
|
+
targets[target]=verdict;
|
|
62
|
+
if(target==='self-hosted')for(const requirement of report.requirements)capabilities.add(requirement.capability);
|
|
63
|
+
}
|
|
64
|
+
return {capabilities:[...capabilities].sort(),targets,routes:Object.keys(loaded.routes).length};
|
|
65
|
+
}
|
|
66
|
+
/** Reports where hand-written derived fields disagree with the preflight; empty means consistent. */
|
|
67
|
+
export function derivedDifferences(metadata ,derived ) {
|
|
68
|
+
const problems =[];
|
|
69
|
+
const same=(a ,b )=>JSON.stringify(a)===JSON.stringify(b);
|
|
70
|
+
if(!same(metadata.capabilities??[],derived.capabilities))problems.push(`capabilities should be [${derived.capabilities.join(', ')}]`);
|
|
71
|
+
for(const target of capabilityTargets)if(metadata.targets?.[target]!==derived.targets[target])problems.push(`targets.${target} should be ${derived.targets[target]}`);
|
|
72
|
+
if(metadata.routes!==derived.routes)problems.push(`routes should be ${derived.routes}`);
|
|
73
|
+
return problems;
|
|
74
|
+
}
|
|
75
|
+
const stopWords=new Set(['a','an','and','the','with','for','to','of','in','on','that','my','i','want','need']);
|
|
76
|
+
export function searchTerms(text ) {
|
|
77
|
+
assert(typeof text==='string' && text.length<=256,'Search text must be at most 256 characters');
|
|
78
|
+
const words=text.toLowerCase().split(/[^a-z0-9.-]+/).filter(Boolean);
|
|
79
|
+
const terms=[...new Set(words.filter(word=>!stopWords.has(word)||words.length===1))];
|
|
80
|
+
assert(terms.length>0 && terms.length<=16,'Provide one to sixteen search words');
|
|
81
|
+
return terms;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Local search over id, description, tags and capabilities: every term must match
|
|
85
|
+
* somewhere; the score prefers whole-tag and id hits over description substrings.
|
|
86
|
+
* Ties keep catalog order, so the smallest entry wins when a caller sorts by size first.
|
|
87
|
+
*/
|
|
88
|
+
export function searchMetadata (entries ,text ,extraText =()=>[]) {
|
|
89
|
+
const terms=searchTerms(text),hits =[];
|
|
90
|
+
for(const entry of entries){
|
|
91
|
+
const tags=entry.tags.map(tag=>tag.toLowerCase()),capabilities=(entry.capabilities??[]).map(name=>name.toLowerCase());
|
|
92
|
+
const id=entry.id.toLowerCase(),description=entry.description.toLowerCase(),extra=extraText(entry).map(value=>value.toLowerCase());
|
|
93
|
+
let score=0;const matched =[];
|
|
94
|
+
for(const term of terms){
|
|
95
|
+
let best=0;
|
|
96
|
+
if(id===term || tags.includes(term) || capabilities.includes(term))best=4;
|
|
97
|
+
else if(id.includes(term) || tags.some(tag=>tag.includes(term)) || capabilities.some(name=>name.includes(term)))best=2;
|
|
98
|
+
else if(description.includes(term) || extra.some(value=>value.includes(term)))best=1;
|
|
99
|
+
if(best===0){score=0;break;}
|
|
100
|
+
score+=best;matched.push(term);
|
|
101
|
+
}
|
|
102
|
+
if(score>0)hits.push({entry,score,matched});
|
|
103
|
+
}
|
|
104
|
+
return hits.sort((a,b)=>b.score-a.score);
|
|
105
|
+
}
|