@volter/world-runtime 2.0.12 → 2.0.14
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/dist/src/app-url.d.ts +7 -1
- package/dist/src/app-url.js +113 -40
- package/dist/src/cli.js +9 -5
- package/dist/src/redirect-proxy.js +6 -2
- package/dist/src/served-world.d.ts +5 -1
- package/dist/src/served-world.js +23 -0
- package/dist/src/world-view.js +44 -3
- package/package.json +5 -5
- package/src/app-url.ts +124 -43
- package/src/cli.ts +8 -5
- package/src/redirect-proxy.ts +5 -1
- package/src/served-world.ts +16 -1
- package/src/world-view.ts +32 -3
package/dist/src/app-url.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export type AppUrlRecord = {
|
|
|
11
11
|
/** the application's own production hostnames (`--host`), routed to `url` inside the World as DNS routes them to
|
|
12
12
|
* its host in production: the injector, the socket backstop and the redirect proxy read them from this record */
|
|
13
13
|
hosts?: string[];
|
|
14
|
+
/** the application's name in the World (`--app`); absent: `app`, the one a command names without it */
|
|
15
|
+
app?: string;
|
|
14
16
|
};
|
|
15
17
|
export declare function appUrlFile(root: string, name: string): string;
|
|
16
18
|
/** Record `url` as the app endpoint of the RUNNING/booted instance `name`. The instance must
|
|
@@ -20,10 +22,12 @@ export declare function setAppUrl(name: string, url: string, options?: {
|
|
|
20
22
|
root?: string;
|
|
21
23
|
via?: Exclude<AppUrlSource, 'service'>;
|
|
22
24
|
detail?: string;
|
|
25
|
+
app?: string;
|
|
23
26
|
}): AppUrlRecord;
|
|
24
27
|
/** Record `hosts` as the app's own production hostnames, beside its recorded URL (or its `app` service's). */
|
|
25
28
|
export declare function addAppHosts(name: string, hosts: string[], options?: {
|
|
26
29
|
root?: string;
|
|
30
|
+
app?: string;
|
|
27
31
|
}): AppUrlRecord;
|
|
28
32
|
/**
|
|
29
33
|
*`--detect <pid|port>`: resolve the app URL off what is ALREADY listening and record it.
|
|
@@ -34,6 +38,7 @@ export declare function addAppHosts(name: string, hosts: string[], options?: {
|
|
|
34
38
|
*/
|
|
35
39
|
export declare function detectAppUrl(name: string, target: string, options?: {
|
|
36
40
|
root?: string;
|
|
41
|
+
app?: string;
|
|
37
42
|
}): Promise<AppUrlRecord>;
|
|
38
43
|
/**
|
|
39
44
|
* The recorded app URL of `name`, or the `app` service's assigned URL when nothing was recorded
|
|
@@ -42,6 +47,7 @@ export declare function detectAppUrl(name: string, target: string, options?: {
|
|
|
42
47
|
*/
|
|
43
48
|
export declare function readAppUrl(name: string, options?: {
|
|
44
49
|
root?: string;
|
|
50
|
+
app?: string;
|
|
45
51
|
}): AppUrlRecord | null;
|
|
46
52
|
/** The message the CLI prints when no app URL is known — the registration recipe, not a guess. */
|
|
47
|
-
export declare function appUrlUnsetMessage(name: string): string;
|
|
53
|
+
export declare function appUrlUnsetMessage(name: string, app?: string): string;
|
package/dist/src/app-url.js
CHANGED
|
@@ -19,13 +19,53 @@
|
|
|
19
19
|
// once, at the caller's request, and stores what it saw. The record lives in the instance dir, so
|
|
20
20
|
// a re-`up` (which wipes the dir) or `down --purge` clears it: a recorded URL never outlives the
|
|
21
21
|
// instance it described.
|
|
22
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { withFileLock } from '@volter/world-core';
|
|
23
24
|
import { join } from 'node:path';
|
|
24
25
|
import { spawnSync } from 'node:child_process';
|
|
25
26
|
import net from 'node:net';
|
|
26
27
|
import { instanceDir, statusWorld } from "./runtime.js";
|
|
27
28
|
import { loadInject } from "./inject-map.js";
|
|
28
29
|
import { packFacts } from "./pack-facts.js";
|
|
30
|
+
const APP_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
31
|
+
const DEFAULT_APP = 'app';
|
|
32
|
+
function appName(app) {
|
|
33
|
+
const name = app ?? DEFAULT_APP;
|
|
34
|
+
if (!APP_NAME.test(name))
|
|
35
|
+
throw new Error(`volter-world app-url: --app "${name}" is not an application name (lower-case letters, digits and -)`);
|
|
36
|
+
return name;
|
|
37
|
+
}
|
|
38
|
+
function readFile(root, name) {
|
|
39
|
+
const file = appUrlFile(root, name);
|
|
40
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
|
|
41
|
+
}
|
|
42
|
+
function entryOf(file, app) {
|
|
43
|
+
if (app !== DEFAULT_APP)
|
|
44
|
+
return file.apps && Object.hasOwn(file.apps, app) ? file.apps[app] : undefined;
|
|
45
|
+
const { apps: _others, ...own } = file;
|
|
46
|
+
return own.url !== undefined || own.hosts !== undefined ? own : undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Write one application's entry, keeping every other application's: under the record's lock (several booters record
|
|
49
|
+
* their applications at once), replaced whole so a reader never meets half a file. */
|
|
50
|
+
function writeEntry(root, name, app, build) {
|
|
51
|
+
// the whole read, check, merge and write under one lock (withFileLock is not reentrant: nothing inside takes it)
|
|
52
|
+
return withFileLock(`${appUrlFile(root, name)}.lock`, () => {
|
|
53
|
+
const file = readFile(root, name);
|
|
54
|
+
const { entry, result } = build(file);
|
|
55
|
+
writeEntryLocked(root, name, app, file, entry);
|
|
56
|
+
return result;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function writeEntryLocked(root, name, app, file, entry) {
|
|
60
|
+
const { app: _name, ...stored } = entry;
|
|
61
|
+
const next = app === DEFAULT_APP
|
|
62
|
+
? { ...stored, ...(file.apps ? { apps: file.apps } : {}) }
|
|
63
|
+
: { ...Object.fromEntries(Object.entries(file).filter(([k]) => k !== 'apps')), apps: { ...(file.apps ?? {}), [app]: stored } };
|
|
64
|
+
const target = appUrlFile(root, name);
|
|
65
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
66
|
+
writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`);
|
|
67
|
+
renameSync(temp, target);
|
|
68
|
+
}
|
|
29
69
|
export function appUrlFile(root, name) {
|
|
30
70
|
return join(instanceDir(root, name), 'app-url.json');
|
|
31
71
|
}
|
|
@@ -47,24 +87,27 @@ function assertHttpUrl(url) {
|
|
|
47
87
|
* deliberately NOT probed — the booter declaring "this is where I put the app" is the truth. */
|
|
48
88
|
export function setAppUrl(name, url, options = {}) {
|
|
49
89
|
const root = options.root ?? process.cwd();
|
|
90
|
+
const app = appName(options.app);
|
|
50
91
|
const status = statusWorld(name, root); // throws "World instance not found" — the loud path
|
|
51
92
|
assertHttpUrl(url);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
93
|
+
return writeEntry(root, name, app, (file) => {
|
|
94
|
+
// a new URL keeps the hosts recorded for the app (they name the app, not where it listens)
|
|
95
|
+
const hosts = entryOf(file, app)?.hosts;
|
|
96
|
+
const record = {
|
|
97
|
+
world: status.name,
|
|
98
|
+
url,
|
|
99
|
+
via: options.via ?? 'set',
|
|
100
|
+
recordedAt: new Date().toISOString(),
|
|
101
|
+
...(options.detail === undefined ? {} : { detail: options.detail }),
|
|
102
|
+
...(hosts?.length ? { hosts } : {}),
|
|
103
|
+
};
|
|
104
|
+
return { entry: record, result: app === DEFAULT_APP ? record : { ...record, app } };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** A hostname the app may answer as: a DNS name (not an address, not loopback) that no twin the World runs serves,
|
|
108
|
+
* not the host of this World's own origin or of a twin it runs (their requests carry the World key), and not one
|
|
109
|
+
* another of its applications answers. A vendor the World runs no twin of can be the application itself. */
|
|
110
|
+
function assertAppHost(host, world, root, app, file) {
|
|
68
111
|
const name = host.trim().toLowerCase().replace(/\.$/, '');
|
|
69
112
|
// a wildcard (`*.dub.link`) stands for every name below its parent, as a DNS wildcard record does; its parent has two
|
|
70
113
|
// labels at least (never a bare TLD). Whether the parent is a public suffix (co.uk, github.io) is the operator's word:
|
|
@@ -75,18 +118,22 @@ function assertAppHost(host, world, root) {
|
|
|
75
118
|
throw new Error(`volter-world app-url: --host "${host}" is not a DNS hostname the app answers as (e.g. app.example.com, or *.example.com)`);
|
|
76
119
|
}
|
|
77
120
|
const inject = loadInject();
|
|
78
|
-
|
|
121
|
+
const status = statusWorld(world, root);
|
|
122
|
+
// the twins the World runs, by vendor key, as its injector reads them from the World env (a service's id is not its
|
|
123
|
+
// vendor key: the aws service serves s3, dynamodb and others; a stripe twin may be a service named payments)
|
|
124
|
+
const twins = inject.readMap(status.env);
|
|
125
|
+
const runs = (vendor) => Object.hasOwn(twins, vendor);
|
|
126
|
+
let vendor = Object.keys(inject.VENDOR_HOSTS).find((v) => runs(v) && inject.VENDOR_HOSTS[v](bare));
|
|
79
127
|
if (!vendor && wildcard) {
|
|
80
128
|
// a wildcard is refused when any name a vendor's rule can give its twin lies under it, or it lies under a vendor's
|
|
81
129
|
// host suffix: the rules' names, read from the descriptors (a host, a suffix, a pattern's literal tail) and from
|
|
82
130
|
// the hand table's predicates (their quoted names)
|
|
83
131
|
const names = vendorNames(inject.VENDOR_HOSTS);
|
|
84
132
|
const dotted = `.${bare}`;
|
|
85
|
-
vendor = names.find((n) => n.name === bare || n.name.endsWith(dotted) || (n.suffix && dotted.endsWith(n.name.startsWith('.') ? n.name : `.${n.name}`)))?.vendor;
|
|
133
|
+
vendor = names.find((n) => runs(n.vendor) && (n.name === bare || n.name.endsWith(dotted) || (n.suffix && dotted.endsWith(n.name.startsWith('.') ? n.name : `.${n.name}`))))?.vendor;
|
|
86
134
|
}
|
|
87
135
|
if (vendor)
|
|
88
|
-
throw new Error(`volter-world app-url: --host ${name} is ${vendor}'s host, which its twin serves; an app cannot answer as it`);
|
|
89
|
-
const status = statusWorld(world, root);
|
|
136
|
+
throw new Error(`volter-world app-url: --host ${name} is ${vendor}'s host, which its twin serves in this World; an app cannot answer as it while it runs`);
|
|
90
137
|
const own = [status.env.VOLTER_WORLD ?? '', ...Object.values(status.services).map((service) => service.url ?? '')]
|
|
91
138
|
.flatMap((url) => { try {
|
|
92
139
|
return [new URL(url).hostname.toLowerCase()];
|
|
@@ -96,6 +143,17 @@ function assertAppHost(host, world, root) {
|
|
|
96
143
|
} });
|
|
97
144
|
if (own.some((h) => h === name || (wildcard && h.endsWith(`.${bare}`))))
|
|
98
145
|
throw new Error(`volter-world app-url: --host ${name} is this World's own origin or a twin's; an app cannot answer as it`);
|
|
146
|
+
// one application per hostname: a name, or a wildcard over it, another application already answers
|
|
147
|
+
for (const other of [DEFAULT_APP, ...Object.keys(file.apps ?? {})]) {
|
|
148
|
+
if (other === app)
|
|
149
|
+
continue;
|
|
150
|
+
const held = entryOf(file, other)?.hosts ?? [];
|
|
151
|
+
// a wildcard answers every name below its parent and never the parent, as a DNS wildcard does, so `*.example.org`
|
|
152
|
+
// and `example.org` are two applications' without overlap
|
|
153
|
+
const clash = held.find((h) => h === name || (h.startsWith('*.') && name.endsWith(h.slice(1)) && name !== h) || (wildcard && h.endsWith(`.${bare}`)));
|
|
154
|
+
if (clash)
|
|
155
|
+
throw new Error(`volter-world app-url: --host ${name} overlaps ${clash}, which the application "${other}" answers; a hostname belongs to one application`);
|
|
156
|
+
}
|
|
99
157
|
return name;
|
|
100
158
|
}
|
|
101
159
|
/** Every hostname the vendor table can name, with its vendor: a descriptor rule's host, suffix, or a host pattern's
|
|
@@ -131,14 +189,20 @@ function vendorNames(table) {
|
|
|
131
189
|
/** Record `hosts` as the app's own production hostnames, beside its recorded URL (or its `app` service's). */
|
|
132
190
|
export function addAppHosts(name, hosts, options = {}) {
|
|
133
191
|
const root = options.root ?? process.cwd();
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
192
|
+
const app = appName(options.app);
|
|
193
|
+
const status = statusWorld(name, root);
|
|
194
|
+
return writeEntry(root, name, app, (file) => {
|
|
195
|
+
// checked against the record as it stands under the lock, so two booters never both take one hostname
|
|
196
|
+
const current = appUrlOf(file, status, app);
|
|
197
|
+
if (!current)
|
|
198
|
+
throw new Error(appUrlUnsetMessage(name, app));
|
|
199
|
+
const all = [...new Set([...(current.hosts ?? []), ...hosts.map((host) => assertAppHost(host, name, root, app, file))])].sort();
|
|
200
|
+
// hosts beside the World's own service of the application's name keep no URL: they follow the service wherever a
|
|
201
|
+
// boot puts it
|
|
202
|
+
const { app: _name, ...stored } = current;
|
|
203
|
+
const entry = current.via === 'service' ? { world: current.world, via: 'service', hosts: all } : { ...stored, hosts: all };
|
|
204
|
+
return { entry, result: { ...current, hosts: all } };
|
|
205
|
+
});
|
|
142
206
|
}
|
|
143
207
|
/** The listening TCP ports of a live pid, via lsof (macOS + Linux). */
|
|
144
208
|
function listeningTcpPorts(pid) {
|
|
@@ -214,24 +278,33 @@ export async function detectAppUrl(name, target, options = {}) {
|
|
|
214
278
|
*/
|
|
215
279
|
export function readAppUrl(name, options = {}) {
|
|
216
280
|
const root = options.root ?? process.cwd();
|
|
281
|
+
const app = appName(options.app);
|
|
217
282
|
const status = statusWorld(name, root); // loud when the world does not exist at all
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
283
|
+
return appUrlOf(readFile(root, name), status, app);
|
|
284
|
+
}
|
|
285
|
+
/** One application's URL from the record `file` and the instance `status`: the recorded URL, or the World's service
|
|
286
|
+
* of the application's name. */
|
|
287
|
+
function appUrlOf(file, status, app) {
|
|
288
|
+
const named = app === DEFAULT_APP ? {} : { app };
|
|
289
|
+
const service = status.services[app];
|
|
290
|
+
const record = entryOf(file, app);
|
|
291
|
+
if (record) {
|
|
292
|
+
// hosts recorded beside the World's own service of the application's name: its URL is this boot's
|
|
293
|
+
if (!record.url && service?.url !== undefined)
|
|
294
|
+
return { world: status.name, url: service.url, via: 'service', detail: `the world's own "${app}" service`, ...(record.hosts ? { hosts: record.hosts } : {}), ...named };
|
|
225
295
|
if (record.url)
|
|
226
|
-
return record;
|
|
296
|
+
return { ...record, world: status.name, ...named };
|
|
227
297
|
}
|
|
228
|
-
if (
|
|
229
|
-
return { world: status.name, url:
|
|
298
|
+
if (service?.url !== undefined) {
|
|
299
|
+
return { world: status.name, url: service.url, via: 'service', detail: `the world's own "${app}" service (nothing was recorded)`, ...named };
|
|
230
300
|
}
|
|
231
301
|
return null;
|
|
232
302
|
}
|
|
233
303
|
/** The message the CLI prints when no app URL is known — the registration recipe, not a guess. */
|
|
234
|
-
export function appUrlUnsetMessage(name) {
|
|
304
|
+
export function appUrlUnsetMessage(name, app = DEFAULT_APP) {
|
|
305
|
+
if (app !== DEFAULT_APP) {
|
|
306
|
+
return `World ${name} has no URL for the application "${app}": record it with \`volter-world app-url ${name} --app ${app} --set http://127.0.0.1:<port>\`, or declare a service "${app}" in the World's config.`;
|
|
307
|
+
}
|
|
235
308
|
return (`World ${name} has no recorded app URL.\n`
|
|
236
309
|
+ `If the app is booted OUTSIDE the world (the attach pattern), the booter registers it as the LAST boot step:\n`
|
|
237
310
|
+ ` volter-world app-url ${name} --set http://127.0.0.1:<port> # or: --detect <pid|port>\n`
|
package/dist/src/cli.js
CHANGED
|
@@ -99,7 +99,7 @@ function printHelp() {
|
|
|
99
99
|
volter-world urls <name> [--json] [--root <repo>]
|
|
100
100
|
volter-world url <name> [service] [--root <repo>] # the clean base URL of one running service (no world.env parsing);
|
|
101
101
|
# without a service: every service, one \`<id> <url>\` per line
|
|
102
|
-
volter-world app-url <world> [--set <url>|--detect <pid|port>] [--host <name>...] [--json] [--root <repo>]
|
|
102
|
+
volter-world app-url <world> [--app <name>] [--set <url>|--detect <pid|port>] [--host <name>...] [--json] [--root <repo>]
|
|
103
103
|
# the recorded APP endpoint of the instance — the attach pattern's
|
|
104
104
|
# missing output. The BOOTER records it as the last boot step
|
|
105
105
|
# (--set, or --detect an already-listening pid/port); consumers
|
|
@@ -678,22 +678,26 @@ async function main() {
|
|
|
678
678
|
if (set && detect)
|
|
679
679
|
throw new Error('volter-world app-url: pass --set OR --detect, not both');
|
|
680
680
|
const json = rest.includes('--json');
|
|
681
|
+
// --app: which of the World's applications (each with its URL and hostnames); without it, `app`
|
|
682
|
+
const app = rest.some((arg) => arg === '--app' || arg.startsWith('--app=')) ? optionValue(rest, '--app') : undefined;
|
|
683
|
+
if (app === '' || app?.startsWith('--'))
|
|
684
|
+
throw new Error('volter-world app-url: --app needs an application name (--app <name>)');
|
|
681
685
|
// --host (repeatable): a hostname the app answers as in production, routed to it inside the World
|
|
682
686
|
const hosts = rest.flatMap((arg, index) => (arg === '--host' ? [rest[index + 1] ?? ''] : []));
|
|
683
687
|
if (set || detect || hosts.length) {
|
|
684
|
-
let record = set ? setAppUrl(subject, set, { root }) : detect ? await detectAppUrl(subject, detect, { root }) : undefined;
|
|
688
|
+
let record = set ? setAppUrl(subject, set, { root, app }) : detect ? await detectAppUrl(subject, detect, { root, app }) : undefined;
|
|
685
689
|
if (hosts.length)
|
|
686
|
-
record = addAppHosts(subject, hosts, { root });
|
|
690
|
+
record = addAppHosts(subject, hosts, { root, app });
|
|
687
691
|
if (json)
|
|
688
692
|
process.stdout.write(`${JSON.stringify(record, null, 2)}\n`);
|
|
689
693
|
else
|
|
690
694
|
process.stdout.write(`Recorded app URL for world ${subject}: ${record.url}${record.detail ? ` (${record.detail})` : ''}${record.hosts?.length ? `; answering as ${record.hosts.join(', ')}` : ''}\n`);
|
|
691
695
|
return;
|
|
692
696
|
}
|
|
693
|
-
const record = readAppUrl(subject, { root });
|
|
697
|
+
const record = readAppUrl(subject, { root, app });
|
|
694
698
|
if (record === null) {
|
|
695
699
|
// LOUD when unset — a consumer must never mistake "nobody registered it" for an endpoint.
|
|
696
|
-
process.stderr.write(`${appUrlUnsetMessage(subject)}\n`);
|
|
700
|
+
process.stderr.write(`${appUrlUnsetMessage(subject, app)}\n`);
|
|
697
701
|
if (json)
|
|
698
702
|
process.stdout.write(`${JSON.stringify({ world: subject, url: null }, null, 2)}\n`);
|
|
699
703
|
process.exit(1);
|
|
@@ -351,8 +351,12 @@ export async function startRedirectProxy(options) {
|
|
|
351
351
|
for (const [k, v] of Object.entries(loadInject().appForwardHeaders(new URL(`https://${req.headers.get('host') ?? vendorHost}`))))
|
|
352
352
|
headers.set(k, v);
|
|
353
353
|
}
|
|
354
|
-
else
|
|
355
|
-
|
|
354
|
+
else {
|
|
355
|
+
if (env.VOLTER_TWINS_KEY)
|
|
356
|
+
headers.set('x-twins-key', env.VOLTER_TWINS_KEY);
|
|
357
|
+
// the caller reached the vendor's host over TLS, which ends here: the twin renders its own links with https
|
|
358
|
+
headers.set('x-forwarded-proto', 'https');
|
|
359
|
+
}
|
|
356
360
|
// Hosted namespaces need their transport Host; direct twins can use
|
|
357
361
|
// the original Host for vendor semantics (for example S3 buckets).
|
|
358
362
|
if (origin.pathname.replace(/\/$/, '') && twin.vendor !== 'app') {
|
|
@@ -63,7 +63,8 @@ export declare function mountWorld(name: string, opts?: {
|
|
|
63
63
|
root?: string; /** the host's branches of this World, by its served name */
|
|
64
64
|
branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */
|
|
65
65
|
browserOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */
|
|
66
|
-
localTrust?: boolean;
|
|
66
|
+
localTrust?: boolean; /** DoorHost.serveBranch */
|
|
67
|
+
serveBranch?: (branch: string) => Promise<void>;
|
|
67
68
|
}): Promise<MountedWorld>;
|
|
68
69
|
/** Boot the world (state kept) and serve it on its own port. Announces and returns after boot; `stop` downs the world. */
|
|
69
70
|
export declare function serveWorld(name: string, opts?: {
|
|
@@ -143,6 +144,9 @@ export type DoorHost = {
|
|
|
143
144
|
/** This World's branches, where the host can make them (a branch is another World, so making one
|
|
144
145
|
* is the host's act); absent, the branches doors answer 404. */
|
|
145
146
|
branches?: BranchDoors;
|
|
147
|
+
/** Serve another of the World's own named branches in this one's place (`volter world view` does; a host that
|
|
148
|
+
* serves one branch leaves it absent): this branch's twins stop, that one's start, under the same served name. */
|
|
149
|
+
serveBranch?: (branch: string) => Promise<void>;
|
|
146
150
|
};
|
|
147
151
|
/** A branch as its parent lists it. */
|
|
148
152
|
export type BranchRow = {
|
package/dist/src/served-world.js
CHANGED
|
@@ -143,6 +143,7 @@ export async function mountWorld(name, opts = {}) {
|
|
|
143
143
|
...(opts.branches?.(served) ? { branches: opts.branches(served) } : {}),
|
|
144
144
|
...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin(served), originOf: (other) => opts.browserOrigin(other) } : {}),
|
|
145
145
|
...(opts.localTrust ? { localTrust: () => true } : {}),
|
|
146
|
+
...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
|
|
146
147
|
...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers } : {}),
|
|
147
148
|
// a local twin is its own process: the wire forwards to the port it listens on
|
|
148
149
|
twinFetch: (vendor) => {
|
|
@@ -1439,6 +1440,28 @@ export class WorldDoors {
|
|
|
1439
1440
|
catch { /* a World with no local branches */ }
|
|
1440
1441
|
return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named });
|
|
1441
1442
|
}
|
|
1443
|
+
// SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
|
|
1444
|
+
if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
|
|
1445
|
+
if (!this.host.serveBranch)
|
|
1446
|
+
return Response.json({ error: `this host serves ${this.served} on one branch` }, { status: 404 });
|
|
1447
|
+
if (this.credential(request).scope !== 'write')
|
|
1448
|
+
return Response.json({ error: 'serving another branch needs the World\'s write access' }, { status: 403 });
|
|
1449
|
+
const target = decodeURIComponent(rest[0]);
|
|
1450
|
+
let known = false;
|
|
1451
|
+
try {
|
|
1452
|
+
known = listWorlds(this.worldRoot).some((w) => w.name === target);
|
|
1453
|
+
}
|
|
1454
|
+
catch { /* no local branches */ }
|
|
1455
|
+
if (!known)
|
|
1456
|
+
return Response.json({ error: `no branch ${target} in this World` }, { status: 404 });
|
|
1457
|
+
try {
|
|
1458
|
+
await this.host.serveBranch(target);
|
|
1459
|
+
}
|
|
1460
|
+
catch (error) {
|
|
1461
|
+
return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
|
|
1462
|
+
}
|
|
1463
|
+
return Response.json({ world: this.served, checkedOut: target });
|
|
1464
|
+
}
|
|
1442
1465
|
if (rest.length === 0 && request.method === 'POST') {
|
|
1443
1466
|
const body = (await request.json().catch(() => null));
|
|
1444
1467
|
const instant = body?.at?.instant;
|
package/dist/src/world-view.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// ("as of" views) and the console, on one origin (docs/contributing/architecture.md, "Viewing a
|
|
3
3
|
// World"). `volter world view` is this; the runtime's `up` gains no step. A host that serves many
|
|
4
4
|
// Worlds (world-host) answers the same doors; this serves one, and the branches made of it.
|
|
5
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { loadWorldConfig } from "./configs.js";
|
|
7
7
|
import { join, resolve } from 'node:path';
|
|
8
8
|
import { serveHttp, stateDirName } from '@volter/world-core';
|
|
@@ -42,7 +42,46 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
42
42
|
const browserOrigin = origins ? (served) => (port ? localWorldOrigin(served, port) : null) : undefined;
|
|
43
43
|
const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}) });
|
|
44
44
|
const branches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
|
|
45
|
-
|
|
45
|
+
// THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
|
|
46
|
+
// same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
|
|
47
|
+
// \`volter world checkout\` sets it. While it switches, the World's doors answer 503.
|
|
48
|
+
let url = '';
|
|
49
|
+
let switching = false;
|
|
50
|
+
const serveBranch = async (branch) => {
|
|
51
|
+
if (switching)
|
|
52
|
+
throw new Error('already switching branches');
|
|
53
|
+
if (branch === world.name)
|
|
54
|
+
return;
|
|
55
|
+
switching = true;
|
|
56
|
+
const from = world.name;
|
|
57
|
+
try {
|
|
58
|
+
await world.stop();
|
|
59
|
+
let next;
|
|
60
|
+
try {
|
|
61
|
+
next = await mountWorld(branch, mountOpts);
|
|
62
|
+
await next.boot(url);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
const back = await mountWorld(from, mountOpts);
|
|
66
|
+
await back.boot(url);
|
|
67
|
+
worlds.set(back.served, back);
|
|
68
|
+
world = back;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
worlds.set(next.served, next);
|
|
72
|
+
world = next;
|
|
73
|
+
const current = join(root, stateDirName(), 'current');
|
|
74
|
+
if (branch === loadWorldConfig(join(root, stateDirName(), 'world.json'), root).config.id)
|
|
75
|
+
rmSync(current, { force: true });
|
|
76
|
+
else
|
|
77
|
+
writeFileSync(current, `${branch}\n`);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
switching = false;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}), serveBranch };
|
|
84
|
+
let world = await mountWorld(name, mountOpts);
|
|
46
85
|
worlds.set(world.served, world);
|
|
47
86
|
const server = await serveHttp({
|
|
48
87
|
// a pushed blob (a release video) is one PUT: bodies up to 1 GiB, as `serve` takes them
|
|
@@ -73,6 +112,8 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
73
112
|
return answered;
|
|
74
113
|
}
|
|
75
114
|
const m = /^\/(?:-\/)?([^/]+)\/([^/]+)/.exec(path);
|
|
115
|
+
if (switching && m && `${m[1]}/${m[2]}` === world.served)
|
|
116
|
+
return Response.json({ error: `${world.served} is switching branches` }, { status: 503, headers: { 'retry-after': '2' } });
|
|
76
117
|
const target = m ? worlds.get(`${m[1]}/${m[2]}`) : undefined;
|
|
77
118
|
if (!target)
|
|
78
119
|
return Response.json({ error: `no world at ${path}: this view serves ${[...worlds.keys()].join(', ')}` }, { status: 404 });
|
|
@@ -80,7 +121,7 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
80
121
|
},
|
|
81
122
|
});
|
|
82
123
|
port = server.port ?? 0;
|
|
83
|
-
|
|
124
|
+
url = `http://${host}:${server.port}`;
|
|
84
125
|
loopback = `http://${host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host}:${server.port}`;
|
|
85
126
|
// a branch outlives the view that made it until its time runs out: stopping stops its compute only
|
|
86
127
|
const stop = async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.14",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -67,14 +67,14 @@
|
|
|
67
67
|
"node": ">=22.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@volter/world-core": "2.0.
|
|
71
|
-
"@volter/world-console": "2.0.
|
|
70
|
+
"@volter/world-core": "2.0.8",
|
|
71
|
+
"@volter/world-console": "2.0.8"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@electric-sql/pglite": "0.5.8",
|
|
75
75
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
76
76
|
"@volter/world-access": "2.0.1",
|
|
77
|
-
"@volter/world-core": "2.0.
|
|
77
|
+
"@volter/world-core": "2.0.8",
|
|
78
78
|
"pg-gateway": "0.3.0-beta.4",
|
|
79
79
|
"smol-toml": "^1.8.0"
|
|
80
80
|
},
|
|
@@ -84,6 +84,6 @@
|
|
|
84
84
|
}
|
|
85
85
|
},
|
|
86
86
|
"optionalDependencies": {
|
|
87
|
-
"@volter/twin-mongodb": "0.1.
|
|
87
|
+
"@volter/twin-mongodb": "0.1.8"
|
|
88
88
|
}
|
|
89
89
|
}
|
package/src/app-url.ts
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
// once, at the caller's request, and stores what it saw. The record lives in the instance dir, so
|
|
20
20
|
// a re-`up` (which wipes the dir) or `down --purge` clears it: a recorded URL never outlives the
|
|
21
21
|
// instance it described.
|
|
22
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { withFileLock } from '@volter/world-core';
|
|
23
24
|
import { join } from 'node:path';
|
|
24
25
|
import { spawnSync } from 'node:child_process';
|
|
25
26
|
import net from 'node:net';
|
|
@@ -41,8 +42,56 @@ export type AppUrlRecord = {
|
|
|
41
42
|
/** the application's own production hostnames (`--host`), routed to `url` inside the World as DNS routes them to
|
|
42
43
|
* its host in production: the injector, the socket backstop and the redirect proxy read them from this record */
|
|
43
44
|
hosts?: string[];
|
|
45
|
+
/** the application's name in the World (`--app`); absent: `app`, the one a command names without it */
|
|
46
|
+
app?: string;
|
|
44
47
|
};
|
|
45
48
|
|
|
49
|
+
/** `app-url.json`: the application named `app` at the top level (the record's original shape), the others by name. */
|
|
50
|
+
type AppUrlFile = Partial<AppUrlRecord> & { apps?: Record<string, Partial<AppUrlRecord>> };
|
|
51
|
+
|
|
52
|
+
const APP_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
53
|
+
const DEFAULT_APP = 'app';
|
|
54
|
+
|
|
55
|
+
function appName(app: string | undefined): string {
|
|
56
|
+
const name = app ?? DEFAULT_APP;
|
|
57
|
+
if (!APP_NAME.test(name)) throw new Error(`volter-world app-url: --app "${name}" is not an application name (lower-case letters, digits and -)`);
|
|
58
|
+
return name;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readFile(root: string, name: string): AppUrlFile {
|
|
62
|
+
const file = appUrlFile(root, name);
|
|
63
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) as AppUrlFile : {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function entryOf(file: AppUrlFile, app: string): Partial<AppUrlRecord> | undefined {
|
|
67
|
+
if (app !== DEFAULT_APP) return file.apps && Object.hasOwn(file.apps, app) ? file.apps[app] : undefined;
|
|
68
|
+
const { apps: _others, ...own } = file;
|
|
69
|
+
return own.url !== undefined || own.hosts !== undefined ? own : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Write one application's entry, keeping every other application's: under the record's lock (several booters record
|
|
73
|
+
* their applications at once), replaced whole so a reader never meets half a file. */
|
|
74
|
+
function writeEntry<T>(root: string, name: string, app: string, build: (file: AppUrlFile) => { entry: Partial<AppUrlRecord>; result: T }): T {
|
|
75
|
+
// the whole read, check, merge and write under one lock (withFileLock is not reentrant: nothing inside takes it)
|
|
76
|
+
return withFileLock(`${appUrlFile(root, name)}.lock`, () => {
|
|
77
|
+
const file = readFile(root, name);
|
|
78
|
+
const { entry, result } = build(file);
|
|
79
|
+
writeEntryLocked(root, name, app, file, entry);
|
|
80
|
+
return result;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeEntryLocked(root: string, name: string, app: string, file: AppUrlFile, entry: Partial<AppUrlRecord>): void {
|
|
85
|
+
const { app: _name, ...stored } = entry;
|
|
86
|
+
const next: AppUrlFile = app === DEFAULT_APP
|
|
87
|
+
? { ...stored, ...(file.apps ? { apps: file.apps } : {}) }
|
|
88
|
+
: { ...Object.fromEntries(Object.entries(file).filter(([k]) => k !== 'apps')), apps: { ...(file.apps ?? {}), [app]: stored } };
|
|
89
|
+
const target = appUrlFile(root, name);
|
|
90
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
91
|
+
writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`);
|
|
92
|
+
renameSync(temp, target);
|
|
93
|
+
}
|
|
94
|
+
|
|
46
95
|
export function appUrlFile(root: string, name: string): string {
|
|
47
96
|
return join(instanceDir(root, name), 'app-url.json');
|
|
48
97
|
}
|
|
@@ -63,27 +112,30 @@ function assertHttpUrl(url: string): URL {
|
|
|
63
112
|
/** Record `url` as the app endpoint of the RUNNING/booted instance `name`. The instance must
|
|
64
113
|
* exist (a record for a world that was never upped would describe nothing); the app itself is
|
|
65
114
|
* deliberately NOT probed — the booter declaring "this is where I put the app" is the truth. */
|
|
66
|
-
export function setAppUrl(name: string, url: string, options: { root?: string; via?: Exclude<AppUrlSource, 'service'>; detail?: string } = {}): AppUrlRecord {
|
|
115
|
+
export function setAppUrl(name: string, url: string, options: { root?: string; via?: Exclude<AppUrlSource, 'service'>; detail?: string; app?: string } = {}): AppUrlRecord {
|
|
67
116
|
const root = options.root ?? process.cwd();
|
|
117
|
+
const app = appName(options.app);
|
|
68
118
|
const status = statusWorld(name, root); // throws "World instance not found" — the loud path
|
|
69
119
|
assertHttpUrl(url);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
120
|
+
return writeEntry(root, name, app, (file) => {
|
|
121
|
+
// a new URL keeps the hosts recorded for the app (they name the app, not where it listens)
|
|
122
|
+
const hosts = entryOf(file, app)?.hosts;
|
|
123
|
+
const record: AppUrlRecord = {
|
|
124
|
+
world: status.name,
|
|
125
|
+
url,
|
|
126
|
+
via: options.via ?? 'set',
|
|
127
|
+
recordedAt: new Date().toISOString(),
|
|
128
|
+
...(options.detail === undefined ? {} : { detail: options.detail }),
|
|
129
|
+
...(hosts?.length ? { hosts } : {}),
|
|
130
|
+
};
|
|
131
|
+
return { entry: record, result: app === DEFAULT_APP ? record : { ...record, app } };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A hostname the app may answer as: a DNS name (not an address, not loopback) that no twin the World runs serves,
|
|
136
|
+
* not the host of this World's own origin or of a twin it runs (their requests carry the World key), and not one
|
|
137
|
+
* another of its applications answers. A vendor the World runs no twin of can be the application itself. */
|
|
138
|
+
function assertAppHost(host: string, world: string, root: string, app: string, file: AppUrlFile): string {
|
|
87
139
|
const name = host.trim().toLowerCase().replace(/\.$/, '');
|
|
88
140
|
// a wildcard (`*.dub.link`) stands for every name below its parent, as a DNS wildcard record does; its parent has two
|
|
89
141
|
// labels at least (never a bare TLD). Whether the parent is a public suffix (co.uk, github.io) is the operator's word:
|
|
@@ -94,20 +146,33 @@ function assertAppHost(host: string, world: string, root: string): string {
|
|
|
94
146
|
throw new Error(`volter-world app-url: --host "${host}" is not a DNS hostname the app answers as (e.g. app.example.com, or *.example.com)`);
|
|
95
147
|
}
|
|
96
148
|
const inject = loadInject();
|
|
97
|
-
|
|
149
|
+
const status = statusWorld(world, root);
|
|
150
|
+
// the twins the World runs, by vendor key, as its injector reads them from the World env (a service's id is not its
|
|
151
|
+
// vendor key: the aws service serves s3, dynamodb and others; a stripe twin may be a service named payments)
|
|
152
|
+
const twins = inject.readMap(status.env);
|
|
153
|
+
const runs = (vendor: string) => Object.hasOwn(twins, vendor);
|
|
154
|
+
let vendor = Object.keys(inject.VENDOR_HOSTS).find((v) => runs(v) && inject.VENDOR_HOSTS[v]!(bare));
|
|
98
155
|
if (!vendor && wildcard) {
|
|
99
156
|
// a wildcard is refused when any name a vendor's rule can give its twin lies under it, or it lies under a vendor's
|
|
100
157
|
// host suffix: the rules' names, read from the descriptors (a host, a suffix, a pattern's literal tail) and from
|
|
101
158
|
// the hand table's predicates (their quoted names)
|
|
102
159
|
const names = vendorNames(inject.VENDOR_HOSTS);
|
|
103
160
|
const dotted = `.${bare}`;
|
|
104
|
-
vendor = names.find((n) => n.name === bare || n.name.endsWith(dotted) || (n.suffix && dotted.endsWith(n.name.startsWith('.') ? n.name : `.${n.name}`)))?.vendor;
|
|
161
|
+
vendor = names.find((n) => runs(n.vendor) && (n.name === bare || n.name.endsWith(dotted) || (n.suffix && dotted.endsWith(n.name.startsWith('.') ? n.name : `.${n.name}`))))?.vendor;
|
|
105
162
|
}
|
|
106
|
-
if (vendor) throw new Error(`volter-world app-url: --host ${name} is ${vendor}'s host, which its twin serves; an app cannot answer as it`);
|
|
107
|
-
const status = statusWorld(world, root);
|
|
163
|
+
if (vendor) throw new Error(`volter-world app-url: --host ${name} is ${vendor}'s host, which its twin serves in this World; an app cannot answer as it while it runs`);
|
|
108
164
|
const own = [status.env.VOLTER_WORLD ?? '', ...Object.values(status.services).map((service) => service.url ?? '')]
|
|
109
165
|
.flatMap((url) => { try { return [new URL(url).hostname.toLowerCase()]; } catch { return []; } });
|
|
110
166
|
if (own.some((h) => h === name || (wildcard && h.endsWith(`.${bare}`)))) throw new Error(`volter-world app-url: --host ${name} is this World's own origin or a twin's; an app cannot answer as it`);
|
|
167
|
+
// one application per hostname: a name, or a wildcard over it, another application already answers
|
|
168
|
+
for (const other of [DEFAULT_APP, ...Object.keys(file.apps ?? {})]) {
|
|
169
|
+
if (other === app) continue;
|
|
170
|
+
const held = entryOf(file, other)?.hosts ?? [];
|
|
171
|
+
// a wildcard answers every name below its parent and never the parent, as a DNS wildcard does, so `*.example.org`
|
|
172
|
+
// and `example.org` are two applications' without overlap
|
|
173
|
+
const clash = held.find((h) => h === name || (h.startsWith('*.') && name.endsWith(h.slice(1)) && name !== h) || (wildcard && h.endsWith(`.${bare}`)));
|
|
174
|
+
if (clash) throw new Error(`volter-world app-url: --host ${name} overlaps ${clash}, which the application "${other}" answers; a hostname belongs to one application`);
|
|
175
|
+
}
|
|
111
176
|
return name;
|
|
112
177
|
}
|
|
113
178
|
|
|
@@ -137,15 +202,21 @@ function vendorNames(table: Record<string, (host: string, pathname?: string) =>
|
|
|
137
202
|
}
|
|
138
203
|
|
|
139
204
|
/** Record `hosts` as the app's own production hostnames, beside its recorded URL (or its `app` service's). */
|
|
140
|
-
export function addAppHosts(name: string, hosts: string[], options: { root?: string } = {}): AppUrlRecord {
|
|
205
|
+
export function addAppHosts(name: string, hosts: string[], options: { root?: string; app?: string } = {}): AppUrlRecord {
|
|
141
206
|
const root = options.root ?? process.cwd();
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
207
|
+
const app = appName(options.app);
|
|
208
|
+
const status = statusWorld(name, root);
|
|
209
|
+
return writeEntry(root, name, app, (file) => {
|
|
210
|
+
// checked against the record as it stands under the lock, so two booters never both take one hostname
|
|
211
|
+
const current = appUrlOf(file, status, app);
|
|
212
|
+
if (!current) throw new Error(appUrlUnsetMessage(name, app));
|
|
213
|
+
const all = [...new Set([...(current.hosts ?? []), ...hosts.map((host) => assertAppHost(host, name, root, app, file))])].sort();
|
|
214
|
+
// hosts beside the World's own service of the application's name keep no URL: they follow the service wherever a
|
|
215
|
+
// boot puts it
|
|
216
|
+
const { app: _name, ...stored } = current;
|
|
217
|
+
const entry = current.via === 'service' ? { world: current.world, via: 'service', hosts: all } as AppUrlRecord : { ...stored, hosts: all };
|
|
218
|
+
return { entry, result: { ...current, hosts: all } };
|
|
219
|
+
});
|
|
149
220
|
}
|
|
150
221
|
|
|
151
222
|
/** The listening TCP ports of a live pid, via lsof (macOS + Linux). */
|
|
@@ -193,7 +264,7 @@ function tcpListening(port: number, timeoutMs = 1500): Promise<boolean> {
|
|
|
193
264
|
* Otherwise the number is tried as a loopback port and must actually be listening. Detection
|
|
194
265
|
* that finds nothing refuses loudly rather than recording a URL nothing serves.
|
|
195
266
|
*/
|
|
196
|
-
export async function detectAppUrl(name: string, target: string, options: { root?: string } = {}): Promise<AppUrlRecord> {
|
|
267
|
+
export async function detectAppUrl(name: string, target: string, options: { root?: string; app?: string } = {}): Promise<AppUrlRecord> {
|
|
197
268
|
const numeric = Number(target);
|
|
198
269
|
if (!Number.isInteger(numeric) || numeric <= 0) {
|
|
199
270
|
throw new Error(`volter-world app-url: --detect wants a pid or a port, got "${target}"`);
|
|
@@ -225,25 +296,35 @@ export async function detectAppUrl(name: string, target: string, options: { root
|
|
|
225
296
|
* (the world-boots-the-app convention), or null. Null is the CALLER's loud-error cue — the CLI
|
|
226
297
|
* turns it into exit 1 with the registration recipe; a library consumer decides for itself.
|
|
227
298
|
*/
|
|
228
|
-
export function readAppUrl(name: string, options: { root?: string } = {}): AppUrlRecord | null {
|
|
299
|
+
export function readAppUrl(name: string, options: { root?: string; app?: string } = {}): AppUrlRecord | null {
|
|
229
300
|
const root = options.root ?? process.cwd();
|
|
301
|
+
const app = appName(options.app);
|
|
230
302
|
const status = statusWorld(name, root); // loud when the world does not exist at all
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
303
|
+
return appUrlOf(readFile(root, name), status, app);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** One application's URL from the record `file` and the instance `status`: the recorded URL, or the World's service
|
|
307
|
+
* of the application's name. */
|
|
308
|
+
function appUrlOf(file: AppUrlFile, status: { name: string; services: Record<string, { url?: string }> }, app: string): AppUrlRecord | null {
|
|
309
|
+
const named = app === DEFAULT_APP ? {} : { app };
|
|
310
|
+
const service = status.services[app];
|
|
311
|
+
const record = entryOf(file, app);
|
|
312
|
+
if (record) {
|
|
313
|
+
// hosts recorded beside the World's own service of the application's name: its URL is this boot's
|
|
314
|
+
if (!record.url && service?.url !== undefined) return { world: status.name, url: service.url, via: 'service', detail: `the world's own "${app}" service`, ...(record.hosts ? { hosts: record.hosts } : {}), ...named };
|
|
315
|
+
if (record.url) return { ...(record as AppUrlRecord), world: status.name, ...named };
|
|
238
316
|
}
|
|
239
|
-
if (
|
|
240
|
-
return { world: status.name, url:
|
|
317
|
+
if (service?.url !== undefined) {
|
|
318
|
+
return { world: status.name, url: service.url, via: 'service', detail: `the world's own "${app}" service (nothing was recorded)`, ...named };
|
|
241
319
|
}
|
|
242
320
|
return null;
|
|
243
321
|
}
|
|
244
322
|
|
|
245
323
|
/** The message the CLI prints when no app URL is known — the registration recipe, not a guess. */
|
|
246
|
-
export function appUrlUnsetMessage(name: string): string {
|
|
324
|
+
export function appUrlUnsetMessage(name: string, app = DEFAULT_APP): string {
|
|
325
|
+
if (app !== DEFAULT_APP) {
|
|
326
|
+
return `World ${name} has no URL for the application "${app}": record it with \`volter-world app-url ${name} --app ${app} --set http://127.0.0.1:<port>\`, or declare a service "${app}" in the World's config.`;
|
|
327
|
+
}
|
|
247
328
|
return (
|
|
248
329
|
`World ${name} has no recorded app URL.\n`
|
|
249
330
|
+ `If the app is booted OUTSIDE the world (the attach pattern), the booter registers it as the LAST boot step:\n`
|
package/src/cli.ts
CHANGED
|
@@ -100,7 +100,7 @@ function printHelp(): void {
|
|
|
100
100
|
volter-world urls <name> [--json] [--root <repo>]
|
|
101
101
|
volter-world url <name> [service] [--root <repo>] # the clean base URL of one running service (no world.env parsing);
|
|
102
102
|
# without a service: every service, one \`<id> <url>\` per line
|
|
103
|
-
volter-world app-url <world> [--set <url>|--detect <pid|port>] [--host <name>...] [--json] [--root <repo>]
|
|
103
|
+
volter-world app-url <world> [--app <name>] [--set <url>|--detect <pid|port>] [--host <name>...] [--json] [--root <repo>]
|
|
104
104
|
# the recorded APP endpoint of the instance — the attach pattern's
|
|
105
105
|
# missing output. The BOOTER records it as the last boot step
|
|
106
106
|
# (--set, or --detect an already-listening pid/port); consumers
|
|
@@ -608,19 +608,22 @@ async function main(): Promise<void> {
|
|
|
608
608
|
const detect = optionValue(rest, '--detect');
|
|
609
609
|
if (set && detect) throw new Error('volter-world app-url: pass --set OR --detect, not both');
|
|
610
610
|
const json = rest.includes('--json');
|
|
611
|
+
// --app: which of the World's applications (each with its URL and hostnames); without it, `app`
|
|
612
|
+
const app = rest.some((arg) => arg === '--app' || arg.startsWith('--app=')) ? optionValue(rest, '--app') : undefined;
|
|
613
|
+
if (app === '' || app?.startsWith('--')) throw new Error('volter-world app-url: --app needs an application name (--app <name>)');
|
|
611
614
|
// --host (repeatable): a hostname the app answers as in production, routed to it inside the World
|
|
612
615
|
const hosts = rest.flatMap((arg, index) => (arg === '--host' ? [rest[index + 1] ?? ''] : []));
|
|
613
616
|
if (set || detect || hosts.length) {
|
|
614
|
-
let record = set ? setAppUrl(subject, set, { root }) : detect ? await detectAppUrl(subject, detect, { root }) : undefined;
|
|
615
|
-
if (hosts.length) record = addAppHosts(subject, hosts, { root });
|
|
617
|
+
let record = set ? setAppUrl(subject, set, { root, app }) : detect ? await detectAppUrl(subject, detect, { root, app }) : undefined;
|
|
618
|
+
if (hosts.length) record = addAppHosts(subject, hosts, { root, app });
|
|
616
619
|
if (json) process.stdout.write(`${JSON.stringify(record, null, 2)}\n`);
|
|
617
620
|
else process.stdout.write(`Recorded app URL for world ${subject}: ${record!.url}${record!.detail ? ` (${record!.detail})` : ''}${record!.hosts?.length ? `; answering as ${record!.hosts.join(', ')}` : ''}\n`);
|
|
618
621
|
return;
|
|
619
622
|
}
|
|
620
|
-
const record = readAppUrl(subject, { root });
|
|
623
|
+
const record = readAppUrl(subject, { root, app });
|
|
621
624
|
if (record === null) {
|
|
622
625
|
// LOUD when unset — a consumer must never mistake "nobody registered it" for an endpoint.
|
|
623
|
-
process.stderr.write(`${appUrlUnsetMessage(subject)}\n`);
|
|
626
|
+
process.stderr.write(`${appUrlUnsetMessage(subject, app)}\n`);
|
|
624
627
|
if (json) process.stdout.write(`${JSON.stringify({ world: subject, url: null }, null, 2)}\n`);
|
|
625
628
|
process.exit(1);
|
|
626
629
|
}
|
package/src/redirect-proxy.ts
CHANGED
|
@@ -373,7 +373,11 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
373
373
|
if (twin.vendor === 'app') {
|
|
374
374
|
headers.delete('x-twins-key');
|
|
375
375
|
for (const [k, v] of Object.entries(loadInject().appForwardHeaders(new URL(`https://${req.headers.get('host') ?? vendorHost}`)))) headers.set(k, v);
|
|
376
|
-
} else
|
|
376
|
+
} else {
|
|
377
|
+
if (env.VOLTER_TWINS_KEY) headers.set('x-twins-key', env.VOLTER_TWINS_KEY);
|
|
378
|
+
// the caller reached the vendor's host over TLS, which ends here: the twin renders its own links with https
|
|
379
|
+
headers.set('x-forwarded-proto', 'https');
|
|
380
|
+
}
|
|
377
381
|
// Hosted namespaces need their transport Host; direct twins can use
|
|
378
382
|
// the original Host for vendor semantics (for example S3 buckets).
|
|
379
383
|
if (origin.pathname.replace(/\/$/, '') && twin.vendor !== 'app') {
|
package/src/served-world.ts
CHANGED
|
@@ -116,7 +116,7 @@ export type MountedWorld = { name: string; served: string; root: string; readonl
|
|
|
116
116
|
/** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
|
|
117
117
|
type HeldSession = { scope: 'read' | 'write'; until: number; who?: string; /** the platform whose pass opened it */ issuer?: string; /** the person's subject there */ sub?: string; /** the read key a shared link opened it with: it lasts only while that key does */ key?: string };
|
|
118
118
|
|
|
119
|
-
export async function mountWorld(name: string, opts: { /** the platforms whose passes open this World */ passIssuers?: TrustedIssuer[]; root?: string; /** the host's branches of this World, by its served name */ branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */ browserOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean } = {}): Promise<MountedWorld> {
|
|
119
|
+
export async function mountWorld(name: string, opts: { /** the platforms whose passes open this World */ passIssuers?: TrustedIssuer[]; root?: string; /** the host's branches of this World, by its served name */ branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */ browserOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean; /** DoorHost.serveBranch */ serveBranch?: (branch: string) => Promise<void> } = {}): Promise<MountedWorld> {
|
|
120
120
|
const worldRoot = resolve(opts.root ?? process.cwd());
|
|
121
121
|
const configRef = configRefFor(worldRoot, name);
|
|
122
122
|
if (loadWorldConfig(configRef, worldRoot).config.resources) {
|
|
@@ -149,6 +149,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
149
149
|
...(opts.branches?.(served) ? { branches: opts.branches(served)! } : {}),
|
|
150
150
|
...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin!(served), originOf: (other: string) => opts.browserOrigin!(other) } : {}),
|
|
151
151
|
...(opts.localTrust ? { localTrust: () => true } : {}),
|
|
152
|
+
...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
|
|
152
153
|
...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers! } : {}),
|
|
153
154
|
// a local twin is its own process: the wire forwards to the port it listens on
|
|
154
155
|
twinFetch: (vendor) => {
|
|
@@ -304,6 +305,9 @@ export type DoorHost = {
|
|
|
304
305
|
/** This World's branches, where the host can make them (a branch is another World, so making one
|
|
305
306
|
* is the host's act); absent, the branches doors answer 404. */
|
|
306
307
|
branches?: BranchDoors;
|
|
308
|
+
/** Serve another of the World's own named branches in this one's place (`volter world view` does; a host that
|
|
309
|
+
* serves one branch leaves it absent): this branch's twins stop, that one's start, under the same served name. */
|
|
310
|
+
serveBranch?: (branch: string) => Promise<void>;
|
|
307
311
|
};
|
|
308
312
|
|
|
309
313
|
/** A branch as its parent lists it. */
|
|
@@ -1232,6 +1236,17 @@ export class WorldDoors {
|
|
|
1232
1236
|
try { named = listWorlds(this.worldRoot).map((w) => ({ name: w.name, running: w.running })); } catch { /* a World with no local branches */ }
|
|
1233
1237
|
return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named });
|
|
1234
1238
|
}
|
|
1239
|
+
// SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
|
|
1240
|
+
if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
|
|
1241
|
+
if (!this.host.serveBranch) return Response.json({ error: `this host serves ${this.served} on one branch` }, { status: 404 });
|
|
1242
|
+
if (this.credential(request).scope !== 'write') return Response.json({ error: 'serving another branch needs the World\'s write access' }, { status: 403 });
|
|
1243
|
+
const target = decodeURIComponent(rest[0]!);
|
|
1244
|
+
let known = false;
|
|
1245
|
+
try { known = listWorlds(this.worldRoot).some((w) => w.name === target); } catch { /* no local branches */ }
|
|
1246
|
+
if (!known) return Response.json({ error: `no branch ${target} in this World` }, { status: 404 });
|
|
1247
|
+
try { await this.host.serveBranch(target); } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 }); }
|
|
1248
|
+
return Response.json({ world: this.served, checkedOut: target });
|
|
1249
|
+
}
|
|
1235
1250
|
if (rest.length === 0 && request.method === 'POST') {
|
|
1236
1251
|
const body = (await request.json().catch(() => null)) as { at?: { instant?: unknown }; ttl?: unknown; live?: unknown; label?: unknown } | null;
|
|
1237
1252
|
const instant = body?.at?.instant;
|
package/src/world-view.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// ("as of" views) and the console, on one origin (docs/contributing/architecture.md, "Viewing a
|
|
3
3
|
// World"). `volter world view` is this; the runtime's `up` gains no step. A host that serves many
|
|
4
4
|
// Worlds (world-host) answers the same doors; this serves one, and the branches made of it.
|
|
5
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { loadWorldConfig } from './configs.ts';
|
|
7
7
|
import { join, resolve } from 'node:path';
|
|
8
8
|
import { serveHttp, stateDirName } from '@volter/world-core';
|
|
@@ -44,7 +44,35 @@ export async function serveWorldView(name: string, opts: { root?: string; port?:
|
|
|
44
44
|
const browserOrigin = origins ? (served: string) => (port ? localWorldOrigin(served, port) : null) : undefined;
|
|
45
45
|
const mountAt = (at: string): Promise<MountedWorld> => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}) });
|
|
46
46
|
const branches: LocalBranches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
|
|
47
|
-
|
|
47
|
+
// THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
|
|
48
|
+
// same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
|
|
49
|
+
// \`volter world checkout\` sets it. While it switches, the World's doors answer 503.
|
|
50
|
+
let url = '';
|
|
51
|
+
let switching = false;
|
|
52
|
+
const serveBranch = async (branch: string): Promise<void> => {
|
|
53
|
+
if (switching) throw new Error('already switching branches');
|
|
54
|
+
if (branch === world.name) return;
|
|
55
|
+
switching = true;
|
|
56
|
+
const from = world.name;
|
|
57
|
+
try {
|
|
58
|
+
await world.stop();
|
|
59
|
+
let next: MountedWorld;
|
|
60
|
+
try {
|
|
61
|
+
next = await mountWorld(branch, mountOpts);
|
|
62
|
+
await next.boot(url);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const back = await mountWorld(from, mountOpts);
|
|
65
|
+
await back.boot(url);
|
|
66
|
+
worlds.set(back.served, back); world = back;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
worlds.set(next.served, next); world = next;
|
|
70
|
+
const current = join(root, stateDirName(), 'current');
|
|
71
|
+
if (branch === loadWorldConfig(join(root, stateDirName(), 'world.json'), root).config.id) rmSync(current, { force: true }); else writeFileSync(current, `${branch}\n`);
|
|
72
|
+
} finally { switching = false; }
|
|
73
|
+
};
|
|
74
|
+
const mountOpts = { root, branches: (served: string) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}), serveBranch };
|
|
75
|
+
let world = await mountWorld(name, mountOpts);
|
|
48
76
|
worlds.set(world.served, world);
|
|
49
77
|
const server = await serveHttp({
|
|
50
78
|
// a pushed blob (a release video) is one PUT: bodies up to 1 GiB, as `serve` takes them
|
|
@@ -69,13 +97,14 @@ export async function serveWorldView(name: string, opts: { root?: string; port?:
|
|
|
69
97
|
if (answered) return answered;
|
|
70
98
|
}
|
|
71
99
|
const m = /^\/(?:-\/)?([^/]+)\/([^/]+)/.exec(path);
|
|
100
|
+
if (switching && m && `${m[1]}/${m[2]}` === world.served) return Response.json({ error: `${world.served} is switching branches` }, { status: 503, headers: { 'retry-after': '2' } });
|
|
72
101
|
const target = m ? worlds.get(`${m[1]}/${m[2]}`) : undefined;
|
|
73
102
|
if (!target) return Response.json({ error: `no world at ${path}: this view serves ${[...worlds.keys()].join(', ')}` }, { status: 404 });
|
|
74
103
|
return target.handle(request);
|
|
75
104
|
},
|
|
76
105
|
});
|
|
77
106
|
port = server.port ?? 0;
|
|
78
|
-
|
|
107
|
+
url = `http://${host}:${server.port}`;
|
|
79
108
|
loopback = `http://${host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host}:${server.port}`;
|
|
80
109
|
// a branch outlives the view that made it until its time runs out: stopping stops its compute only
|
|
81
110
|
const stop = async (): Promise<void> => {
|