@jimhoyd/urlcode 0.3.0
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/CONTRIBUTING.md +80 -0
- package/LICENSE +202 -0
- package/NOTICE +20 -0
- package/README.md +342 -0
- package/ROADMAP.md +248 -0
- package/SECURITY.md +40 -0
- package/data/agents/LICENSES/ai-robots-txt.txt +24 -0
- package/data/agents/LICENSES/crawler-user-agents.txt +23 -0
- package/data/agents/ai-crawlers.json +1240 -0
- package/data/agents/crawlers.json +10515 -0
- package/data/agents/index.d.ts +3 -0
- package/data/agents/index.js +2453 -0
- package/data/agents/monitoring.json +1520 -0
- package/data/agents/seo.json +3865 -0
- package/dist/BUILD-MANIFEST.json +60 -0
- package/dist/adapters.js +59 -0
- package/dist/agent-lists.js +59 -0
- package/dist/assets.js +129 -0
- package/dist/authoring.js +68 -0
- package/dist/aws.js +110 -0
- package/dist/build-cloudflare.js +164 -0
- package/dist/cli.js +185 -0
- package/dist/client-address.js +85 -0
- package/dist/cloudflare.js +165 -0
- package/dist/compliance-rules/baseline.js +98 -0
- package/dist/compliance-rules/privacy.js +38 -0
- package/dist/compliance-rules/shared.js +62 -0
- package/dist/compliance-rules/strict.js +61 -0
- package/dist/compliance.js +219 -0
- package/dist/config-worker.js +9 -0
- package/dist/config.js +172 -0
- package/dist/errors.js +8 -0
- package/dist/function-sources.js +54 -0
- package/dist/function-worker.js +105 -0
- package/dist/functions.js +174 -0
- package/dist/guest-api.js +114 -0
- package/dist/header-validation.js +18 -0
- package/dist/http-policy.js +74 -0
- package/dist/http-response.js +84 -0
- package/dist/index.js +11 -0
- package/dist/link-api.js +136 -0
- package/dist/link-cli.js +141 -0
- package/dist/link-events.js +76 -0
- package/dist/link-records.js +31 -0
- package/dist/link-store-worker.js +150 -0
- package/dist/link-store.js +250 -0
- package/dist/logging.js +22 -0
- package/dist/management-policy.js +41 -0
- package/dist/match.js +124 -0
- package/dist/observability.js +242 -0
- package/dist/plugins.js +74 -0
- package/dist/policies/agents.js +248 -0
- package/dist/policies/cache.js +297 -0
- package/dist/policies/compression.js +187 -0
- package/dist/policies/security.js +0 -0
- package/dist/policies/throttle.js +131 -0
- package/dist/policies.js +142 -0
- package/dist/policy.js +55 -0
- package/dist/prerender.js +165 -0
- package/dist/project-tests.js +23 -0
- package/dist/readiness.js +216 -0
- package/dist/router.js +166 -0
- package/dist/runtime.js +244 -0
- package/dist/scaffold.js +0 -0
- package/dist/scripts/operational-drills.js +75 -0
- package/dist/server.js +270 -0
- package/dist/site.js +0 -0
- package/dist/sqlite-version.js +6 -0
- package/dist/types/adapters.d.ts +12 -0
- package/dist/types/agent-lists.d.ts +5 -0
- package/dist/types/assets.d.ts +13 -0
- package/dist/types/authoring.d.ts +2 -0
- package/dist/types/aws.d.ts +35 -0
- package/dist/types/build-cloudflare.d.ts +14 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/client-address.d.ts +9 -0
- package/dist/types/cloudflare.d.ts +75 -0
- package/dist/types/compliance-rules/baseline.d.ts +12 -0
- package/dist/types/compliance-rules/privacy.d.ts +6 -0
- package/dist/types/compliance-rules/shared.d.ts +24 -0
- package/dist/types/compliance-rules/strict.d.ts +9 -0
- package/dist/types/compliance.d.ts +119 -0
- package/dist/types/config-worker.d.ts +1 -0
- package/dist/types/config.d.ts +21 -0
- package/dist/types/errors.d.ts +7 -0
- package/dist/types/function-sources.d.ts +21 -0
- package/dist/types/function-worker.d.ts +1 -0
- package/dist/types/functions.d.ts +92 -0
- package/dist/types/guest-api.d.ts +16 -0
- package/dist/types/header-validation.d.ts +2 -0
- package/dist/types/http-policy.d.ts +39 -0
- package/dist/types/http-response.d.ts +43 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/link-api.d.ts +30 -0
- package/dist/types/link-cli.d.ts +36 -0
- package/dist/types/link-events.d.ts +27 -0
- package/dist/types/link-records.d.ts +11 -0
- package/dist/types/link-store-worker.d.ts +1 -0
- package/dist/types/link-store.d.ts +130 -0
- package/dist/types/logging.d.ts +9 -0
- package/dist/types/management-policy.d.ts +9 -0
- package/dist/types/match.d.ts +81 -0
- package/dist/types/observability.d.ts +104 -0
- package/dist/types/plugins.d.ts +27 -0
- package/dist/types/policies/agents.d.ts +86 -0
- package/dist/types/policies/cache.d.ts +82 -0
- package/dist/types/policies/compression.d.ts +41 -0
- package/dist/types/policies/security.d.ts +37 -0
- package/dist/types/policies/throttle.d.ts +55 -0
- package/dist/types/policies.d.ts +36 -0
- package/dist/types/policy.d.ts +21 -0
- package/dist/types/prerender.d.ts +31 -0
- package/dist/types/project-tests.d.ts +13 -0
- package/dist/types/readiness.d.ts +121 -0
- package/dist/types/router.d.ts +11 -0
- package/dist/types/runtime.d.ts +91 -0
- package/dist/types/scaffold.d.ts +17 -0
- package/dist/types/server.d.ts +37 -0
- package/dist/types/site.d.ts +12 -0
- package/dist/types/sqlite-version.d.ts +1 -0
- package/dist/types/types.d.ts +332 -0
- package/dist/types/vercel.d.ts +12 -0
- package/dist/types.js +177 -0
- package/dist/vercel.js +65 -0
- package/docs/AI-AUTHORING.md +113 -0
- package/docs/ASSETS.md +106 -0
- package/docs/AWS.md +77 -0
- package/docs/BEST-PRACTICES.md +267 -0
- package/docs/CAPACITY.md +208 -0
- package/docs/CLOUDFLARE.md +110 -0
- package/docs/COMPLIANCE.md +242 -0
- package/docs/DYNAMIC-LINKS.md +561 -0
- package/docs/FUNCTION-SECURITY.md +113 -0
- package/docs/HTTP.md +129 -0
- package/docs/INSTALL.md +90 -0
- package/docs/LOAD-TESTING.md +91 -0
- package/docs/LOCAL-DEVELOPMENT.md +99 -0
- package/docs/MANAGEMENT-SECURITY.md +82 -0
- package/docs/MIDDLEWARE.md +83 -0
- package/docs/MONITORING.md +132 -0
- package/docs/OBSERVABILITY.md +229 -0
- package/docs/OPERATIONAL-PROOF.md +42 -0
- package/docs/OPERATIONS.md +207 -0
- package/docs/ORGANIZATION.md +135 -0
- package/docs/PERFORMANCE.md +72 -0
- package/docs/PLUGINS.md +234 -0
- package/docs/POLICIES.md +489 -0
- package/docs/PRERENDER.md +193 -0
- package/docs/PROJECT-DIRECTION.md +51 -0
- package/docs/READINESS.md +125 -0
- package/docs/README.md +58 -0
- package/docs/RELEASE-READINESS.md +95 -0
- package/docs/RELEASE-SECURITY.md +72 -0
- package/docs/RESILIENCE.md +160 -0
- package/docs/ROUTING.md +95 -0
- package/docs/SANDBOX-REVIEW.md +59 -0
- package/docs/SCAFFOLDING.md +72 -0
- package/docs/SECURITY-AUDIT.md +124 -0
- package/docs/SITE.md +150 -0
- package/docs/SPECIFICATION.md +232 -0
- package/docs/SPIKE-EXTENSIONS.md +475 -0
- package/docs/STANDARDS.md +303 -0
- package/docs/STARTERS.md +51 -0
- package/docs/TUNNELS.md +75 -0
- package/docs/TYPESCRIPT.md +102 -0
- package/docs/VERCEL.md +107 -0
- package/docs/YAML-GUIDE.md +521 -0
- package/docs/YAML-REFERENCE.md +328 -0
- package/docs/policies/agents.md +182 -0
- package/docs/policies/cache.md +139 -0
- package/docs/policies/compression.md +168 -0
- package/docs/policies/security.md +160 -0
- package/docs/policies/throttle.md +103 -0
- package/examples/assets/.env.example +1 -0
- package/examples/assets/.gitattributes +2 -0
- package/examples/assets/Makefile +30 -0
- package/examples/assets/README.md +18 -0
- package/examples/assets/functions/hello.mjs +3 -0
- package/examples/assets/gitignore.template +7 -0
- package/examples/assets/public/about.html +2 -0
- package/examples/assets/public/assets/example.txt +1 -0
- package/examples/assets/public/guide.txt +1 -0
- package/examples/assets/tests/requests.json +44 -0
- package/examples/assets/urlcode.yaml +27 -0
- package/examples/aws/README.md +13 -0
- package/examples/aws/handler.mjs +5 -0
- package/examples/aws/public/index.html +3 -0
- package/examples/aws/public/notes.txt +1 -0
- package/examples/aws/template.yaml +33 -0
- package/examples/aws/tests/requests.json +9 -0
- package/examples/aws/urlcode.yaml +19 -0
- package/examples/cloudflare/README.md +18 -0
- package/examples/cloudflare/package.json +14 -0
- package/examples/cloudflare/tests/requests.json +9 -0
- package/examples/cloudflare/urlcode.yaml +24 -0
- package/examples/cloudflare/wrangler.toml +5 -0
- package/examples/compliance/README.md +20 -0
- package/examples/compliance/rules.mjs +65 -0
- package/examples/cookbook/README.md +21 -0
- package/examples/cookbook/functions/choice.mjs +4 -0
- package/examples/cookbook/functions/echo.mjs +3 -0
- package/examples/cookbook/functions/hello.mjs +3 -0
- package/examples/cookbook/functions/text.mjs +3 -0
- package/examples/cookbook/middleware/headers.mjs +6 -0
- package/examples/cookbook/public/about.html +1 -0
- package/examples/cookbook/public/assets/index.html +1 -0
- package/examples/cookbook/public/assets/site.css +1 -0
- package/examples/cookbook/public/favicon.svg +1 -0
- package/examples/cookbook/public/guide.txt +1 -0
- package/examples/cookbook/public/llms.txt +6 -0
- package/examples/cookbook/routes/code.yaml +55 -0
- package/examples/cookbook/routes/files.yaml +17 -0
- package/examples/cookbook/routes/policies.yaml +28 -0
- package/examples/cookbook/routes/redirects.yaml +38 -0
- package/examples/cookbook/routes/responses.yaml +26 -0
- package/examples/cookbook/tests/requests.json +254 -0
- package/examples/cookbook/urlcode.yaml +22 -0
- package/examples/live-links/README.md +11 -0
- package/examples/live-links/tests/requests.json +6 -0
- package/examples/live-links/urlcode.yaml +16 -0
- package/examples/monitoring/blackbox-jobs.yaml +37 -0
- package/examples/monitoring/prometheus-rules.yaml +88 -0
- package/examples/monitoring/prometheus-scrape.yaml +20 -0
- package/examples/monitoring/vector.toml +65 -0
- package/examples/prerender/README.md +48 -0
- package/examples/prerender/functions/page.mjs +6 -0
- package/examples/prerender/middleware/template.mjs +29 -0
- package/examples/prerender/prerender.d.mts +4 -0
- package/examples/prerender/prerender.mjs +63 -0
- package/examples/prerender/tests/requests.json +39 -0
- package/examples/prerender/urlcode.yaml +50 -0
- package/examples/tunnel/dev-with-ngrok.sh +61 -0
- package/examples/vercel/README.md +13 -0
- package/examples/vercel/api/index.js +5 -0
- package/examples/vercel/package.json +8 -0
- package/examples/vercel/public/index.html +3 -0
- package/examples/vercel/public/notes.txt +1 -0
- package/examples/vercel/tests/requests.json +48 -0
- package/examples/vercel/urlcode.yaml +19 -0
- package/examples/vercel/vercel.json +10 -0
- package/llms.txt +46 -0
- package/package.json +123 -0
- package/schemas/urlcode.schema.json +1230 -0
- package/starters/default/.gitattributes +1 -0
- package/starters/default/Makefile +30 -0
- package/starters/default/README.md +34 -0
- package/starters/default/functions/hello.mjs +3 -0
- package/starters/default/gitignore.template +8 -0
- package/starters/default/middleware/headers.mjs +6 -0
- package/starters/default/routes/functions.yaml +20 -0
- package/starters/default/routes/marketing/links.yaml +7 -0
- package/starters/default/starter.json +5 -0
- package/starters/default/tests/requests.json +56 -0
- package/starters/default/urlcode.yaml +8 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { readFile, realpath, stat, lstat, open } from 'node:fs/promises';
|
|
3
|
+
import { resolve, relative, isAbsolute, extname } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { parseDocument, visit, isAlias, isScalar, isMap, isNode } from 'yaml';
|
|
6
|
+
import Ajv from 'ajv/dist/2020.js';
|
|
7
|
+
import { assert, ConfigError } from './errors.js';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/** What config-worker.ts posts back: the loaded document, or the ConfigError message. */
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
// The schema file is this package's own; JSON.parse gives unknown and Ajv takes it as a schema object.
|
|
15
|
+
const schema = JSON.parse(await readFile(new URL('../schemas/urlcode.schema.json', import.meta.url), 'utf8')) ;
|
|
16
|
+
// Node hands the CJS module.exports (the class) to a default import; TypeScript types it as the namespace, whose .default is the same class.
|
|
17
|
+
const validate = new Ajv.default({ allErrors: false, strict: true, strictRequired: false, allowUnionTypes: true }).compile(schema);
|
|
18
|
+
export const MAX_CONFIG_BYTES = 32 * 1024 * 1024;
|
|
19
|
+
export function parseYaml(text ) {
|
|
20
|
+
assert(Buffer.byteLength(text) <= MAX_CONFIG_BYTES, 'Configuration exceeds 32 MiB');
|
|
21
|
+
const doc = parseDocument(text, { version: '1.2', uniqueKeys: false, strict: true });
|
|
22
|
+
assert(!doc.errors.length && !doc.warnings.length, 'Invalid YAML: check syntax, duplicate keys and tags');
|
|
23
|
+
visit(doc, (_key, node) => {
|
|
24
|
+
assert(!isAlias(node) && !(isNode(node) && (node.anchor || node.tag)), 'YAML aliases, anchors and explicit tags are unsupported');
|
|
25
|
+
if (isMap(node)) {
|
|
26
|
+
// The parser's generic pair comparison is quadratic on large mappings.
|
|
27
|
+
// Our string-only profile permits equivalent linear duplicate detection.
|
|
28
|
+
const keys = new Set ();
|
|
29
|
+
for (const pair of node.items) {
|
|
30
|
+
assert(isScalar(pair.key) && typeof pair.key.value === 'string', 'YAML mapping keys must be strings');
|
|
31
|
+
assert(!keys.has(pair.key.value), 'Duplicate YAML mapping key');
|
|
32
|
+
keys.add(pair.key.value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (isScalar(node)) assert(node.value === null || ['string', 'number', 'boolean'].includes(typeof node.value), 'Non-JSON YAML value');
|
|
36
|
+
});
|
|
37
|
+
const data = doc.toJS({ maxAliasCount: 0, mapAsMap: false });
|
|
38
|
+
const inspect = (value , depth = 0) => {
|
|
39
|
+
assert(depth < 40, 'Configuration nesting exceeds 40 levels');
|
|
40
|
+
if (typeof value === 'number') assert(Number.isFinite(value), 'Non-finite YAML number');
|
|
41
|
+
if (value && typeof value === 'object') {
|
|
42
|
+
for (const [key, child] of Object.entries(value)) {
|
|
43
|
+
assert(!['__proto__', 'prototype', 'constructor', '<<'].includes(key), 'Reserved mapping key');
|
|
44
|
+
inspect(child, depth + 1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
inspect(data);
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
export function validateDocument(data ) {
|
|
52
|
+
if (!validate(data)) {
|
|
53
|
+
const e = validate.errors [0] ;
|
|
54
|
+
throw new ConfigError(`Invalid configuration at ${e.instancePath || '/'} (${e.keyword})`);
|
|
55
|
+
}
|
|
56
|
+
return data ; // trust boundary: the schema just admitted it
|
|
57
|
+
}
|
|
58
|
+
export async function safeFile(root , file ) {
|
|
59
|
+
root = await realpath(root);
|
|
60
|
+
assert(typeof file === 'string' && file.length && !isAbsolute(file), 'File reference must be project-relative');
|
|
61
|
+
const actual = await realpath(resolve(root, file)).catch(() => { throw new ConfigError('Referenced project file is missing'); });
|
|
62
|
+
const rel = relative(root, actual);
|
|
63
|
+
assert(rel && rel !== '..' && !rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) && !isAbsolute(rel), 'File reference escapes project');
|
|
64
|
+
assert((await stat(actual)).isFile(), 'Reference must point to a file');
|
|
65
|
+
return actual;
|
|
66
|
+
}
|
|
67
|
+
export const MAX_PROJECT_CONFIG_BYTES = 64 * 1024 * 1024;
|
|
68
|
+
async function readConfig(file , budget ) {
|
|
69
|
+
const handle = await open(file, 'r');
|
|
70
|
+
try {
|
|
71
|
+
const size = (await handle.stat()).size;
|
|
72
|
+
assert(size <= MAX_CONFIG_BYTES, 'Configuration exceeds 32 MiB');
|
|
73
|
+
assert(size <= budget.remaining, 'Project configuration exceeds aggregate 64 MiB');
|
|
74
|
+
// Read at most the checked size plus one byte; concurrent growth cannot
|
|
75
|
+
// turn a small stat result into an unbounded readFile allocation.
|
|
76
|
+
const buffer = Buffer.alloc(size + 1);
|
|
77
|
+
let offset = 0;
|
|
78
|
+
while (offset < buffer.length) {
|
|
79
|
+
const {bytesRead} = await handle.read(buffer, offset, buffer.length-offset, null);
|
|
80
|
+
if (!bytesRead) break;
|
|
81
|
+
offset += bytesRead;
|
|
82
|
+
}
|
|
83
|
+
assert(offset <= size, 'Configuration changed while reading');
|
|
84
|
+
budget.remaining -= offset;
|
|
85
|
+
return parseYaml(new TextDecoder('utf-8', {fatal:true}).decode(buffer.subarray(0,offset)));
|
|
86
|
+
} finally { await handle.close(); }
|
|
87
|
+
}
|
|
88
|
+
// Resource limits contain parser/AST/schema expansion, not just source bytes.
|
|
89
|
+
// The parent can terminate a blocked parser without blocking serving requests.
|
|
90
|
+
let activeLoads = 0;
|
|
91
|
+
export async function loadDocument(project , {timeoutMs=10000} ={}) {
|
|
92
|
+
assert(Number.isInteger(timeoutMs) && timeoutMs>=1 && timeoutMs<=30000, 'Configuration deadline must be 1–30000 ms');
|
|
93
|
+
assert(activeLoads < 2, 'Configuration compilation capacity unavailable');
|
|
94
|
+
activeLoads++;
|
|
95
|
+
let worker ;
|
|
96
|
+
try {
|
|
97
|
+
const data = {project};
|
|
98
|
+
worker = new Worker(new URL('./config-worker.js', import.meta.url), {
|
|
99
|
+
workerData:data, env:{}, execArgv:[], stdout:true, stderr:true,
|
|
100
|
+
resourceLimits:{maxOldGenerationSizeMb:256, maxYoungGenerationSizeMb:16, stackSizeMb:4},
|
|
101
|
+
});
|
|
102
|
+
worker.stdout.resume(); worker.stderr.resume();
|
|
103
|
+
const started = worker;
|
|
104
|
+
return await new Promise ((resolve,reject)=>{
|
|
105
|
+
const timer=setTimeout(()=>reject(new ConfigError('Configuration compilation deadline exceeded')),timeoutMs);
|
|
106
|
+
const done=(error ,value )=>{clearTimeout(timer); if(error)reject(error);else resolve(value );};
|
|
107
|
+
// The worker only ever posts a ConfigWorkerResult (config-worker.ts).
|
|
108
|
+
started.once('message',(message )=>'error' in message ? done(new ConfigError(message.error)) : done(null,message.value));
|
|
109
|
+
started.once('error',()=>done(new ConfigError('Configuration worker resource limit or failure')));
|
|
110
|
+
started.once('exit',()=>done(new ConfigError('Configuration worker exited')));
|
|
111
|
+
});
|
|
112
|
+
} finally { try { await worker?.terminate(); } finally { activeLoads--; } }
|
|
113
|
+
}
|
|
114
|
+
export async function loadDocumentInWorker(project ) {
|
|
115
|
+
const budget={remaining:MAX_PROJECT_CONFIG_BYTES};
|
|
116
|
+
const root = await realpath(project);
|
|
117
|
+
const file = await safeFile(root, 'urlcode.yaml');
|
|
118
|
+
const document = validateDocument(await readConfig(file, budget));
|
|
119
|
+
const routes = Object.assign(Object.create(null) , document.routes);
|
|
120
|
+
const files = [file];
|
|
121
|
+
let routeCount=Object.keys(routes).length;
|
|
122
|
+
for (const include of document.includes || []) {
|
|
123
|
+
const path = await safeFile(root, include);
|
|
124
|
+
assert(!files.includes(path), 'Duplicate include');
|
|
125
|
+
files.push(path);
|
|
126
|
+
const part = validateDocument(await readConfig(path, budget));
|
|
127
|
+
assert(!part.includes?.length, 'Nested includes are unsupported');
|
|
128
|
+
assert(part.dynamicLinks===undefined, 'dynamicLinks may only be set in the entry urlcode.yaml');
|
|
129
|
+
assert(part.site===undefined, 'site may only be set in the entry urlcode.yaml');
|
|
130
|
+
for (const [pattern, route] of Object.entries(part.routes)) {
|
|
131
|
+
assert(!Object.hasOwn(routes, pattern), 'Duplicate route across files');
|
|
132
|
+
routes[pattern] = route;
|
|
133
|
+
assert(++routeCount <= 100000, 'Maximum 100000 routes per project');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
assert(Object.keys(routes).length <= 100000, 'Maximum 100000 routes per project');
|
|
137
|
+
assert(document.dynamicLinks===true || !Object.values(routes).some(route=>route.link), 'Link routes require dynamicLinks: true in urlcode.yaml');
|
|
138
|
+
return { root, document, routes, files, version: createHash('sha256').update(JSON.stringify(document.site ? {...(document.dynamicLinks===true?{routes,dynamicLinks:true}:{routes}), site:document.site} : document.dynamicLinks===true?{routes,dynamicLinks:true}:routes)).digest('hex').slice(0, 16) };
|
|
139
|
+
}
|
|
140
|
+
export async function loadBindings(root , local = false, environment = process.env) {
|
|
141
|
+
const vars = {};
|
|
142
|
+
if (local) {
|
|
143
|
+
try {
|
|
144
|
+
const path = await safeFile(root, '.env.local');
|
|
145
|
+
assert((await stat(path)).size <= 65536, '.env.local exceeds 64 KiB');
|
|
146
|
+
const text = await readFile(path, 'utf8');
|
|
147
|
+
// A deliberately small non-executable dotenv profile. No interpolation/escapes.
|
|
148
|
+
for (const line of text.split(/\r?\n/)) {
|
|
149
|
+
if (!line.trim() || line.trimStart().startsWith('#')) continue;
|
|
150
|
+
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
151
|
+
assert(match, 'Invalid .env.local assignment');
|
|
152
|
+
const [, key = '', raw = ''] = match;
|
|
153
|
+
assert(!Object.hasOwn(vars, key), 'Duplicate .env.local name');
|
|
154
|
+
let value = raw.trim();
|
|
155
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
156
|
+
assert(value.length >= 2 && value.endsWith(value[0] ), 'Invalid .env.local quote');
|
|
157
|
+
value = value.slice(1, -1);
|
|
158
|
+
}
|
|
159
|
+
vars[key] = value;
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// Only absence is optional. Malformed files and unsafe symlinks fail closed.
|
|
163
|
+
try { await lstat(resolve(root, '.env.local')); } catch (e) { if ((e ).code === 'ENOENT') return { ...environment }; }
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { ...vars, ...environment };
|
|
168
|
+
}
|
|
169
|
+
export async function functionFile(root , file ) {
|
|
170
|
+
assert(['.mjs', '.js'].includes(extname(file)), 'Functions must be JavaScript ES modules (.mjs or .js)');
|
|
171
|
+
return safeFile(root, file);
|
|
172
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export class ConfigError extends Error {}
|
|
2
|
+
export class HttpError extends Error {
|
|
3
|
+
status ;
|
|
4
|
+
constructor(status , message ) { super(message); this.status = status; }
|
|
5
|
+
}
|
|
6
|
+
export function assert(condition , message ) {
|
|
7
|
+
if (!condition) throw new ConfigError(message);
|
|
8
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { relative, dirname, resolve, sep, posix } from 'node:path';
|
|
3
|
+
import { init, parse } from 'es-module-lexer';
|
|
4
|
+
import { functionFile } from './config.js';
|
|
5
|
+
import { assert } from './errors.js';
|
|
6
|
+
|
|
7
|
+
/** A function or middleware reference with its export named: what the sandbox loads. */
|
|
8
|
+
|
|
9
|
+
/** Any route-like object carrying function definitions (a YAML route, whose `export` may be absent, or a compiled one). */
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
export function routeFunctions (route ) { return [...(route.middleware || []), ...(route.function ? [route.function] : [])]; }
|
|
21
|
+
|
|
22
|
+
// Parse and snapshot source without ever importing project code into Node.
|
|
23
|
+
export async function collectFunctionSources(routes , root ) {
|
|
24
|
+
await init;
|
|
25
|
+
const sources = Object.create(null), dependencies = Object.create(null);
|
|
26
|
+
const entries = [], names = new Map (), seenEntries = new Set ();
|
|
27
|
+
let bytes = 0;
|
|
28
|
+
async function collect(file ) {
|
|
29
|
+
const name = '/' + relative(root,file).split(sep).join('/');
|
|
30
|
+
if (Object.hasOwn(sources,name)) return name;
|
|
31
|
+
assert(Object.keys(sources).length < 128, 'Function module limit exceeded');
|
|
32
|
+
const info = await stat(file);
|
|
33
|
+
assert(info.size <= 1048576 && bytes + info.size <= 4194304, 'Function source limit exceeded');
|
|
34
|
+
const code = await readFile(file,'utf8'); bytes += Buffer.byteLength(code);
|
|
35
|
+
assert(bytes <= 4194304, 'Function source limit exceeded');
|
|
36
|
+
sources[name] = code; const deps = dependencies[name] = [];
|
|
37
|
+
const [imports] = parse(code);
|
|
38
|
+
for (const item of imports) {
|
|
39
|
+
assert(item.type === 'static' && typeof item.specifier === 'string' && !item.attributes && !item.phase, 'Dynamic imports and import.meta are unsupported in sandbox functions');
|
|
40
|
+
assert(item.specifier.startsWith('./') || item.specifier.startsWith('../'), 'Only relative project JavaScript imports are allowed');
|
|
41
|
+
const dependency = await functionFile(root,relative(root,resolve(dirname(file),item.specifier)));
|
|
42
|
+
assert(posix.normalize(posix.join(posix.dirname(name),item.specifier)).startsWith('/'), 'Invalid module reference');
|
|
43
|
+
deps.push(await collect(dependency));
|
|
44
|
+
}
|
|
45
|
+
return name;
|
|
46
|
+
}
|
|
47
|
+
for (const definition of routes.flatMap(routeFunctions)) {
|
|
48
|
+
const name = await collect(definition.source);
|
|
49
|
+
names.set(definition.source,name);
|
|
50
|
+
const key = name + ":" + definition.export;
|
|
51
|
+
if (!seenEntries.has(key)) { entries.push([name,definition.export]); seenEntries.add(key); }
|
|
52
|
+
}
|
|
53
|
+
return { sources,dependencies,entries,names };
|
|
54
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
import { getQuickJS, } from 'quickjs-emscripten';
|
|
4
|
+
import { guestBootstrap } from './guest-api.js';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
if (!parentPort) throw new Error('Function worker requires a parent');
|
|
9
|
+
const port = parentPort;
|
|
10
|
+
const data = workerData ; // trust boundary: set by FunctionPool.spawn
|
|
11
|
+
const post = (message ) => port.postMessage(message);
|
|
12
|
+
const engine = await getQuickJS();
|
|
13
|
+
async function evaluate(entry , name , payload , timeoutMs = 5000, chain = []) {
|
|
14
|
+
// New heap/module state for every invocation, including validation. No Node
|
|
15
|
+
// objects/functions are injected. Only strings and JSON cross the boundary.
|
|
16
|
+
const allowed = new Set ();
|
|
17
|
+
function allow(name ) { if (allowed.has(name)) return; allowed.add(name); for (const dep of data.dependencies[name] || []) allow(dep); }
|
|
18
|
+
if (entry) allow(entry);
|
|
19
|
+
for (const item of chain) if (item.source !== undefined) allow(item.source);
|
|
20
|
+
const runtime = engine.newRuntime();
|
|
21
|
+
runtime.setMemoryLimit(32 * 1024 * 1024);
|
|
22
|
+
runtime.setMaxStackSize(512 * 1024);
|
|
23
|
+
const deadline = Date.now() + timeoutMs;
|
|
24
|
+
runtime.setInterruptHandler(() => Date.now() >= deadline);
|
|
25
|
+
runtime.setModuleLoader(moduleName => {
|
|
26
|
+
const source = data.sources[moduleName];
|
|
27
|
+
if (!allowed.has(moduleName) || source === undefined) throw new Error('Module denied');
|
|
28
|
+
return source;
|
|
29
|
+
}, (base, requested) => {
|
|
30
|
+
const resolved = requested.startsWith('./') || requested.startsWith('../') ? posix.resolve(posix.dirname(base),requested) : requested;
|
|
31
|
+
if (!allowed.has(resolved)) throw new Error('Module denied');
|
|
32
|
+
return resolved;
|
|
33
|
+
});
|
|
34
|
+
const vm = runtime.newContext();
|
|
35
|
+
function run(code , filename = 'bootstrap.js', type = 'global') {
|
|
36
|
+
const result = vm.evalCode(code,filename,{type});
|
|
37
|
+
if (result.error) { result.error.dispose(); throw new Error('Guest evaluation failed'); }
|
|
38
|
+
result.value.dispose();
|
|
39
|
+
}
|
|
40
|
+
function string(name ) {
|
|
41
|
+
const handle = vm.getProp(vm.global,name);
|
|
42
|
+
try { return vm.getString(handle); } finally { handle.dispose(); }
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
run(guestBootstrap);
|
|
46
|
+
if (payload !== undefined) {
|
|
47
|
+
const value = vm.newString(payload); vm.setProp(vm.global,'__payload',value); value.dispose();
|
|
48
|
+
}
|
|
49
|
+
if (chain.length) {
|
|
50
|
+
const imports = chain.map((item,i) => `import * as mw${i} from ${JSON.stringify(item.source)};`).join('\n');
|
|
51
|
+
const functions = chain.map((item,i) => `mw${i}[${JSON.stringify(item.name)}]`).join(',');
|
|
52
|
+
run(`${imports}
|
|
53
|
+
${entry ? `import * as entry from ${JSON.stringify(entry)};` : ''}
|
|
54
|
+
globalThis.__invokePipeline([${functions}], ${entry ? `entry[${JSON.stringify(name)}]` : 'null'}, globalThis.__payload);`,
|
|
55
|
+
'/__urlcode_entry.mjs','module');
|
|
56
|
+
} else {
|
|
57
|
+
run(`import * as entry from ${JSON.stringify(entry)};
|
|
58
|
+
if (typeof entry[${JSON.stringify(name)}] !== 'function') throw new Error('Invalid export');
|
|
59
|
+
${payload === undefined ? "globalThis.__state = 'done';" : `globalThis.__invoke(entry[${JSON.stringify(name)}], globalThis.__payload);`}`,
|
|
60
|
+
'/__urlcode_entry.mjs','module');
|
|
61
|
+
}
|
|
62
|
+
while (string('__state') === 'pending') {
|
|
63
|
+
if (Date.now() >= deadline) throw new Error('Guest deadline exceeded');
|
|
64
|
+
const jobs = runtime.executePendingJobs(100);
|
|
65
|
+
if (jobs.error) { jobs.error.dispose(); throw new Error('Guest promise failed'); }
|
|
66
|
+
run('globalThis.__pump()');
|
|
67
|
+
if (string('__state') === 'pending') await new Promise(resolve => setTimeout(resolve,2));
|
|
68
|
+
}
|
|
69
|
+
if (string('__state') !== 'done') throw new Error('Guest invocation failed');
|
|
70
|
+
return payload === undefined ? undefined : string('__output');
|
|
71
|
+
} finally { vm.dispose(); runtime.dispose(); }
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
for (const [source,name] of data.entries) await evaluate(source,name);
|
|
75
|
+
post({ready:true});
|
|
76
|
+
} catch { post({startupError:true}); }
|
|
77
|
+
const isPair = (pair ) => Array.isArray(pair) && pair.length === 2 && pair.every(v => typeof v === 'string');
|
|
78
|
+
port.on('message', async ({id,source,name,request,context,maxBytes,timeoutMs,chain=[],native} ) => { // trust boundary: posted by FunctionPool.execute
|
|
79
|
+
try {
|
|
80
|
+
const payload = JSON.stringify({request:{...request,body:Buffer.from(request.body || []).toString('utf8')},context,native});
|
|
81
|
+
if (Buffer.byteLength(payload) > 4 * 1024 * 1024) throw new Error('Input limit');
|
|
82
|
+
const output = await evaluate(source,name,payload,timeoutMs,chain);
|
|
83
|
+
if (output === undefined || Buffer.byteLength(output) > maxBytes * 6 + 65536) throw new Error('Output limit');
|
|
84
|
+
const value = JSON.parse(output) ; // trust boundary: guest JSON, checked below
|
|
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.nativeBody) {
|
|
87
|
+
if (!native || value.status !== native.status || value.body !== '') throw new Error('Invalid native response');
|
|
88
|
+
// Preserve native status and metadata (validators, ranges, redirect Location).
|
|
89
|
+
for (const key of new Set(native.headers.map(([k]) => k.toLowerCase()))) {
|
|
90
|
+
const originals = native.headers.filter(([k]) => k.toLowerCase() === key).map(([,v])=>v);
|
|
91
|
+
const output = (value.headers ).flatMap(pair => Array.isArray(pair) && typeof pair[0] === 'string' && pair[0].toLowerCase() === key ? [pair[1] ] : []);
|
|
92
|
+
if (JSON.stringify(originals) !== JSON.stringify(output)) throw new Error('Native metadata changed');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const body = Buffer.from(value.body,'utf8');
|
|
96
|
+
if (body.length > maxBytes) throw new Error('Response limit');
|
|
97
|
+
let bytes = 0;
|
|
98
|
+
for (const pair of value.headers ) {
|
|
99
|
+
if (!isPair(pair)) throw new Error('Invalid headers');
|
|
100
|
+
bytes += Buffer.byteLength(pair[0]) + Buffer.byteLength(pair[1]) + 4;
|
|
101
|
+
}
|
|
102
|
+
if (bytes > 16384) throw new Error('Header limit');
|
|
103
|
+
post({id,status:value.status,headers:value.headers,body,nativeBody:value.nativeBody === true});
|
|
104
|
+
} catch { post({id,error:true}); }
|
|
105
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { collectFunctionSources, routeFunctions } from './function-sources.js';
|
|
4
|
+
|
|
5
|
+
import { assert, ConfigError, HttpError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
// The worker protocol. Only JSON-shaped data and byte buffers cross it.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export class FunctionPool {
|
|
36
|
+
root ; routes ; preparedSnapshot ; snapshot ;
|
|
37
|
+
restarts ; restartTimers ; log ; timeoutMs ; maxBytes ;
|
|
38
|
+
modules ; size ; slots ; closed ;
|
|
39
|
+
constructor(routes , { root, snapshot, workers = 2, timeoutMs = 5000, maxBytes = 1048576, log = () => {} } = {}) {
|
|
40
|
+
assert(Number.isInteger(workers) && workers >= 1 && workers <= 32, 'Workers must be 1–32');
|
|
41
|
+
assert(Number.isInteger(timeoutMs) && timeoutMs >= 10 && timeoutMs <= 60000, 'Function timeout must be 10–60000 ms');
|
|
42
|
+
assert(Number.isInteger(maxBytes) && maxBytes >= 1 && maxBytes <= 16777216, 'Response limit must be 1–16777216 bytes');
|
|
43
|
+
this.root = root; this.routes = routes; this.preparedSnapshot = snapshot;
|
|
44
|
+
// Consecutive replacement attempts per slot; cleared by a completed invocation.
|
|
45
|
+
this.restarts = new Map(); this.restartTimers = new Set();
|
|
46
|
+
this.log = log;
|
|
47
|
+
this.timeoutMs = timeoutMs; this.maxBytes = maxBytes;
|
|
48
|
+
const modules = new Map ();
|
|
49
|
+
for (const definition of routes.flatMap(routeFunctions)) {
|
|
50
|
+
const { source, export: name } = definition;
|
|
51
|
+
let names = modules.get(source);
|
|
52
|
+
if (!names) { names = new Set(); modules.set(source, names); }
|
|
53
|
+
names.add(name);
|
|
54
|
+
}
|
|
55
|
+
this.modules = [...modules].map(([source, names]) => [source, [...names]]);
|
|
56
|
+
this.size = this.modules.length ? workers : 0;
|
|
57
|
+
this.slots = []; this.closed = false;
|
|
58
|
+
}
|
|
59
|
+
async start() {
|
|
60
|
+
let snapshot = this.preparedSnapshot;
|
|
61
|
+
if (!snapshot && this.size) { assert(this.root !== undefined, 'Function pool requires a project root'); snapshot = await collectFunctionSources(this.routes,this.root); }
|
|
62
|
+
snapshot ??= {sources:{},dependencies:{},entries:[],names:new Map()};
|
|
63
|
+
this.snapshot = snapshot;
|
|
64
|
+
try { await Promise.all(Array.from({ length: this.size }, (_, i) => this.spawn(i))); }
|
|
65
|
+
catch { await this.close(); throw new ConfigError('Function initialization failed (check module syntax, imports and exports)'); }
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
spawn(index ) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
if (this.closed) return reject(new Error('Pool closed'));
|
|
71
|
+
const snapshot = this.snapshot;
|
|
72
|
+
assert(snapshot, 'Pool not started');
|
|
73
|
+
// The WASM guest is the capability boundary. The outer worker supplies
|
|
74
|
+
// an independent termination deadline if the guest engine stops responding.
|
|
75
|
+
const workerData = { sources:snapshot.sources, dependencies:snapshot.dependencies, entries:snapshot.entries };
|
|
76
|
+
const worker = new Worker(new URL('./function-worker.js', import.meta.url), {
|
|
77
|
+
env: {}, execArgv: [],
|
|
78
|
+
workerData, stdout: true, stderr: true,
|
|
79
|
+
resourceLimits: { maxOldGenerationSizeMb: 128, stackSizeMb: 4 },
|
|
80
|
+
});
|
|
81
|
+
worker.stdout.resume(); worker.stderr.resume(); // Engine diagnostics must not expose guest data.
|
|
82
|
+
const slot = { worker, ready: false, pending: null };
|
|
83
|
+
this.slots[index] = slot;
|
|
84
|
+
let initialized = false;
|
|
85
|
+
const timer = setTimeout(() => fail(), 5000);
|
|
86
|
+
const fail = () => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
slot.ready = false;
|
|
89
|
+
if (!initialized) reject(new Error('Worker initialization failed'));
|
|
90
|
+
if (slot.pending) {
|
|
91
|
+
clearTimeout(slot.pending.timer);
|
|
92
|
+
slot.pending.reject(new HttpError(502, 'Function execution failed'));
|
|
93
|
+
slot.pending = null;
|
|
94
|
+
}
|
|
95
|
+
void worker.terminate();
|
|
96
|
+
};
|
|
97
|
+
worker.on('message', (message ) => { // trust boundary: the worker's own protocol
|
|
98
|
+
if ('ready' in message && !initialized) {
|
|
99
|
+
this.report('started', index);
|
|
100
|
+
initialized = true; clearTimeout(timer); slot.ready = true; resolve(); return;
|
|
101
|
+
}
|
|
102
|
+
if ('startupError' in message) { fail(); return; }
|
|
103
|
+
if (!('id' in message)) return;
|
|
104
|
+
const pending = slot.pending;
|
|
105
|
+
if (!pending || pending.id !== message.id) return;
|
|
106
|
+
// Any answered invocation, success or guest error, proves this worker is
|
|
107
|
+
// serving again; a worker that starts cleanly but dies on every request
|
|
108
|
+
// must keep backing off rather than restarting in a tight loop.
|
|
109
|
+
this.restarts.delete(index);
|
|
110
|
+
clearTimeout(pending.timer); slot.pending = null;
|
|
111
|
+
if ('error' in message) pending.reject(new HttpError(502, 'Function execution failed'));
|
|
112
|
+
else pending.resolve(message);
|
|
113
|
+
});
|
|
114
|
+
worker.on('error', fail);
|
|
115
|
+
worker.on('exit', () => {
|
|
116
|
+
fail();
|
|
117
|
+
if (initialized && !this.closed && this.slots[index] === slot) this.scheduleRespawn(index);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
report(status , index , extra = {}) {
|
|
122
|
+
try { this.log({ event: 'function_worker', status, slot: index, ...extra }); } catch { /* Logging cannot fail the pool. */ }
|
|
123
|
+
}
|
|
124
|
+
// A worker exit must never latch a slot off permanently: a deadline or an
|
|
125
|
+
// out-of-memory guest is reachable from ordinary request input, so replacement
|
|
126
|
+
// backs off instead of stopping. Backoff bounds churn; it does not stop it.
|
|
127
|
+
scheduleRespawn(index ) {
|
|
128
|
+
if (this.closed) return;
|
|
129
|
+
const attempt = (this.restarts.get(index) || 0) + 1;
|
|
130
|
+
this.restarts.set(index, attempt);
|
|
131
|
+
const delayMs = Math.min(30000, 250 * 2 ** Math.min(attempt - 1, 7));
|
|
132
|
+
this.report('restarting', index, { attempt, delayMs });
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
this.restartTimers.delete(timer);
|
|
135
|
+
if (this.closed) return;
|
|
136
|
+
void this.spawn(index).catch(() => this.scheduleRespawn(index));
|
|
137
|
+
}, delayMs);
|
|
138
|
+
timer.unref(); this.restartTimers.add(timer);
|
|
139
|
+
}
|
|
140
|
+
get healthy() { return !this.closed && this.slots.length === this.size && this.slots.every(slot => slot?.ready); }
|
|
141
|
+
execute(route , request , context , native ) {
|
|
142
|
+
const slot = this.slots.find(s => s?.ready && !s.pending);
|
|
143
|
+
const snapshot = this.snapshot;
|
|
144
|
+
if (this.closed || !slot || !snapshot) return Promise.reject(new HttpError(503, 'Function capacity unavailable'));
|
|
145
|
+
const id = randomUUID();
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
slot.pending = null; slot.ready = false;
|
|
149
|
+
reject(new HttpError(504, 'Function deadline exceeded'));
|
|
150
|
+
void slot.worker.terminate();
|
|
151
|
+
}, this.timeoutMs);
|
|
152
|
+
slot.pending = { id, timer, resolve: message => {
|
|
153
|
+
// Native bytes never enter the guest; only this invocation's body can be retained.
|
|
154
|
+
if (message.nativeBody && native) resolve({...message,body:native.body,...(native.contentLength === undefined ? {} : {contentLength:native.contentLength})});
|
|
155
|
+
else resolve(message);
|
|
156
|
+
}, reject };
|
|
157
|
+
const message = { id, source: route.function ? snapshot.names.get(route.function.source) : undefined, name: route.function?.export,
|
|
158
|
+
chain: (route.middleware || []).map(item => ({source:snapshot.names.get(item.source),name:item.export})),
|
|
159
|
+
native: native ? {status:native.status,headers:native.headers} : undefined,
|
|
160
|
+
request, context, maxBytes: this.maxBytes, timeoutMs:this.timeoutMs + 100 };
|
|
161
|
+
slot.worker.postMessage(message);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async close() {
|
|
165
|
+
this.closed = true;
|
|
166
|
+
for (const timer of this.restartTimers) clearTimeout(timer);
|
|
167
|
+
this.restartTimers.clear();
|
|
168
|
+
for (const slot of this.slots) if (slot?.pending) {
|
|
169
|
+
clearTimeout(slot.pending.timer);
|
|
170
|
+
slot.pending.reject(new HttpError(503, 'Runtime shutting down')); slot.pending = null;
|
|
171
|
+
}
|
|
172
|
+
await Promise.all(this.slots.map(s => s?.worker.terminate()));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
|
|
2
|
+
// The JSON that crosses the guest boundary, in both directions. Only strings
|
|
3
|
+
// cross it; function-worker.ts stringifies the request and checks the
|
|
4
|
+
// response's shape before trusting it.
|
|
5
|
+
/** The request the guest receives (stringified as JSON in the worker). */
|
|
6
|
+
|
|
7
|
+
/** What the guest returns as JSON text; the worker enforces this shape before trusting it. */
|
|
8
|
+
|
|
9
|
+
// Runs only inside QuickJS/WASM. No native host functions or objects are exposed.
|
|
10
|
+
// This is the documented text/JSON subset, not a complete Fetch implementation.
|
|
11
|
+
export const guestBootstrap = String.raw`
|
|
12
|
+
(() => {
|
|
13
|
+
const NativeJSON = JSON;
|
|
14
|
+
const stringify = JSON.stringify.bind(JSON);
|
|
15
|
+
const now = Date.now.bind(Date);
|
|
16
|
+
const timers = new Map(); let next = 1;
|
|
17
|
+
globalThis.setTimeout = (fn, delay = 0) => {
|
|
18
|
+
if (typeof fn !== 'function' || timers.size >= 128) throw new Error('Timer limit');
|
|
19
|
+
const id = next++; timers.set(id, {fn, at:now() + Math.max(0, Number(delay) || 0)}); return id;
|
|
20
|
+
};
|
|
21
|
+
globalThis.clearTimeout = id => timers.delete(id);
|
|
22
|
+
globalThis.__pump = () => {
|
|
23
|
+
for (const [id,timer] of timers) if (timer.at <= now()) { timers.delete(id); timer.fn(); }
|
|
24
|
+
};
|
|
25
|
+
globalThis.console = Object.freeze({log(){},error(){},warn(){},info(){},debug(){}});
|
|
26
|
+
class Headers {
|
|
27
|
+
constructor(init = []) {
|
|
28
|
+
this._pairs = [];
|
|
29
|
+
for (const [k,v] of init instanceof Headers ? init._pairs : Array.isArray(init) ? init : Object.entries(init)) this.append(k,v);
|
|
30
|
+
}
|
|
31
|
+
append(key,value) {
|
|
32
|
+
key = String(key).toLowerCase(); value = String(value).trim();
|
|
33
|
+
if (!/^[!#$%&'*+.^_\x60|~0-9a-z-]+$/.test(key) || /[\r\n\0]/.test(value)) throw new TypeError('Invalid header');
|
|
34
|
+
this._pairs.push([key,value]);
|
|
35
|
+
}
|
|
36
|
+
set(key,value) { this.delete(key); this.append(key,value); }
|
|
37
|
+
delete(key) { this._pairs = this._pairs.filter(p => p[0] !== String(key).toLowerCase()); }
|
|
38
|
+
get(key) { const values = this._pairs.filter(p=>p[0]===String(key).toLowerCase()).map(p=>p[1]); return values.length ? values.join(', ') : null; }
|
|
39
|
+
has(key) { return this.get(key) !== null; }
|
|
40
|
+
getSetCookie() { return this._pairs.filter(p=>p[0]==='set-cookie').map(p=>p[1]); }
|
|
41
|
+
*entries() { const keys = new Set(this._pairs.map(p=>p[0])); for (const key of keys) yield [key,this.get(key)]; }
|
|
42
|
+
[Symbol.iterator]() { return this.entries(); }
|
|
43
|
+
}
|
|
44
|
+
class Request {
|
|
45
|
+
constructor(url, init = {}) {
|
|
46
|
+
this.url = String(url); this.method = init.method || 'GET'; this.headers = new Headers(init.headers);
|
|
47
|
+
this._text = init.body || ''; this.bodyUsed = false;
|
|
48
|
+
}
|
|
49
|
+
async text() { if (this.bodyUsed) throw new TypeError('Body already read'); this.bodyUsed = true; return this._text; }
|
|
50
|
+
async json() { return NativeJSON.parse(await this.text()); }
|
|
51
|
+
}
|
|
52
|
+
class Response {
|
|
53
|
+
constructor(body = null, init = {}) {
|
|
54
|
+
if (body !== null && typeof body !== 'string') throw new TypeError('Only text/JSON bodies are supported');
|
|
55
|
+
this.status = init.status ?? 200;
|
|
56
|
+
if (!Number.isInteger(this.status) || this.status < 200 || this.status > 599) throw new TypeError('Invalid status');
|
|
57
|
+
if ([204,205,304].includes(this.status) && body !== null && body !== '') throw new TypeError('Null-body status');
|
|
58
|
+
this.headers = new Headers(init.headers);
|
|
59
|
+
this._text = body || ''; this.bodyUsed = false; this.ok = this.status >= 200 && this.status < 300;
|
|
60
|
+
if (body !== null && !this.headers.has('content-type')) this.headers.set('content-type','text/plain;charset=UTF-8');
|
|
61
|
+
}
|
|
62
|
+
async text() { if (this.bodyUsed) throw new TypeError('Body already read'); this.bodyUsed = true; return this._text; }
|
|
63
|
+
async json() { return NativeJSON.parse(await this.text()); }
|
|
64
|
+
static json(value, init = {}) { const headers = new Headers(init.headers); if (!headers.has('content-type')) headers.set('content-type','application/json'); return new Response(stringify(value), {...init,headers}); }
|
|
65
|
+
static redirect(url, status = 302) { if (![301,302,303,307,308].includes(status)) throw new TypeError('Invalid redirect status'); return new Response(null,{status,headers:{location:String(url)}}); }
|
|
66
|
+
}
|
|
67
|
+
globalThis.Headers = Headers; globalThis.Request = Request; globalThis.Response = Response;
|
|
68
|
+
globalThis.__invokePipeline = async (middleware, handler, payload) => {
|
|
69
|
+
try {
|
|
70
|
+
const input = NativeJSON.parse(payload);
|
|
71
|
+
const request = new Request(input.request.url, input.request);
|
|
72
|
+
const context = input.context; context.state = {};
|
|
73
|
+
let nativeResponse;
|
|
74
|
+
if (input.native) {
|
|
75
|
+
nativeResponse = new Response(null,input.native);
|
|
76
|
+
nativeResponse.text = nativeResponse.json = async () => { throw new TypeError('Native body is opaque; return a new Response to replace it'); };
|
|
77
|
+
}
|
|
78
|
+
let violated = false;
|
|
79
|
+
async function dispatch(index) {
|
|
80
|
+
if (index === middleware.length) return handler ? await handler(request,context) : nativeResponse;
|
|
81
|
+
let called = false, open = true, pending;
|
|
82
|
+
const next = (...args) => {
|
|
83
|
+
if (args.length || called || !open) { violated = true; throw new TypeError('next may be called once during middleware'); }
|
|
84
|
+
called = true; pending = dispatch(index+1); return pending;
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
const response = await middleware[index](request,context,next);
|
|
88
|
+
// Drain downstream work under the same deadline even if the caller forgot await.
|
|
89
|
+
if (pending) await pending.catch(() => {});
|
|
90
|
+
if (!(response instanceof Response)) throw new TypeError('Middleware must return a Response');
|
|
91
|
+
return response;
|
|
92
|
+
} finally { open = false; }
|
|
93
|
+
}
|
|
94
|
+
const response = await dispatch(0);
|
|
95
|
+
if (violated || !(response instanceof Response)) throw new TypeError('Invalid middleware response');
|
|
96
|
+
const nativeBody = response === nativeResponse;
|
|
97
|
+
globalThis.__output = stringify({status:response.status,headers:response.headers._pairs,
|
|
98
|
+
body:nativeBody || input.request.method === 'HEAD' ? '' : response._text,nativeBody});
|
|
99
|
+
globalThis.__state = 'done';
|
|
100
|
+
} catch { globalThis.__state = 'failed'; }
|
|
101
|
+
};
|
|
102
|
+
globalThis.__state = 'pending'; globalThis.__output = '';
|
|
103
|
+
globalThis.__invoke = async (handler, payload) => {
|
|
104
|
+
try {
|
|
105
|
+
const input = NativeJSON.parse(payload);
|
|
106
|
+
const response = await handler(new Request(input.request.url, input.request), input.context);
|
|
107
|
+
if (!(response instanceof Response)) throw new TypeError('Return a Response');
|
|
108
|
+
const headers = response.headers._pairs;
|
|
109
|
+
globalThis.__output = stringify({status:response.status,headers,body: input.request.method === 'HEAD' ? '' : response._text});
|
|
110
|
+
globalThis.__state = 'done';
|
|
111
|
+
} catch { globalThis.__state = 'failed'; }
|
|
112
|
+
};
|
|
113
|
+
})();
|
|
114
|
+
`;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Header validation with no Node dependency, so one response policy runs on a
|
|
2
|
+
// Node server, a provider adapter and a Web-standard runtime. The rules are
|
|
3
|
+
// Node's: a name is an RFC 7230 token, and a value carries no control
|
|
4
|
+
// characters. test/header-validation.test.ts compares this against node:http
|
|
5
|
+
// across a wide input range, because disagreeing here is header injection.
|
|
6
|
+
const token = /^[\^`\-\w!#$%&'*+.|~]+$/;
|
|
7
|
+
const invalidValue = /[^\t -~-ÿ]/;
|
|
8
|
+
|
|
9
|
+
export function validateHeaderName(name ) {
|
|
10
|
+
if (typeof name !== 'string' || !token.test(name)) {
|
|
11
|
+
throw new TypeError(`Invalid header name: ${String(name)}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateHeaderValue(name , value ) {
|
|
16
|
+
if (value === undefined) throw new TypeError(`Invalid value "undefined" for header "${name}"`);
|
|
17
|
+
if (invalidValue.test(String(value))) throw new TypeError(`Invalid character in header content ["${name}"]`);
|
|
18
|
+
}
|