@jimhoyd/urlcode 0.4.8 → 0.5.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.
@@ -176,6 +176,8 @@ export async function loadExtensionHooks (config
176
176
 
177
177
 
178
178
 
179
+
180
+
179
181
 
180
182
 
181
183
 
@@ -204,6 +206,8 @@ export async function loadExtensionHooks (config
204
206
 
205
207
 
206
208
 
209
+
210
+
207
211
 
208
212
 
209
213
 
package/dist/functions.js CHANGED
@@ -30,7 +30,8 @@ import { assert, ConfigError, HttpError } from './errors.js';
30
30
 
31
31
  // The worker protocol. Only JSON-shaped data and byte buffers cross it.
32
32
 
33
-
33
+ /** `route.pattern` is the route key that matched, so one module can serve several routes without reading `request.url`. */
34
+
34
35
 
35
36
 
36
37
 
package/dist/index.js CHANGED
@@ -44,3 +44,5 @@ export {initProject, addRedirect} from './authoring.js';
44
44
  export {initProjectWith} from './init-with.js';
45
45
  export {collectDependencySet,renderPackageManifest,installSteps} from './project-dependencies.js';
46
46
 
47
+ export {installBundle,loadExtensionBundle,readBundleLock,parseBundleCatalog} from './extension-bundles.js';
48
+
package/dist/init-with.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, open, readFile, rm, unlink, lstat } from 'node:fs/promises';
1
+ import { mkdir, mkdtemp, open, readFile, rename, rm, unlink, lstat } from 'node:fs/promises';
2
2
  import { createRequire } from 'node:module';
3
3
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
@@ -11,6 +11,7 @@ import { inspectExtensionRevision } from './extensions.js';
11
11
  import { collectDependencySet, installSteps, renderPackageManifest } from './project-dependencies.js';
12
12
 
13
13
  import { ConfigError, assert } from './errors.js';
14
+ import { installBundle, loadExtensionBundle, } from './extension-bundles.js';
14
15
 
15
16
  /** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
16
17
  const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
@@ -24,6 +25,10 @@ const namePattern = /^[a-z][a-z0-9-]{0,63}$/, capabilityPattern = /^[a-z][a-z0-9
24
25
 
25
26
 
26
27
 
28
+
29
+
30
+
31
+
27
32
 
28
33
 
29
34
 
@@ -79,15 +84,19 @@ export function orderScaffolds(results )
79
84
  * conditions), imports it, and calls its `scaffold` export. Nothing is bundled; core never imports these packages
80
85
  * at build time. Refuses a missing package or a package without `scaffold` before anything is written.
81
86
  */
82
- async function loadScaffold(name , request , cwd , retry ) {
87
+ async function loadScaffold(name , request , cwd , retry , bundleProject ) {
83
88
  const pkg = packageName(name);
84
- let entry ;
85
- try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
86
- catch (error) {
87
- if (isCode(error, 'MODULE_NOT_FOUND')) throw new ConfigError(`Extension package ${pkg} is not installed in ${cwd}; run: npm install ${pkg}`);
88
- throw error;
89
+ let module ;
90
+ if (bundleProject) module = await loadExtensionBundle(bundleProject, name);
91
+ else {
92
+ let entry ;
93
+ try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
94
+ catch (error) {
95
+ if (isCode(error, 'MODULE_NOT_FOUND')) throw new ConfigError(`Extension package ${pkg} is not installed in ${cwd}; run: npm install ${pkg}`);
96
+ throw error;
97
+ }
98
+ module = await import(pathToFileURL(entry).href) ;
89
99
  }
90
- const module = await import(pathToFileURL(entry).href) ;
91
100
  const scaffold = module.scaffold;
92
101
  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`);
93
102
  let result ;
@@ -102,6 +111,8 @@ async function loadScaffold(name , request , cwd ,
102
111
  assert(record(result) && result.name === name, `${pkg} scaffold must return a result named ${name}`);
103
112
  assert(record(result.extensions) && record(result.routes), `${pkg} scaffold must return extensions and routes objects`);
104
113
  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`);
114
+ assert(result.hostBundleExports === undefined || (strings(result.hostBundleExports) && result.hostBundleExports.every(value => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value))), `${pkg} scaffold hostBundleExports must be JavaScript identifiers`);
115
+ if (request.distribution === 'bundle') assert(Array.isArray(result.hostBundleExports) && result.hostBundleExports.length > 0, `${pkg} scaffold must declare hostBundleExports for executable bundle distribution`);
105
116
  assert(result.acknowledged === undefined || (strings(result.acknowledged) && result.acknowledged.every(id => request.acknowledgements.includes(id) && id.startsWith(`${name}:`))), `${pkg} scaffold acknowledged may only list ${name}:<id> acknowledgements the operator passed`);
106
117
  assert(result.routeNotes === undefined || (strings(result.routeNotes) && result.routeNotes.every(note => note.length <= 300 && !/[\r\n]/.test(note))), `${pkg} scaffold routeNotes must be single-line strings`);
107
118
  assert(strings(result.nextSteps) && typeof result.readme === 'string', `${pkg} scaffold must return readme text and nextSteps strings`);
@@ -124,8 +135,17 @@ async function write(target , content , mode = 0o644)
124
135
  const file = await open(target, 'wx', mode);
125
136
  try { await file.writeFile(content); await file.sync(); } finally { await file.close(); }
126
137
  }
127
- function renderHost(names , results ) {
138
+ function renderHost(names , results , distribution ='npm') {
128
139
  const lines = [`// Generated by urlcode init --with ${names.join(',')}. Trusted operator code: keep it outside ${PROJECT_DIRECTORY}/ and review before serving.`];
140
+ if (distribution === 'bundle') {
141
+ lines.push("import {loadExtensionBundle} from '@jimhoyd/urlcode/extension-bundles';", "import {fileURLToPath} from 'node:url';", "const extensionBundleDirectory = fileURLToPath(new URL('.', import.meta.url));");
142
+ const exports = new Set ();
143
+ for (const result of results) {
144
+ const names = result.hostBundleExports ;
145
+ for (const name of names) { assert(!exports.has(name), `Bundle host export ${name} is declared by more than one extension`); exports.add(name); }
146
+ lines.push(`const {${names.join(', ')}} = await loadExtensionBundle(extensionBundleDirectory, '${result.name}');`);
147
+ }
148
+ }
129
149
  // Extensions that need the same module (node:url, for example) each list it; an identical line is written once so the host stays valid ESM.
130
150
  for (const result of results) for (const line of result.hostImports) if (!lines.includes(line)) lines.push(line);
131
151
  lines.push('');
@@ -142,23 +162,23 @@ function demote(markdown ) {
142
162
  let fence = false;
143
163
  return markdown.split('\n').map(line => { if (/^\s*(?:```|~~~)/.test(line)) fence = !fence; return !fence && /^#{1,5} /.test(line) ? `#${line}` : line; }).join('\n');
144
164
  }
145
- function renderDependencySection(directory , set ) {
165
+ function renderDependencySection(directory , set , bundle = false) {
146
166
  const rows = set.pins.map(pin => `- \`${pin.name}\` ${pin.version} (${pin.role})${pin.specifier === pin.version ? '' : ` installed from \`${pin.specifier}\``}`);
147
167
  const lines = ['## Dependencies', '',
148
- '`package.json` pins the runtime, every extension named in `--with` and their declared peers to the exact versions that were installed when this site was generated. Those versions were checked against each package\'s own `peerDependencies` as one set.', '',
168
+ bundle ? '`package.json` pins only the URLCode runtime. Executable extensions are locked GitHub Release bundles in `urlcode.extension-bundles.lock.json`, not npm dependencies.' : '`package.json` pins the runtime, every extension named in `--with` and their declared peers to the exact versions that were installed when this site was generated. Those versions were checked against each package\'s own `peerDependencies` as one set.', '',
149
169
  ...rows, '',
150
170
  ...installSteps(directory, set).flatMap(step => [step, '']),
151
171
  set.local ? 'At least one pin is a local path or tarball rather than a registry version: reproducing this install needs that path to exist, so keep it under your control or replace the specifier before publishing the site.' : 'The pins are registry versions; `npm install` resolves them without the network only if your cache or mirror already holds them.', '',
152
172
  'There is no upgrade command. Changing a pinned version today means editing `package.json` yourself and re-running `npm install`; review the extension changelogs first.', ''];
153
173
  return lines.join('\n');
154
174
  }
155
- function renderReadme(directory , names , results , starter , env , projectSha256 , set ) {
175
+ function renderReadme(directory , names , results , starter , env , projectSha256 , set , distribution ='npm') {
156
176
  const steps = [...(set ? installSteps(directory, set) : []), ...results.flatMap(result => result.nextSteps)];
157
177
  const parts = [`# ${basename(directory)}`, '',
158
- `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}"\`.`, '',
178
+ `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 ${distribution === 'bundle' ? 'the explicitly installed, verified extension bundles' : '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}"\`.`, '',
159
179
  '## Starter', '', `The starter files live in \`${PROJECT_DIRECTORY}/\`; add \`--project ${PROJECT_DIRECTORY}\` and the host file to the commands below.`, '', demote(starter).trim(), ''];
160
180
  for (const result of results) parts.push(`## Extension: ${result.name}`, '', result.readme.trim(), '');
161
- if (set) parts.push(renderDependencySection(directory, set));
181
+ if (set) parts.push(renderDependencySection(directory, set, distribution === 'bundle'));
162
182
  parts.push('## Next steps', '', ...steps.map((step, index) => `${index + 1}. ${step}`), '');
163
183
  if (Object.keys(env).length) parts.push('## Environment', '', ...Object.entries(env).map(([key, text]) => `- \`${key}\`: ${text}`), '');
164
184
  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.`, '');
@@ -170,7 +190,7 @@ function renderReadme(directory , names , results
170
190
  * `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
171
191
  * their scaffolds computed before anything is written, so a refusal leaves no directory behind.
172
192
  */
173
- export async function initProjectWith(destination , requested , { cwd = process.cwd(), manifest = true, pins, acknowledgements = [] } = {}) {
193
+ export async function initProjectWith(destination , requested , { cwd = process.cwd(), manifest = true, pins, acknowledgements = [], bundleRelease, bundleTransport } = {}) {
174
194
  assert(requested.length > 0, 'Provide at least one --with name');
175
195
  assert(new Set(requested).size === requested.length, 'Duplicate --with names');
176
196
  // --with is an unordered set: scaffolds see one canonical name order, and the emitted order comes from their declared requirements.
@@ -178,13 +198,20 @@ export async function initProjectWith(destination , requested
178
198
  const directory = resolve(destination), project = join(directory, PROJECT_DIRECTORY), hostFile = join(directory, HOST_FILE);
179
199
  assert(acknowledgements.every(id => acknowledgementPattern.test(id)), 'Use --ack <extension>:<id>, for example --ack store:public-write');
180
200
  const acked = [...new Set(acknowledgements)].sort();
181
- const request = { directory, project, hostFile, names: sorted, acknowledgements: acked };
201
+ const distribution =bundleRelease===undefined?'npm':'bundle';
202
+ const request = { directory, project, hostFile, names: sorted, acknowledgements: acked, distribution };
182
203
  const quote = (value ) => /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
183
- const retry = (id ) => ['urlcode init', quote(destination), '--with', requested.join(','), ...(manifest ? [] : ['--no-manifest']), ...[...(pins ?? [])].flatMap(([pkg, specifier]) => ['--pin', quote(`${pkg}=${specifier}`)]), ...[...acked, id].sort().flatMap(item => ['--ack', item])].join(' ');
204
+ const retry = (id ) => ['urlcode init', quote(destination), '--with', requested.join(','), ...(bundleRelease===undefined?[]:['--bundle-release',bundleRelease]), ...(manifest ? [] : ['--no-manifest']), ...[...(pins ?? [])].flatMap(([pkg, specifier]) => ['--pin', quote(`${pkg}=${specifier}`)]), ...[...acked, id].sort().flatMap(item => ['--ack', item])].join(' ');
184
205
  const results = [];
206
+ let bundleRoot ;
185
207
  const wipe = () => { for (const result of results) for (const file of result.files) if (file.content instanceof Uint8Array) file.content.fill(0); };
186
208
  try {
187
- for (const name of sorted) results.push(await loadScaffold(name, request, cwd, retry));
209
+ if (bundleRelease) {
210
+ await mkdir(dirname(directory), { recursive: true });
211
+ bundleRoot=await mkdtemp(join(dirname(directory),'.urlcode-bundle-init-'));
212
+ for (const name of sorted) await installBundle(bundleRoot,bundleRelease,name,bundleTransport);
213
+ }
214
+ for (const name of sorted) results.push(await loadScaffold(name, request, cwd, retry, bundleRoot));
188
215
  const consumed = new Set(results.flatMap(result => result.acknowledged ?? []));
189
216
  const unused = acked.filter(id => !consumed.has(id));
190
217
  assert(unused.length === 0, `--ack ${unused.join(', ')} has no effect here: no scaffold in --with (${sorted.join(', ')}) consumed it. Remove it, or check the extension name and id in that extension's documentation`);
@@ -202,11 +229,16 @@ export async function initProjectWith(destination , requested
202
229
  }
203
230
  // Also resolved before the destination exists: an incompatible or incompletely installed set refuses with
204
231
  // nothing written. It runs after the scaffold conflicts so a composition error is still reported as one.
205
- const dependencies = manifest ? await collectDependencySet(names, names.map(packageName), { cwd, ...(pins === undefined ? {} : { overrides: pins }) }) : undefined;
232
+ const dependencies = manifest ? await collectDependencySet(bundleRelease===undefined?names:[], bundleRelease===undefined?names.map(packageName):[], { cwd, ...(pins === undefined ? {} : { overrides: pins }) }) : undefined;
206
233
  await mkdir(dirname(directory), { recursive: true });
207
234
  await mkdir(directory, { mode: 0o700 }); // refuses an existing destination
208
235
  try {
209
236
  await initProject(project);
237
+ if (bundleRoot) {
238
+ await rename(join(bundleRoot,'.urlcode'),join(directory,'.urlcode'));
239
+ await rename(join(bundleRoot,'urlcode.extension-bundles.lock.json'),join(directory,'urlcode.extension-bundles.lock.json'));
240
+ await rm(bundleRoot,{recursive:true,force:true}); bundleRoot=undefined;
241
+ }
210
242
  const starter = await readFile(join(project, 'README.md'), 'utf8');
211
243
  await unlink(join(project, 'README.md')); // its content moves into the site README
212
244
  await unlink(join(project, mcpConfigFile)); // re-registered at the site root, pointing at app/
@@ -236,17 +268,17 @@ export async function initProjectWith(destination , requested
236
268
  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); }
237
269
  await write(target, file.content, file.mode ?? 0o644); written.add(target);
238
270
  }
239
- await write(hostFile, renderHost(names, results), 0o600);
271
+ await write(hostFile, renderHost(names, results, distribution), 0o600);
240
272
  if (dependencies) await write(join(directory, 'package.json'), renderPackageManifest(directory, dependencies));
241
- await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256, dependencies));
273
+ await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256, dependencies, distribution));
242
274
  await write(join(directory, '.gitignore'), 'node_modules/\ndata/\n.env\n.env.*\n');
243
275
  // The read-only MCP server for agents opened at the site root; --host-file and --allow-authoring stay operator choices.
244
- await write(join(directory, mcpConfigFile), renderMcpConfig(PROJECT_DIRECTORY));
276
+ await write(join(directory, mcpConfigFile), renderMcpConfig(PROJECT_DIRECTORY, { local: dependencies !== undefined }));
245
277
  // AGENTS.md: initProject writes the application-level file into app/ once it produces one (NEXT-STEPS 1.1);
246
278
  // nothing here overrides it. A site-level agent note would be assembled beside README.md at this point.
247
279
  return { directory, project, hostFile, extensions: [...names], projectSha256,
248
280
  nextSteps: [...(dependencies ? installSteps(directory, dependencies) : []), ...results.flatMap(result => result.nextSteps)],
249
281
  dependencies: dependencies?.pins ?? [] };
250
282
  } catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
251
- } finally { wipe(); }
283
+ } finally { if(bundleRoot)await rm(bundleRoot,{recursive:true,force:true}); wipe(); }
252
284
  }
@@ -166,7 +166,7 @@ export async function exportRoutes(options )
166
166
  keys(record(options.document),['version','routes']);validateDocument(options.document);
167
167
  if(Object.keys(options.document.routes).length>100000)throw new Error('Input exceeds 100,000 routes');
168
168
  for(const [path,config]of Object.entries(options.document.routes)) {
169
- try{keys(record(config),['redirect']);if(!config.redirect)throw new Error('Only redirects can be exported');keys(record(config.redirect),['url','status']);rows.push(row({path,url:config.redirect.url,status:config.redirect.status??302},rows.length+1));}
169
+ try{keys(record(config),['redirect']);if(!config.redirect)throw new Error('Only redirects can be exported');if(path.includes('*')||config.redirect.url.startsWith('/'))throw new Error('Suffix wildcards and root-relative destinations have no provider equivalent');keys(record(config.redirect),['url','status']);rows.push(row({path,url:config.redirect.url,status:config.redirect.status??302},rows.length+1));}
170
170
  catch{diagnostics.push({severity:'error',code:'runtime-required',source,path,message:'Only literal redirects with default methods and no extra behavior can be exported'});}
171
171
  }
172
172
  rows.sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0);
package/dist/match.js CHANGED
@@ -19,7 +19,7 @@ import { HttpError } from './errors.js';
19
19
 
20
20
  /** The part of a compiled route that request-time matching reads. router.ts widens it. */
21
21
 
22
-
22
+
23
23
 
24
24
 
25
25
 
@@ -52,6 +52,8 @@ export function parseTarget(target ) {
52
52
  if (parts.length > 32 || parts.some(p => p === '.' || p === '..')) throw new HttpError(400, 'Invalid path');
53
53
  return { path, parts, query: new URLSearchParams(query) };
54
54
  }
55
+ /** Longest suffix a `/**` redirect captures; a longer one is simply not matched. */
56
+ const wildcardMaxLength = 1024;
55
57
  export function matchRoute (compiled , target ) {
56
58
  const exact = compiled.exact.get(target.path);
57
59
  if (exact) return { route: exact, path: dict() };
@@ -64,7 +66,15 @@ export function matchRoute (compiled
64
66
  return p === actual;
65
67
  })) return { route, path };
66
68
  }
67
- for (const route of compiled.mounts) if (route.prefix !== undefined && (target.path.startsWith(route.prefix)||(route.extension&&target.path===route.prefix.slice(0,-1)))) return { route, path: dict() };
69
+ for (const route of compiled.mounts) {
70
+ if (route.wildcard) {
71
+ // `/prefix/**`: one or more whole segments, no empty segment (so a redirect can never gain a `//`), bounded length.
72
+ const rest = route.prefix !== undefined && target.path.startsWith(route.prefix) ? target.path.slice(route.prefix.length) : '';
73
+ if (rest && rest.length <= wildcardMaxLength && !rest.split('/').includes('')) { const path = dict (); path['**'] = rest; return { route, path }; }
74
+ continue;
75
+ }
76
+ if (route.prefix !== undefined && (target.path.startsWith(route.prefix)||(route.extension&&target.path===route.prefix.slice(0,-1)))) return { route, path: dict() };
77
+ }
68
78
  return null;
69
79
  }
70
80
  function scalar(value , type ) {
@@ -102,14 +112,21 @@ export function contextFor(route , path ,
102
112
  if (!p.validate(value)) throw new HttpError(400, 'Invalid parameter');
103
113
  (inputs[p.in] )[p.name] = value;
104
114
  }
115
+ if (route.wildcard) inputs.path['**'] = path['**'] ?? '';
105
116
  return { inputs, env: route.env, secrets: route.secrets };
106
117
  }
107
118
  // The router accepts a placeholder only for a declared path input, and a path
108
119
  // input always matches a segment, so the '' fallback is unreachable through a
109
120
  // compiled route; it keeps a direct call with an undeclared name from writing
110
121
  // the text "undefined" into the location.
122
+ // A root-relative destination (`/profiles/{id}`) is resolved against this placeholder origin only to normalize and encode it;
123
+ // the origin never appears in the Location.
124
+ const relativeBase = 'https://relative.invalid';
111
125
  export function redirectLocation(route , context , query ) {
112
- const location = new URL(route.redirect.url.replace(/\{([^}]+)\}/g, (_m, name ) => encodeURIComponent(context.inputs.path[name] ?? '')));
126
+ const relative = route.redirect.url.startsWith('/');
127
+ // `{**}` is the captured suffix: each segment encoded on its own, joined by the `/` that separated them.
128
+ const encode = (name ) => name === '**' ? (context.inputs.path['**'] ?? '').split('/').map(encodeURIComponent).join('/') : encodeURIComponent(context.inputs.path[name] ?? '');
129
+ const location = new URL(route.redirect.url.replace(/\{([^}]+)\}/g, (_m, name ) => encode(name)), relativeBase);
113
130
  function append(key , value ) {
114
131
  if (value === undefined) return;
115
132
  for (const item of Array.isArray(value) ? value : [value]) location.searchParams.append(key, String(item));
@@ -119,6 +136,7 @@ export function redirectLocation(route
119
136
  if (own(context.inputs.query, key)) append(key, context.inputs.query[key]);
120
137
  else for (const value of query.getAll(key)) append(key,value);
121
138
  }
122
- if (location.href.length > 16384) throw new HttpError(400, 'Redirect URL too long');
123
- return location.href;
139
+ const result = relative ? location.pathname + location.search + location.hash : location.href;
140
+ if (result.length > 16384) throw new HttpError(400, 'Redirect URL too long');
141
+ return result;
124
142
  }
package/dist/mcp.js CHANGED
@@ -98,7 +98,7 @@ export async function serveMcp(options ) {
98
98
  if(message.method==='initialize') {
99
99
  if(initialized){await error(id,-32600,'Already initialized');return;}
100
100
  if(typeof params.protocolVersion!=='string'||!object(params.capabilities)||!object(params.clientInfo)||typeof params.clientInfo.name!=='string'||typeof params.clientInfo.version!=='string'){await error(id,-32602,'Invalid initialize params');return;}
101
- initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.8'}}});return;
101
+ initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.5.0'}}});return;
102
102
  }
103
103
  if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
104
104
  if(!ready){await error(id,-32002,'Initialize first');return;}
package/dist/readiness.js CHANGED
@@ -119,11 +119,11 @@ export function projectPlan(compiled )
119
119
  if (route.extension || route.extensionPolicyNames?.length || route.proxy || route.signals?.length || route.match || route.conditional || route.function || route.middleware?.length || route.names.length) continue;
120
120
  // Required inputs need intentional fixtures; never invent business data.
121
121
  let context ;
122
- try { context = contextFor(route,{},new URLSearchParams(),new Headers()); } catch { continue; }
122
+ try { context = contextFor(route,route.wildcard ? {'**':'sample'} : {},new URLSearchParams(),new Headers()); } catch { continue; }
123
123
  if (route.request?.body?.required) continue;
124
124
  const files = route.asset instanceof Map ? route.asset : undefined;
125
125
  const prefix = route.prefix ?? '';
126
- const paths = route.static && files ? [...files.keys()].map(key => prefix + key.split('/').map(encodeURIComponent).join('/')) : [route.pattern];
126
+ const paths = route.wildcard ? [prefix + 'sample'] : route.static && files ? [...files.keys()].map(key => prefix + key.split('/').map(encodeURIComponent).join('/')) : [route.pattern];
127
127
  for (const path of paths) for (const method of route.methods) {
128
128
  if (!['GET','HEAD'].includes(method)) continue;
129
129
  const test = {path,method,status:(route.redirect ? route.redirect.status || 302 : route.reply?.status || 200)};
package/dist/router.js CHANGED
@@ -73,7 +73,10 @@ export async function compileRoutes(loaded , bindings
73
73
  const parts = segments(pattern);
74
74
  assert(!pattern.startsWith('/_urlcode'), 'The /_urlcode prefix is reserved for runtime operations');
75
75
  const names = parts.map(parameterName).filter((name) => Boolean(name));
76
- assert(!pattern.includes('*') || ((config.static || config.extension) && pattern.endsWith('/*') && parts.filter(p => p.includes('*')).length === 1 && parts.at(-1) === '*' && !names.length), 'Only static or extension routes support a terminal /* wildcard');
76
+ // `/prefix/**` is the redirect-only suffix wildcard: a literal prefix, one terminal `**`, no path parameters.
77
+ const wildcardRedirect = Boolean(config.redirect) && pattern.endsWith('/**');
78
+ if (wildcardRedirect) assert(pattern !== '/**' && pattern.indexOf('*') === pattern.length - 2 && parts.at(-1) === '**' && !names.length && !config.conditional, 'A /** wildcard redirect needs a literal prefix, one terminal **, no path parameters and no conditional');
79
+ assert(wildcardRedirect || !pattern.includes('*') || ((config.static || config.extension) && pattern.endsWith('/*') && parts.filter(p => p.includes('*')).length === 1 && parts.at(-1) === '*' && !names.length), 'Only static or extension routes support a terminal /* wildcard; a redirect uses a terminal /** instead');
77
80
  assert(!config.static || pattern.endsWith('/*'), 'Static routes require a terminal /* wildcard');
78
81
  if (config.page || config.download || config.static) assert((config.methods || methodsDefault).every(m => methodsDefault.includes(m)), 'Asset routes support only GET and HEAD');
79
82
  assert(new Set(names).size === names.length, 'Duplicate path parameter');
@@ -167,14 +170,21 @@ export async function compileRoutes(loaded , bindings
167
170
  if (declaredRedirect) {
168
171
  const value = declaredRedirect.url;
169
172
  assert(!/[\u0000-\u0020\u007f\\]/u.test(value), 'Redirect URL contains unsafe characters');
173
+ // A root-relative destination (`/profiles/{id}`) stays on this site: a single leading slash, so never `//host`, and no dot segments.
174
+ const relative = value.startsWith('/') && !value.startsWith('//');
170
175
  let dest ;
171
- try { dest = new URL(value); } catch { assert(false, 'Redirect URL must be absolute HTTP(S)'); }
172
- assert(['http:', 'https:'].includes(dest.protocol) && !dest.username && !dest.password, 'Redirect must use HTTP(S) without credentials');
173
- const authority = value.match(/^https?:\/\/([^/?#]+)/i)?.[1];
174
- assert(authority && !/[{}]/.test(authority) && !/[{}]/.test(dest.search + dest.hash), 'Redirect placeholders are allowed only in path segments');
176
+ try { dest = new URL(value, relative ? 'https://relative.invalid' : undefined); } catch { assert(false, 'Redirect URL must be an absolute HTTP(S) URL or a root-relative path'); }
177
+ if (relative) assert(dest.origin === 'https://relative.invalid' && !value.split(/[?#]/, 1)[0] .split('/').some(part => part === '.' || part === '..'), 'Root-relative redirect must be a plain path without dot segments');
178
+ else {
179
+ assert(['http:', 'https:'].includes(dest.protocol) && !dest.username && !dest.password, 'Redirect must use HTTP(S) without credentials');
180
+ const authority = value.match(/^https?:\/\/([^/?#]+)/i)?.[1];
181
+ assert(authority && !/[{}]/.test(authority), 'Redirect placeholders are allowed only in path segments');
182
+ }
183
+ assert(!/[{}]/.test(dest.search + dest.hash), 'Redirect placeholders are allowed only in path segments');
175
184
  const placeholders = [...value.matchAll(/\{([^}]+)\}/g)].map(m => m[1] );
176
- assert(placeholders.every(n => token.test(n) && names.includes(n)), 'Redirect placeholder must reference a declared path input');
177
- assert(!/[{}]/.test(value.replace(/\{[A-Za-z_][A-Za-z0-9_]*\}/g, '')), 'Invalid redirect placeholder');
185
+ assert(placeholders.every(n => (n === '**' && wildcardRedirect) || (token.test(n) && names.includes(n))), 'Redirect placeholder must reference a declared path input');
186
+ assert(placeholders.filter(n => n === '**').length <= 1, '{**} may appear once in a redirect destination');
187
+ assert(!/[{}]/.test(value.replace(/\{(?:[A-Za-z_][A-Za-z0-9_]*|\*\*)\}/g, '')), 'Invalid redirect placeholder');
178
188
  const query = declaredRedirect.query || {};
179
189
  const reserved = new Set(dest.searchParams.keys());
180
190
  for (const [key, ref] of Object.entries(query.map || {})) {
@@ -195,7 +205,8 @@ export async function compileRoutes(loaded , bindings
195
205
  route.function = { ...declaredFunction, source, export: declaredFunction.export || 'default' };
196
206
  for (const ref of Object.values(declaredFunction.args || {})) referenceCheck(ref, route, true);
197
207
  }
198
- if (config.static || config.extension) { route.prefix = pattern.slice(0, -1); mounts.push(route); }
208
+ if (wildcardRedirect) { route.prefix = pattern.slice(0, -2); route.wildcard = true; mounts.push(route); }
209
+ else if (config.static || config.extension) { route.prefix = pattern.slice(0, -1); mounts.push(route); }
199
210
  else if (!names.length) exact.set(pattern, route);
200
211
  else {
201
212
  assert(dynamic.length < 1000, 'Maximum 1000 parameterized routes per snapshot');
@@ -215,6 +226,7 @@ export async function compileRoutes(loaded , bindings
215
226
  byLength.get(route.parts.length) .push(route);
216
227
  }
217
228
  for(const mount of mounts.filter(route=>route.extension)){const base=mount.parts.slice(0,-1);for(const candidate of [...exact.values(),...dynamic,...mounts]){if(candidate===mount)continue;const parts=candidate.parts;const shared=Math.min(base.length,parts.length-(candidate.prefix?1:0));const compatible=base.slice(0,shared).every((part,index)=>part===parts[index]||parameterName(parts[index] ));assert(!compatible||(!candidate.prefix&&parts.length<base.length),'Extension mount overlaps another route');}}
229
+ assert(new Set(mounts.map(mount => mount.prefix)).size === mounts.length, 'A /** wildcard redirect cannot share its prefix with a static or extension mount');
218
230
  mounts.sort((a,b) => b.prefix .length - a.prefix .length);
219
231
  assert(performance.now()<deadline, 'Route compilation deadline exceeded');
220
232
  return { exact, byLength, mounts, modules: [...modules.keys()], count: exact.size + dynamic.length + mounts.length };
package/dist/runtime.js CHANGED
@@ -299,6 +299,7 @@ export async function createRuntime(project , rawOptions
299
299
  }
300
300
  if (native && !route.middleware.length) return await finishResponse(native);
301
301
  context.args = Object.fromEntries(Object.entries(route.function?.args || {}).map(([key, ref]) => [key, resolveValue(ref, context)]));
302
+ context.route = { pattern: route.pattern };
302
303
  // Uniform for `function` and `middleware` alike: a route dispatches
303
304
  // through the sandboxed worker pool only when it declares
304
305
  // `sandbox: true`; every other route runs trusted, in-process
@@ -5,8 +5,13 @@ export declare const mcpConfigFile = ".mcp.json";
5
5
  /**
6
6
  * Renders `.mcp.json` registering the read-only `urlcode mcp` server for the project at `project`, relative
7
7
  * to the file. `--allow-authoring` is deliberately absent: the operator adds it by hand when they want it.
8
+ * `local` is for a project whose package.json pins the runtime: the server is then launched through `npx --no`,
9
+ * which uses the installed copy and refuses to fetch anything (a bare `urlcode` is not on PATH for a local-only install,
10
+ * and `npx urlcode` would resolve an unrelated registry package). Without a pin the bare command is kept for global installs.
8
11
  */
9
- export declare function renderMcpConfig(project?: string): string;
12
+ export declare function renderMcpConfig(project?: string, { local }?: {
13
+ local?: boolean;
14
+ }): string;
10
15
  /**
11
16
  * The application-level AGENTS.md written by `urlcode init`. Built from the
12
17
  * installed runtime's capability catalog, so it names only the handlers,
@@ -6,7 +6,7 @@ export interface InitOptions {
6
6
  */
7
7
  manifest?: DependencySet | undefined;
8
8
  /** `default` (function, middleware, redirect) or `page`: urlcode.yaml, public/index.html, a README and fixtures only. */
9
- template?: 'default' | 'page' | undefined;
9
+ template?: 'default' | 'page' | 'redirects' | undefined;
10
10
  }
11
11
  export declare function initProject(destination: string, { manifest, template }?: InitOptions): Promise<string>;
12
12
  export declare function addRedirect(project: string, destination: string, alias?: string | undefined): Promise<string>;
@@ -79,11 +79,23 @@ export interface TaskShape {
79
79
  }
80
80
  /** Established by running `urlcode validate` and `urlcode test` on each shape; test/context.test.ts compiles every `yaml` entry so this cannot drift from the runtime. */
81
81
  export declare const redirectShapes: TaskShape[];
82
+ /** A complete, paste-ready project skeleton: every supported shape merged into one urlcode.yaml, plus the start script. */
83
+ export interface TaskStarter {
84
+ file: string;
85
+ yaml: string;
86
+ /** Files the yaml references that must exist, with minimal content. */
87
+ companions: Record<string, string>;
88
+ packageScripts: Record<string, string>;
89
+ note: string;
90
+ }
91
+ /** Merges every supported shape's YAML; test/context-task.test.ts compiles the result, so it cannot drift from the runtime. */
92
+ export declare function redirectStarter(): TaskStarter;
82
93
  export interface TaskContext {
83
94
  urlcode: string;
84
95
  schema: '1';
85
96
  task: ContextTask;
86
97
  shapes?: TaskShape[];
98
+ starter?: TaskStarter;
87
99
  project?: {
88
100
  entry: string;
89
101
  routes: number;
@@ -30,6 +30,19 @@ export interface ExtensionLock {
30
30
  }
31
31
  /** Parse an untrusted catalog only after its GitHub attestation was verified by the caller. */
32
32
  export declare function parseCatalog(bytes: Uint8Array, requestedTag: string): Catalog;
33
+ export interface TarFile {
34
+ path: string;
35
+ bytes: Uint8Array;
36
+ }
37
+ export interface ArchiveLimits {
38
+ archive: number;
39
+ expanded: number;
40
+ files: number;
41
+ file: number;
42
+ label: string;
43
+ }
44
+ /** A minimal tar reader: only regular files are accepted, before any write occurs. */
45
+ export declare function readBoundedTgz(source: Uint8Array, limits: ArchiveLimits): TarFile[];
33
46
  export declare function extractArtifact(bytes: Uint8Array, entry: ArtifactEntry, destination: string): Promise<void>;
34
47
  export declare function readLock(project: string): Promise<ExtensionLock>;
35
48
  export declare function writeLock(project: string, lock: ExtensionLock): Promise<void>;
@@ -0,0 +1,49 @@
1
+ /** Verified, executable first-party bundles. Unlike extension artifacts, these are trusted operator code. */
2
+ export declare const BUNDLE_REPOSITORY = "jimhoyd-com/urlcode";
3
+ export declare const BUNDLE_WORKFLOW = "jimhoyd-com/urlcode/.github/workflows/extension-bundles.yml";
4
+ export interface BundleEntry {
5
+ name: string;
6
+ version: string;
7
+ asset: string;
8
+ sha256: string;
9
+ entry: string;
10
+ }
11
+ export interface BundleCatalog {
12
+ format: 1;
13
+ tag: string;
14
+ commit: string;
15
+ coreVersion: string;
16
+ bundles: BundleEntry[];
17
+ revoked: {
18
+ sha256: string;
19
+ reason: string;
20
+ }[];
21
+ }
22
+ export interface LockedBundle extends BundleEntry {
23
+ catalog: {
24
+ tag: string;
25
+ commit: string;
26
+ };
27
+ coreVersion: string;
28
+ }
29
+ export interface BundleLock {
30
+ format: 1;
31
+ bundles: LockedBundle[];
32
+ }
33
+ export interface BundleTransport {
34
+ release(tag: string): Promise<{
35
+ name: string;
36
+ url: string;
37
+ }[]>;
38
+ download(url: string): Promise<Uint8Array>;
39
+ attest(path: string, release: string): Promise<void>;
40
+ }
41
+ /** Parse only a catalog whose attestation was already verified against the requested immutable tag. */
42
+ export declare function parseBundleCatalog(bytes: Uint8Array, requested: string): BundleCatalog;
43
+ export declare function extractBundle(bytes: Uint8Array, item: Pick<LockedBundle, 'name' | 'version' | 'entry' | 'sha256' | 'coreVersion'>, destination: string): Promise<void>;
44
+ export declare function bundleCachePath(project: string, sha256: string): string;
45
+ export declare function readBundleLock(project: string): Promise<BundleLock>;
46
+ export declare const githubBundleTransport: BundleTransport;
47
+ export declare function installBundle(project: string, release: string, bundleName: string, transport?: BundleTransport): Promise<BundleLock>;
48
+ /** Explicit host-only loader. It never reads project YAML, downloads, updates, or discovers code. */
49
+ export declare function loadExtensionBundle(project: string, bundleName: string): Promise<Record<string, unknown>>;
@@ -185,6 +185,8 @@ export interface ScaffoldRequest {
185
185
  hostFile: string;
186
186
  /** Every extension name being scaffolded together, including this one, in a canonical (sorted) order that is independent of the `--with` spelling. */
187
187
  names: readonly string[];
188
+ /** `npm` resolves extension packages from the operator's install; `bundle` resolves only already-verified, locked release bundles. */
189
+ distribution?: 'npm' | 'bundle';
188
190
  /**
189
191
  * Operator acknowledgements from repeated `--ack <extension>:<id>` flags, sorted and de-duplicated; empty when none. Core treats
190
192
  * them as opaque strings and never invents one. An extension reads only the ones qualified with its own name. To require one, throw
@@ -224,6 +226,8 @@ export interface ScaffoldResult {
224
226
  hostSetup: string[];
225
227
  hostEntries: string[];
226
228
  hostClose?: string[];
229
+ /** Named exports core may bind from this extension's already-verified executable bundle. Required for bundle distribution; never a project-controlled module reference. */
230
+ hostBundleExports?: string[];
227
231
  /** Files written relative to `directory` with their modes; never inside the project, never overwriting. */
228
232
  files: ScaffoldFile[];
229
233
  /** Markdown appended to README.md under a heading core adds; the numbered steps merged in the resolved order. */
@@ -36,8 +36,12 @@ export interface FunctionWorkerData {
36
36
  dependencies: Record<string, string[]>;
37
37
  entries: [string, string][];
38
38
  }
39
+ /** `route.pattern` is the route key that matched, so one module can serve several routes without reading `request.url`. */
39
40
  export type FunctionContext = RequestContext & {
40
41
  args?: Record<string, ParameterValue>;
42
+ route?: {
43
+ pattern: string;
44
+ };
41
45
  };
42
46
  export interface FunctionWorkerRequest {
43
47
  id: string;
@@ -40,3 +40,5 @@ export { initProject, addRedirect } from './authoring.ts';
40
40
  export { initProjectWith } from './init-with.ts';
41
41
  export { collectDependencySet, renderPackageManifest, installSteps } from './project-dependencies.ts';
42
42
  export type { ScaffoldRequest, ScaffoldResult, ScaffoldFile } from './extensions.ts';
43
+ export { installBundle, loadExtensionBundle, readBundleLock, parseBundleCatalog } from './extension-bundles.ts';
44
+ export type { BundleCatalog, BundleEntry, BundleLock, LockedBundle, BundleTransport } from './extension-bundles.ts';
@@ -1,5 +1,6 @@
1
1
  import type { ScaffoldResult } from './extensions.ts';
2
2
  import type { DependencyPin } from './project-dependencies.ts';
3
+ import { type BundleTransport } from './extension-bundles.ts';
3
4
  export interface InitWithOptions {
4
5
  cwd?: string | undefined;
5
6
  /** Default true: record exact pins for core, the named extensions and their declared peers. */
@@ -8,6 +9,10 @@ export interface InitWithOptions {
8
9
  pins?: ReadonlyMap<string, string> | undefined;
9
10
  /** `--ack <extension>:<id>`, repeatable: opaque qualified acknowledgements handed to every scaffold. Core refuses one that no scaffold consumed. */
10
11
  acknowledgements?: readonly string[] | undefined;
12
+ /** Immutable signed release used instead of resolving executable extension packages from npm. */
13
+ bundleRelease?: string | undefined;
14
+ /** Test-only transport injection; production uses GitHub attestation verification. */
15
+ bundleTransport?: BundleTransport | undefined;
11
16
  }
12
17
  export interface InitWithResult {
13
18
  directory: string;
@@ -30,4 +35,4 @@ export declare function orderScaffolds(results: readonly ScaffoldResult[]): Scaf
30
35
  * `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
31
36
  * their scaffolds computed before anything is written, so a refusal leaves no directory behind.
32
37
  */
33
- export declare function initProjectWith(destination: string, requested: readonly string[], { cwd, manifest, pins, acknowledgements }?: InitWithOptions): Promise<InitWithResult>;
38
+ export declare function initProjectWith(destination: string, requested: readonly string[], { cwd, manifest, pins, acknowledgements, bundleRelease, bundleTransport }?: InitWithOptions): Promise<InitWithResult>;
@@ -37,6 +37,7 @@ export interface MatchableRoute {
37
37
  pattern: string;
38
38
  parts: string[];
39
39
  prefix?: string;
40
+ wildcard?: boolean;
40
41
  extension?: string;
41
42
  parameters: CompiledParameter[];
42
43
  env: Record<string, string>;
@@ -264,6 +264,7 @@ export interface CompiledRoute extends Omit<RouteConfig, 'methods' | 'parameters
264
264
  reply?: Reply;
265
265
  expiresAt?: number;
266
266
  prefix?: string;
267
+ wildcard?: boolean;
267
268
  middleware: CompiledMiddleware[];
268
269
  function?: CompiledFunction;
269
270
  respond?: RespondSpec;