@volter/world-runtime 2.0.12 → 2.0.13
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/package.json +4 -4
- package/src/app-url.ts +124 -43
- package/src/cli.ts +8 -5
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.13",
|
|
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.
|
|
70
|
+
"@volter/world-core": "2.0.8",
|
|
71
71
|
"@volter/world-console": "2.0.7"
|
|
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
|
}
|