@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/function-worker.js
CHANGED
|
@@ -83,6 +83,7 @@ port.on('message', async ({id,source,name,request,context,maxBytes,timeoutMs,cha
|
|
|
83
83
|
if (output === undefined || Buffer.byteLength(output) > maxBytes * 6 + 65536) throw new Error('Output limit');
|
|
84
84
|
const value = JSON.parse(output) ; // trust boundary: guest JSON, checked below
|
|
85
85
|
if (!value || !Number.isInteger(value.status) || value.status < 200 || value.status > 599 || typeof value.body !== 'string' || !Array.isArray(value.headers) || value.headers.length > 256) throw new Error('Invalid response');
|
|
86
|
+
if (value.contentLength !== undefined && (!Number.isInteger(value.contentLength) || value.contentLength < 0)) throw new Error('Invalid response');
|
|
86
87
|
if (value.nativeBody) {
|
|
87
88
|
if (!native || value.status !== native.status || value.body !== '') throw new Error('Invalid native response');
|
|
88
89
|
// Preserve native status and metadata (validators, ranges, redirect Location).
|
|
@@ -100,6 +101,7 @@ port.on('message', async ({id,source,name,request,context,maxBytes,timeoutMs,cha
|
|
|
100
101
|
bytes += Buffer.byteLength(pair[0]) + Buffer.byteLength(pair[1]) + 4;
|
|
101
102
|
}
|
|
102
103
|
if (bytes > 16384) throw new Error('Header limit');
|
|
103
|
-
post({id,status:value.status,headers:value.headers,body,nativeBody:value.nativeBody === true
|
|
104
|
+
post({id,status:value.status,headers:value.headers,body,nativeBody:value.nativeBody === true,
|
|
105
|
+
...(typeof value.contentLength === 'number' ? {contentLength:value.contentLength} : {})});
|
|
104
106
|
} catch { post({id,error:true}); }
|
|
105
107
|
});
|
package/dist/functions.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Worker } from 'node:worker_threads';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import { collectSourcesFor, routeFunctions } from './function-sources.js';
|
|
4
|
+
|
|
5
5
|
import { assert, ConfigError, HttpError } from './errors.js';
|
|
6
6
|
|
|
7
7
|
|
|
@@ -9,10 +9,24 @@ import { assert, ConfigError, HttpError } from './errors.js';
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** A module entry point the sandbox must load: an absolute source file path
|
|
13
|
+
* (already resolved and validated, e.g. via `functionFile()`) plus which
|
|
14
|
+
* export of it needs to be reachable. The same shape a `FunctionDefinition`
|
|
15
|
+
* already uses for `function`/`middleware` routes. */
|
|
16
|
+
|
|
17
|
+
/** What one `SandboxPool.execute()` call invokes: the same `{source, export}`
|
|
18
|
+
* shape as a `SandboxEntry`, naming one of the pool's declared entries. */
|
|
19
|
+
|
|
20
|
+
/** `entry` runs last (the route's `function`/handler); `chain` runs first, in
|
|
21
|
+
* order, each with `(request, context, next)` — the same wrap/middleware
|
|
22
|
+
* semantics `__invokePipeline` already gives sandboxed routes. Neither is
|
|
23
|
+
* required: an empty invocation with a `native` reply just returns it. */
|
|
24
|
+
|
|
25
|
+
|
|
13
26
|
|
|
14
27
|
|
|
15
28
|
|
|
29
|
+
|
|
16
30
|
|
|
17
31
|
// The worker protocol. Only JSON-shaped data and byte buffers cross it.
|
|
18
32
|
|
|
@@ -26,27 +40,43 @@ import { assert, ConfigError, HttpError } from './errors.js';
|
|
|
26
40
|
|
|
27
41
|
|
|
28
42
|
|
|
29
|
-
|
|
43
|
+
|
|
30
44
|
|
|
31
45
|
|
|
32
46
|
|
|
33
47
|
|
|
34
48
|
|
|
35
|
-
|
|
36
|
-
|
|
49
|
+
// The generalized sandbox primitive: the pool of worker threads, each running
|
|
50
|
+
// the same function-worker.ts (QuickJS engine setup, dependency-closure module
|
|
51
|
+
// allowlisting, memory/stack limits, deadline enforcement via both the
|
|
52
|
+
// interrupt handler and outer worker termination, response-shape validation),
|
|
53
|
+
// driven by an explicit list of `{source, export}` entries rather than
|
|
54
|
+
// anything route/YAML-shaped. This is the ONE place that owns worker
|
|
55
|
+
// spawning/QuickJS setup/deadline enforcement; `FunctionPool` below is a thin
|
|
56
|
+
// route-shaped wrapper over it, not a second copy of the mechanics. Exported
|
|
57
|
+
// publicly (as `@jimhoyd/urlcode/sandbox`, see src/sandbox.ts) for an
|
|
58
|
+
// extension package that needs to run PROJECT code — a hook a project's own
|
|
59
|
+
// config names — through the same trusted/sandboxed dispatch selection route
|
|
60
|
+
// dispatch gets, when that hook declares `sandbox: true`
|
|
61
|
+
// (docs/EXTENSIONS.md#project-level-lifecycle-hooks). There is no "trusted"
|
|
62
|
+
// mode here: an extension wanting trusted execution just calls the project's
|
|
63
|
+
// function directly via `import()` (already possible via
|
|
64
|
+
// `ExtensionActivation.root`); this primitive is only ever the sandboxed path.
|
|
65
|
+
export class SandboxPool {
|
|
66
|
+
root ; entries ; preparedSnapshot ; snapshot ;
|
|
37
67
|
restarts ; restartTimers ; log ; timeoutMs ; maxBytes ;
|
|
38
68
|
modules ; size ; slots ; closed ;
|
|
39
|
-
constructor(
|
|
69
|
+
constructor(entries , { root, snapshot, workers = 2, timeoutMs = 5000, maxBytes = 1048576, log = () => {} } = {}) {
|
|
40
70
|
assert(Number.isInteger(workers) && workers >= 1 && workers <= 32, 'Workers must be 1–32');
|
|
41
71
|
assert(Number.isInteger(timeoutMs) && timeoutMs >= 10 && timeoutMs <= 60000, 'Function timeout must be 10–60000 ms');
|
|
42
72
|
assert(Number.isInteger(maxBytes) && maxBytes >= 1 && maxBytes <= 16777216, 'Response limit must be 1–16777216 bytes');
|
|
43
|
-
this.root = root; this.
|
|
73
|
+
this.root = root; this.entries = entries; this.preparedSnapshot = snapshot;
|
|
44
74
|
// Consecutive replacement attempts per slot; cleared by a completed invocation.
|
|
45
75
|
this.restarts = new Map(); this.restartTimers = new Set();
|
|
46
76
|
this.log = log;
|
|
47
77
|
this.timeoutMs = timeoutMs; this.maxBytes = maxBytes;
|
|
48
78
|
const modules = new Map ();
|
|
49
|
-
for (const definition of
|
|
79
|
+
for (const definition of entries) {
|
|
50
80
|
const { source, export: name } = definition;
|
|
51
81
|
let names = modules.get(source);
|
|
52
82
|
if (!names) { names = new Set(); modules.set(source, names); }
|
|
@@ -58,7 +88,7 @@ export class FunctionPool {
|
|
|
58
88
|
}
|
|
59
89
|
async start() {
|
|
60
90
|
let snapshot = this.preparedSnapshot;
|
|
61
|
-
if (!snapshot && this.size) { assert(this.root !== undefined, 'Function pool requires a project root'); snapshot = await
|
|
91
|
+
if (!snapshot && this.size) { assert(this.root !== undefined, 'Function pool requires a project root'); snapshot = await collectSourcesFor(this.entries,this.root); }
|
|
62
92
|
snapshot ??= {sources:{},dependencies:{},entries:[],names:new Map()};
|
|
63
93
|
this.snapshot = snapshot;
|
|
64
94
|
try { await Promise.all(Array.from({ length: this.size }, (_, i) => this.spawn(i))); }
|
|
@@ -138,11 +168,18 @@ export class FunctionPool {
|
|
|
138
168
|
timer.unref(); this.restartTimers.add(timer);
|
|
139
169
|
}
|
|
140
170
|
get healthy() { return !this.closed && this.slots.length === this.size && this.slots.every(slot => slot?.ready); }
|
|
141
|
-
|
|
171
|
+
/** `invocation.entry` runs last (chain-wrapped), `invocation.chain` first, in
|
|
172
|
+
* declared order — the same `__invokePipeline` wrap/middleware discipline
|
|
173
|
+
* (`next()` callable at most once) the sandboxed route path already
|
|
174
|
+
* enforces; there is no second dispatch mechanism for this. Every entry and
|
|
175
|
+
* chain source named here must already be one of this pool's declared
|
|
176
|
+
* `entries` (constructor), or the worker's own module allowlist denies it. */
|
|
177
|
+
execute(invocation , request , context , native ) {
|
|
142
178
|
const slot = this.slots.find(s => s?.ready && !s.pending);
|
|
143
179
|
const snapshot = this.snapshot;
|
|
144
180
|
if (this.closed || !slot || !snapshot) return Promise.reject(new HttpError(503, 'Function capacity unavailable'));
|
|
145
181
|
const id = randomUUID();
|
|
182
|
+
const { entry, chain = [] } = invocation;
|
|
146
183
|
return new Promise((resolve, reject) => {
|
|
147
184
|
const timer = setTimeout(() => {
|
|
148
185
|
slot.pending = null; slot.ready = false;
|
|
@@ -154,8 +191,8 @@ export class FunctionPool {
|
|
|
154
191
|
if (message.nativeBody && native) resolve({...message,body:native.body,...(native.contentLength === undefined ? {} : {contentLength:native.contentLength})});
|
|
155
192
|
else resolve(message);
|
|
156
193
|
}, reject };
|
|
157
|
-
const message = { id, source:
|
|
158
|
-
chain:
|
|
194
|
+
const message = { id, source: entry ? snapshot.names.get(entry.source) : undefined, name: entry?.export,
|
|
195
|
+
chain: chain.map(item => ({source:snapshot.names.get(item.source),name:item.export})),
|
|
159
196
|
native: native ? {status:native.status,headers:native.headers} : undefined,
|
|
160
197
|
request, context, maxBytes: this.maxBytes, timeoutMs:this.timeoutMs + 100 };
|
|
161
198
|
slot.worker.postMessage(message);
|
|
@@ -172,3 +209,37 @@ export class FunctionPool {
|
|
|
172
209
|
await Promise.all(this.slots.map(s => s?.worker.terminate()));
|
|
173
210
|
}
|
|
174
211
|
}
|
|
212
|
+
// Thin, route-shaped wrapper over `SandboxPool`: every existing internal
|
|
213
|
+
// caller (route-level `function`/`middleware` dispatch, `src/runtime.ts`)
|
|
214
|
+
// keeps working exactly as before, translating `FunctionRoute[]`/`FunctionRoute`
|
|
215
|
+
// into the generalized `{source, export}` entries/target shape at the edge,
|
|
216
|
+
// never duplicating worker spawning, module allowlisting or deadline
|
|
217
|
+
// enforcement — that all still lives in the wrapped `SandboxPool`. Composition
|
|
218
|
+
// rather than inheritance because `execute()`'s route-shaped and
|
|
219
|
+
// entries-shaped parameters are genuinely different, incompatible types; the
|
|
220
|
+
// fields/methods below the constructor exist only so the (unchanged) test
|
|
221
|
+
// suite and `src/runtime.ts` keep reaching the wrapped pool's own state
|
|
222
|
+
// exactly as they did before this file had two classes.
|
|
223
|
+
export class FunctionPool {
|
|
224
|
+
routes ; pool ;
|
|
225
|
+
constructor(routes , options = {}) {
|
|
226
|
+
this.routes = routes;
|
|
227
|
+
this.pool = new SandboxPool(routes.flatMap(routeFunctions), options);
|
|
228
|
+
}
|
|
229
|
+
async start() { await this.pool.start(); return this; }
|
|
230
|
+
execute(route , request , context , native ) {
|
|
231
|
+
return this.pool.execute({ entry: route.function, chain: route.middleware }, request, context, native);
|
|
232
|
+
}
|
|
233
|
+
scheduleRespawn(index ) { this.pool.scheduleRespawn(index); }
|
|
234
|
+
close() { return this.pool.close(); }
|
|
235
|
+
get healthy() { return this.pool.healthy; }
|
|
236
|
+
get restarts() { return this.pool.restarts; }
|
|
237
|
+
get restartTimers() { return this.pool.restartTimers; }
|
|
238
|
+
get slots() { return this.pool.slots; }
|
|
239
|
+
get size() { return this.pool.size; }
|
|
240
|
+
get closed() { return this.pool.closed; }
|
|
241
|
+
get snapshot() { return this.pool.snapshot; }
|
|
242
|
+
get log() { return this.pool.log; }
|
|
243
|
+
get timeoutMs() { return this.pool.timeoutMs; }
|
|
244
|
+
get maxBytes() { return this.pool.maxBytes; }
|
|
245
|
+
}
|
package/dist/guest-api.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
/** The request the guest receives (stringified as JSON in the worker). */
|
|
6
6
|
|
|
7
7
|
/** What the guest returns as JSON text; the worker enforces this shape before trusting it. */
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
// Runs only inside QuickJS/WASM. No native host functions or objects are exposed.
|
|
10
10
|
// This is the documented text/JSON subset, not a complete Fetch implementation.
|
|
11
11
|
export const guestBootstrap = String.raw`
|
|
@@ -13,6 +13,24 @@ export const guestBootstrap = String.raw`
|
|
|
13
13
|
const NativeJSON = JSON;
|
|
14
14
|
const stringify = JSON.stringify.bind(JSON);
|
|
15
15
|
const now = Date.now.bind(Date);
|
|
16
|
+
// The guest has no TextEncoder/Buffer; this counts the UTF-8 bytes a
|
|
17
|
+
// Response's text would occupy on the wire (matching Buffer.byteLength on
|
|
18
|
+
// the host side, including its handling of lone surrogates as U+FFFD),
|
|
19
|
+
// without pulling any Node capability into the sandbox (#144).
|
|
20
|
+
function byteLength(text) {
|
|
21
|
+
let bytes = 0;
|
|
22
|
+
for (let i = 0; i < text.length; i++) {
|
|
23
|
+
const code = text.charCodeAt(i);
|
|
24
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
25
|
+
const next = text.charCodeAt(i + 1);
|
|
26
|
+
if (next >= 0xdc00 && next <= 0xdfff) { bytes += 4; i++; continue; }
|
|
27
|
+
bytes += 3; continue;
|
|
28
|
+
}
|
|
29
|
+
if (code >= 0xdc00 && code <= 0xdfff) { bytes += 3; continue; }
|
|
30
|
+
bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : 3;
|
|
31
|
+
}
|
|
32
|
+
return bytes;
|
|
33
|
+
}
|
|
16
34
|
const timers = new Map(); let next = 1;
|
|
17
35
|
globalThis.setTimeout = (fn, delay = 0) => {
|
|
18
36
|
if (typeof fn !== 'function' || timers.size >= 128) throw new Error('Timer limit');
|
|
@@ -94,8 +112,14 @@ export const guestBootstrap = String.raw`
|
|
|
94
112
|
const response = await dispatch(0);
|
|
95
113
|
if (violated || !(response instanceof Response)) throw new TypeError('Invalid middleware response');
|
|
96
114
|
const nativeBody = response === nativeResponse;
|
|
115
|
+
const isHead = input.request.method === 'HEAD';
|
|
116
|
+
// The real length is already known "for free": _text is a fully
|
|
117
|
+
// materialized string at construction time, no stream read needed
|
|
118
|
+
// (#144, mirroring #139's fix for the trusted path). Only the
|
|
119
|
+
// transmitted bytes are suppressed for HEAD, never the length.
|
|
97
120
|
globalThis.__output = stringify({status:response.status,headers:response.headers._pairs,
|
|
98
|
-
body:nativeBody ||
|
|
121
|
+
body:nativeBody || isHead ? '' : response._text,nativeBody,
|
|
122
|
+
...(isHead && !nativeBody ? {contentLength:byteLength(response._text)} : {})});
|
|
99
123
|
globalThis.__state = 'done';
|
|
100
124
|
} catch { globalThis.__state = 'failed'; }
|
|
101
125
|
};
|
|
@@ -106,7 +130,9 @@ export const guestBootstrap = String.raw`
|
|
|
106
130
|
const response = await handler(new Request(input.request.url, input.request), input.context);
|
|
107
131
|
if (!(response instanceof Response)) throw new TypeError('Return a Response');
|
|
108
132
|
const headers = response.headers._pairs;
|
|
109
|
-
|
|
133
|
+
const isHead = input.request.method === 'HEAD';
|
|
134
|
+
globalThis.__output = stringify({status:response.status,headers,body: isHead ? '' : response._text,
|
|
135
|
+
...(isHead ? {contentLength:byteLength(response._text)} : {})});
|
|
110
136
|
globalThis.__state = 'done';
|
|
111
137
|
} catch { globalThis.__state = 'failed'; }
|
|
112
138
|
};
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,45 @@
|
|
|
1
1
|
export { createRuntime } from './runtime.js';
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
export { startServer } from './server.js';
|
|
4
4
|
|
|
5
5
|
export { loadDocument, validateDocument, parseYaml } from './config.js';
|
|
6
|
-
export {openLinkStore} from './link-store.js';
|
|
7
|
-
|
|
8
|
-
export {startLinkApi} from './link-api.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
6
|
export { events as observabilityEvents, validateObservers, createObserverSink, createMetrics, renderPrometheus } from './observability.js';
|
|
7
|
+
|
|
8
|
+
export { getCapabilities, routeCapabilities, analyzeProjectCapabilities, analyzeCompiledCapabilities, assertTargetCompatibility, normalizeCapabilityTarget } from './capabilities.js';
|
|
9
|
+
export { capabilityDetails } from './capabilities.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export { importRoutes, exportRoutes } from './interchange.js';
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
export {listRecipes, searchRecipes, showRecipe, addRecipe} from './recipes.js';
|
|
16
|
+
|
|
17
|
+
export {listExamples, searchExamples} from './examples.js';
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
export {buildTypeScriptProject} from './typescript-authoring.js';
|
|
21
|
+
|
|
22
|
+
export {importBulkProject} from './bulk.js';
|
|
23
|
+
|
|
24
|
+
export {inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens} from './tooling.js';
|
|
25
|
+
|
|
26
|
+
export {buildManifest, renderManifest, MANIFEST_SCHEMA_VERSION} from './manifest.js';
|
|
27
|
+
|
|
28
|
+
export {serveMcp} from './mcp.js';
|
|
29
|
+
|
|
30
|
+
export {providerConformanceCases, runProviderConformance, verifyProviderDeployment} from './provider-verification.js';
|
|
31
|
+
|
|
32
|
+
export {normalizeMatch, assertDisjointMatches, matchesRoute} from './conditions.js';
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export {buildCloudflare} from './build-cloudflare.js';
|
|
36
|
+
|
|
37
|
+
export {buildStatic} from './build-static.js';
|
|
38
|
+
|
|
39
|
+
export {runProjectTests} from './project-tests.js';
|
|
40
|
+
|
|
41
|
+
export {scaffoldProject} from './scaffold.js';
|
|
42
|
+
|
|
43
|
+
export {initProject, addRedirect} from './authoring.js';
|
|
44
|
+
export {initProjectWith} from './init-with.js';
|
|
45
|
+
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { mkdir, open, readFile, rm, unlink, lstat } from 'node:fs/promises';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { parseDocument, stringify } from 'yaml';
|
|
6
|
+
import { initProject } from './authoring.js';
|
|
7
|
+
import { mcpConfigFile, renderMcpConfig } from './agents-guide.js';
|
|
8
|
+
import { loadDocument, parseYaml, validateDocument } from './config.js';
|
|
9
|
+
import { inspectExtensionRevision } from './extensions.js';
|
|
10
|
+
|
|
11
|
+
import { ConfigError, assert } from './errors.js';
|
|
12
|
+
|
|
13
|
+
/** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
|
|
14
|
+
export const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
|
|
15
|
+
const namePattern = /^[a-z][a-z0-9-]{0,63}$/;
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
export function parseWithNames(value ) {
|
|
20
|
+
const names = value.split(',').map(name => name.trim());
|
|
21
|
+
assert(names.length > 0 && names.every(name => namePattern.test(name)), 'Use --with name[,name] where each name is a lowercase extension package suffix such as auth');
|
|
22
|
+
assert(new Set(names).size === names.length, 'Duplicate --with names');
|
|
23
|
+
return names;
|
|
24
|
+
}
|
|
25
|
+
export const packageName = (name ) => `@jimhoyd/urlcode-${name}`;
|
|
26
|
+
const isCode = (error , code ) => error instanceof Error && 'code' in error && error.code === code;
|
|
27
|
+
const strings = (value ) => Array.isArray(value) && value.every(item => typeof item === 'string');
|
|
28
|
+
const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolves the extension package from the invoking directory (Node's package resolution with the default
|
|
32
|
+
* conditions), imports it, and calls its `scaffold` export. Nothing is bundled; core never imports these packages
|
|
33
|
+
* at build time. Refuses a missing package or a package without `scaffold` before anything is written.
|
|
34
|
+
*/
|
|
35
|
+
export async function loadScaffold(name , request , cwd ) {
|
|
36
|
+
const pkg = packageName(name);
|
|
37
|
+
let entry ;
|
|
38
|
+
try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (isCode(error, 'MODULE_NOT_FOUND')) throw new ConfigError(`Extension package ${pkg} is not installed in ${cwd}; run: npm install ${pkg}`);
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
const module = await import(pathToFileURL(entry).href) ;
|
|
44
|
+
const scaffold = module.scaffold;
|
|
45
|
+
if (typeof scaffold !== 'function') throw new ConfigError(`${pkg} does not export scaffold; upgrade it to a release that supports urlcode init --with, or add ${name} by hand following its README`);
|
|
46
|
+
let result ;
|
|
47
|
+
try { result = await (scaffold )(request); }
|
|
48
|
+
catch (error) { throw new ConfigError(`${pkg} scaffold refused: ${error instanceof Error ? error.message : String(error)}`); }
|
|
49
|
+
assert(record(result) && result.name === name, `${pkg} scaffold must return a result named ${name}`);
|
|
50
|
+
assert(record(result.extensions) && record(result.routes), `${pkg} scaffold must return extensions and routes objects`);
|
|
51
|
+
assert(strings(result.hostImports) && strings(result.hostSetup) && strings(result.hostEntries) && (result.hostClose === undefined || strings(result.hostClose)), `${pkg} scaffold must return host fragments as string arrays`);
|
|
52
|
+
assert(strings(result.nextSteps) && typeof result.readme === 'string', `${pkg} scaffold must return readme text and nextSteps strings`);
|
|
53
|
+
assert(result.env === undefined || (record(result.env) && Object.values(result.env).every(item => typeof item === 'string')), `${pkg} scaffold env must map names to descriptions`);
|
|
54
|
+
assert(Array.isArray(result.files) && result.files.every((file ) => record(file) && typeof file.path === 'string' && (typeof file.content === 'string' || file.content instanceof Uint8Array) && (file.mode === undefined || (Number.isInteger(file.mode) && (file.mode ) >= 0 && (file.mode ) <= 0o777))), `${pkg} scaffold files must carry a path, content and an optional mode`);
|
|
55
|
+
return result ;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function filePath(root , path ) {
|
|
59
|
+
assert(typeof path === 'string' && path.length > 0 && path.length <= 1024 && !path.includes('\0'), 'Invalid scaffold file path');
|
|
60
|
+
const target = resolve(root, path), rel = relative(root, target);
|
|
61
|
+
assert(!path.startsWith('/') && rel === path.split('/').join(sep) && rel.length > 0 && !rel.startsWith('..'), `Scaffold file path must stay inside the site directory: ${path}`);
|
|
62
|
+
assert(rel !== PROJECT_DIRECTORY && !rel.startsWith(PROJECT_DIRECTORY + sep), `Scaffold files must stay outside the route project: ${path}`);
|
|
63
|
+
return target;
|
|
64
|
+
}
|
|
65
|
+
/** Creates the file exclusively: nothing generated is ever overwritten. */
|
|
66
|
+
async function write(target , content , mode = 0o644) {
|
|
67
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
68
|
+
const file = await open(target, 'wx', mode);
|
|
69
|
+
try { await file.writeFile(content); await file.sync(); } finally { await file.close(); }
|
|
70
|
+
}
|
|
71
|
+
export function renderHost(names , results ) {
|
|
72
|
+
const lines = [`// Generated by urlcode init --with ${names.join(',')}. Trusted operator code: keep it outside ${PROJECT_DIRECTORY}/ and review before serving.`];
|
|
73
|
+
for (const result of results) lines.push(...result.hostImports);
|
|
74
|
+
lines.push('');
|
|
75
|
+
for (const result of results) if (result.hostSetup.length) lines.push(...result.hostSetup);
|
|
76
|
+
lines.push('export default {', ' extensions: [');
|
|
77
|
+
for (const result of results) for (const entry of result.hostEntries) lines.push(` ${entry},`);
|
|
78
|
+
lines.push(' ],', ' async close() {');
|
|
79
|
+
// Later extensions may depend on earlier setup, so release in reverse order.
|
|
80
|
+
for (const result of [...results].reverse()) for (const statement of result.hostClose ?? []) lines.push(` ${statement}`);
|
|
81
|
+
lines.push(' },', '};');
|
|
82
|
+
return lines.join('\n') + '\n';
|
|
83
|
+
}
|
|
84
|
+
function demote(markdown ) {
|
|
85
|
+
let fence = false;
|
|
86
|
+
return markdown.split('\n').map(line => { if (/^\s*(?:```|~~~)/.test(line)) fence = !fence; return !fence && /^#{1,5} /.test(line) ? `#${line}` : line; }).join('\n');
|
|
87
|
+
}
|
|
88
|
+
export function renderReadme(directory , names , results , starter , env , projectSha256 ) {
|
|
89
|
+
const steps = results.flatMap(result => result.nextSteps);
|
|
90
|
+
const parts = [`# ${basename(directory)}`, '',
|
|
91
|
+
`Created with \`urlcode init ${basename(directory)} --with ${names.join(',')}\`. \`${PROJECT_DIRECTORY}/\` is the route project (\`urlcode.yaml\`, functions, tests); \`${HOST_FILE}\` is the trusted operator host that wires the installed extension packages; operator modules and private data stay outside the project. Run every command with \`--project ${PROJECT_DIRECTORY} --host-file "$PWD/${HOST_FILE}"\`.`, '',
|
|
92
|
+
'## Starter', '', `The starter files live in \`${PROJECT_DIRECTORY}/\`; add \`--project ${PROJECT_DIRECTORY}\` and the host file to the commands below.`, '', demote(starter).trim(), ''];
|
|
93
|
+
for (const result of results) parts.push(`## Extension: ${result.name}`, '', result.readme.trim(), '');
|
|
94
|
+
parts.push('## Next steps', '', ...steps.map((step, index) => `${index + 1}. ${step}`), '');
|
|
95
|
+
if (Object.keys(env).length) parts.push('## Environment', '', ...Object.entries(env).map(([key, text]) => `- \`${key}\`: ${text}`), '');
|
|
96
|
+
parts.push('## Project revision', '', `\`${PROJECT_DIRECTORY}/urlcode.yaml\` currently has revision \`${projectSha256}\` (\`inspectExtensionRevision\`). Review the project, then pin exactly that value where the host expects it; any change to extension YAML, policies or mounts changes it and needs a new explicit review.`, '');
|
|
97
|
+
return parts.join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `urlcode init <directory> --with a,b`: the starter under `app/`, every extension's fragments merged into one
|
|
102
|
+
* `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
|
|
103
|
+
* their scaffolds computed before anything is written, so a refusal leaves no directory behind.
|
|
104
|
+
*/
|
|
105
|
+
export async function initProjectWith(destination , names , { cwd = process.cwd() } = {}) {
|
|
106
|
+
assert(names.length > 0, 'Provide at least one --with name');
|
|
107
|
+
const directory = resolve(destination), project = join(directory, PROJECT_DIRECTORY), hostFile = join(directory, HOST_FILE);
|
|
108
|
+
const request = { directory, project, hostFile, names };
|
|
109
|
+
const results = [];
|
|
110
|
+
const wipe = () => { for (const result of results) for (const file of result.files) if (file.content instanceof Uint8Array) file.content.fill(0); };
|
|
111
|
+
try {
|
|
112
|
+
for (const name of names) results.push(await loadScaffold(name, request, cwd));
|
|
113
|
+
// Cross-result conflicts are refused before the destination exists.
|
|
114
|
+
const extensions = Object.create(null) , routes = Object.create(null) , env = {};
|
|
115
|
+
const owners = new Map ();
|
|
116
|
+
for (const result of results) {
|
|
117
|
+
for (const [key, value] of Object.entries(result.extensions)) { assert(!Object.hasOwn(extensions, key), `Extension ${key} is declared by both ${owners.get('e:' + key)} and ${result.name}`); owners.set('e:' + key, result.name); extensions[key] = value; }
|
|
118
|
+
for (const [key, value] of Object.entries(result.routes)) { assert(!Object.hasOwn(routes, key), `Route ${key} is added by both ${owners.get('r:' + key)} and ${result.name}`); owners.set('r:' + key, result.name); routes[key] = value; }
|
|
119
|
+
for (const [key, value] of Object.entries(result.env ?? {})) { assert(!Object.hasOwn(env, key) || env[key] === value, `Environment variable ${key} is described differently by ${result.name}`); env[key] = value; }
|
|
120
|
+
const seen = new Set ();
|
|
121
|
+
for (const file of result.files) { const path = filePath(directory, file.path); assert(!seen.has(path), `${result.name} scaffolds ${file.path} twice`); seen.add(path); }
|
|
122
|
+
}
|
|
123
|
+
await mkdir(dirname(directory), { recursive: true });
|
|
124
|
+
await mkdir(directory, { mode: 0o700 }); // refuses an existing destination
|
|
125
|
+
try {
|
|
126
|
+
await initProject(project);
|
|
127
|
+
const starter = await readFile(join(project, 'README.md'), 'utf8');
|
|
128
|
+
await unlink(join(project, 'README.md')); // its content moves into the site README
|
|
129
|
+
await unlink(join(project, mcpConfigFile)); // re-registered at the site root, pointing at app/
|
|
130
|
+
// Refuse routes or extensions the starter already declares, including in its included files.
|
|
131
|
+
const loaded = await loadDocument(project);
|
|
132
|
+
for (const key of Object.keys(routes)) assert(!Object.hasOwn(loaded.routes, key), `Route ${key} from ${owners.get('r:' + key)} already exists in the starter`);
|
|
133
|
+
for (const key of Object.keys(extensions)) assert(!Object.hasOwn(loaded.document.extensions ?? {}, key), `Extension ${key} from ${owners.get('e:' + key)} already exists in the starter`);
|
|
134
|
+
const yamlFile = join(project, 'urlcode.yaml'), original = await readFile(yamlFile, 'utf8');
|
|
135
|
+
const doc = parseDocument(original);
|
|
136
|
+
// Extensions are declared in the entry file; their routes go into a last include so the starter's own routes
|
|
137
|
+
// stay first in the loaded order and the entry file stays small.
|
|
138
|
+
doc.set('extensions', { ...loaded.document.extensions, ...extensions });
|
|
139
|
+
doc.addIn(['includes'], ROUTES_FILE);
|
|
140
|
+
const fragment = stringify({ version: '1', routes });
|
|
141
|
+
validateDocument(doc.toJS()); validateDocument(parseYaml(fragment));
|
|
142
|
+
await write(join(project, ROUTES_FILE), `# Routes added by urlcode init --with ${names.join(',')}. Mounts are exclusive to the named extension.\n${fragment}`);
|
|
143
|
+
await rm(yamlFile); await write(yamlFile, String(doc));
|
|
144
|
+
await loadDocument(project);
|
|
145
|
+
const projectSha256 = await inspectExtensionRevision(project);
|
|
146
|
+
const written = new Set ();
|
|
147
|
+
for (const result of results) for (const file of result.files) {
|
|
148
|
+
const target = filePath(directory, file.path);
|
|
149
|
+
assert(!written.has(target), `Scaffold file ${file.path} is written by more than one extension`);
|
|
150
|
+
// Parent directories are created; existing files or symlinks anywhere on the path are refused.
|
|
151
|
+
let probe = dirname(target);
|
|
152
|
+
while (probe !== directory && probe.startsWith(directory)) { try { assert(!(await lstat(probe)).isSymbolicLink(), `Scaffold path passes through a symlink: ${file.path}`); } catch (error) { if (!isCode(error, 'ENOENT')) throw error; } probe = dirname(probe); }
|
|
153
|
+
await write(target, file.content, file.mode ?? 0o644); written.add(target);
|
|
154
|
+
}
|
|
155
|
+
await write(hostFile, renderHost(names, results), 0o600);
|
|
156
|
+
await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256));
|
|
157
|
+
await write(join(directory, '.gitignore'), 'node_modules/\ndata/\n.env\n.env.*\n');
|
|
158
|
+
// The read-only MCP server for agents opened at the site root; --host-file and --allow-authoring stay operator choices.
|
|
159
|
+
await write(join(directory, mcpConfigFile), renderMcpConfig(PROJECT_DIRECTORY));
|
|
160
|
+
// AGENTS.md: initProject writes the application-level file into app/ once it produces one (NEXT-STEPS 1.1);
|
|
161
|
+
// nothing here overrides it. A site-level agent note would be assembled beside README.md at this point.
|
|
162
|
+
return { directory, project, hostFile, extensions: [...names], projectSha256, nextSteps: results.flatMap(result => result.nextSteps) };
|
|
163
|
+
} catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
|
|
164
|
+
} finally { wipe(); }
|
|
165
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { open, unlink } from 'node:fs/promises';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { loadDocument } from './config.js';
|
|
4
|
+
import { importRoutes, exportRoutes } from './interchange.js';
|
|
5
|
+
|
|
6
|
+
import { assert } from './errors.js';
|
|
7
|
+
|
|
8
|
+
export async function readConversionInput(file ) {
|
|
9
|
+
const handle=await open(file,'r');
|
|
10
|
+
try {
|
|
11
|
+
const stat=await handle.stat();assert(stat.isFile()&&stat.size<=32*1024*1024,'Conversion input must be a regular file of at most 32 MiB');
|
|
12
|
+
const buffer=Buffer.alloc(stat.size+1);let size=0;
|
|
13
|
+
while(size<buffer.length){const result=await handle.read(buffer,size,buffer.length-size,null);if(!result.bytesRead)break;size+=result.bytesRead;}
|
|
14
|
+
assert(size<=stat.size,'Conversion source grew during read');
|
|
15
|
+
return new TextDecoder('utf-8',{fatal:true}).decode(buffer.subarray(0,size));
|
|
16
|
+
} finally {await handle.close();}
|
|
17
|
+
}
|
|
18
|
+
export async function runInterchange(command ,args ,options ) {
|
|
19
|
+
assert(!options.report||options.report==='json','Use --report json');
|
|
20
|
+
const explicit=args.length===2?args[0]:undefined;
|
|
21
|
+
const file=args.length===2?args[1]:args[0];
|
|
22
|
+
assert(command==='import'?!!file&&args.length<=2:args.length===0,'Invalid conversion arguments');
|
|
23
|
+
const format=(options.format??(command==='export'?options.target:explicit??extname(file ).slice(1))) ;
|
|
24
|
+
let report ;
|
|
25
|
+
if(command==='import') report=await importRoutes({format,text:await readConversionInput(file ),source:file ,acceptProviderDifferences:options.acceptProviderDifferences===true});
|
|
26
|
+
else {
|
|
27
|
+
const loaded=await loadDocument(options.project);
|
|
28
|
+
const {includes:_includes,...document}=loaded.document;
|
|
29
|
+
report=await exportRoutes({format,document:{...document,routes:loaded.routes},source:options.project,acceptProviderDifferences:options.acceptProviderDifferences===true});
|
|
30
|
+
}
|
|
31
|
+
if(report.ok&&options.out&&!options.dryRun) {
|
|
32
|
+
const handle=await open(options.out,'wx',0o600);
|
|
33
|
+
try {await handle.writeFile(report.output );await handle.sync();}
|
|
34
|
+
catch(error){await handle.close();await unlink(options.out);throw error;}
|
|
35
|
+
await handle.close();
|
|
36
|
+
}
|
|
37
|
+
// Acknowledged non-lossless conversion always carries a report, even when
|
|
38
|
+
// output is written to a file; warnings cannot disappear into raw stdout.
|
|
39
|
+
const text=options.report==='json'||options.dryRun||options.out||!report.ok||!report.lossless
|
|
40
|
+
? JSON.stringify(report)+'\n' : report.output ;
|
|
41
|
+
return {report,text};
|
|
42
|
+
}
|