@ultimat3/cli 27.6.1 → 27.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/package.json +28 -28
- package/src/admin-registration.ts +15 -14
- package/src/api-registration.ts +48 -47
- package/src/app-manifest.ts +15 -0
- package/src/cmd-generate.ts +65 -49
- package/src/dev-boot.ts +10 -1
- package/src/generate-disk.ts +38 -0
- package/src/generate-files.ts +6 -1
- package/src/generate-follow-ups.ts +64 -0
- package/src/generate-grant-findings.ts +8 -6
- package/src/generate-grants.ts +79 -52
- package/src/generate-kinds.ts +7 -2
- package/src/generate-plural.ts +11 -0
- package/src/generate-sibling.ts +34 -0
- package/src/generated-imports.ts +9 -6
- package/src/handle-registration.ts +71 -29
- package/src/i18n-index.ts +30 -14
- package/src/isr-query-advice.ts +63 -0
- package/src/isr-query-guard.ts +49 -0
- package/src/mcp-host.ts +2 -1
- package/src/messages.ts +4 -0
- package/src/role-sync-bus-wait.ts +66 -0
- package/src/role-sync.ts +6 -2
- package/src/runtime-bus.ts +198 -0
- package/src/runtime-cache.ts +158 -10
- package/src/runtime-isr-outcome.ts +60 -13
- package/src/runtime-isr.ts +23 -0
- package/src/runtime-render.ts +4 -3
- package/src/runtime-services.ts +30 -37
- package/src/seo-routes.ts +114 -14
- package/src/serve-boot.ts +4 -1
- package/src/serve-types.ts +6 -0
- package/src/serve.ts +8 -1
- package/src/source-list-edit.ts +218 -0
- package/src/templates/entity-module.ts +29 -0
- package/src/templates/entity.ts +30 -14
- package/src/verify-checks.ts +5 -0
- package/src/workspace-dep-edit.ts +13 -10
package/src/i18n-index.ts
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
// why: Bun has no synchronous existence check — `Bun.file(p).exists()` is async, and this decides
|
|
10
10
|
// whether to write at all, before any await the caller could interleave with.
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
|
+
import { stripComments } from '@ultimat3/core';
|
|
12
13
|
import { APP_CATALOGS_PATH } from '@ultimat3/i18n/app-catalogs';
|
|
14
|
+
import type { GenerateDisk } from './generate-disk';
|
|
15
|
+
import { appDisk } from './generate-disk';
|
|
13
16
|
import { containedPath } from './generate-write';
|
|
14
17
|
import type { Finding } from './output';
|
|
15
18
|
import { CATALOG_ROOT, i18nIndex, localeEntry, localeImport } from './templates';
|
|
@@ -30,6 +33,8 @@ export interface IndexSync {
|
|
|
30
33
|
/** False for an app with no i18n package, or an index this writer refused to edit. */
|
|
31
34
|
readonly registered: boolean;
|
|
32
35
|
readonly findings: readonly Finding[];
|
|
36
|
+
/** The index, when this changed its bytes — a run's file list names every file it touched. */
|
|
37
|
+
readonly edited?: readonly string[];
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
const CATALOG_IMPORT = /^import (\w+) from '\.\.\/catalogs\/([^']+)\.json';$/gm;
|
|
@@ -48,31 +53,42 @@ const importedLocales = (source: string): readonly string[] =>
|
|
|
48
53
|
* stay. A shape with no `locales: { … }` object to add to is refused with the edit named, never
|
|
49
54
|
* overwritten: it was, on every `x g`, with a template hard-coding `default: 'en'`.
|
|
50
55
|
*/
|
|
51
|
-
export async function syncI18nIndex(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
export async function syncI18nIndex(
|
|
57
|
+
root: string,
|
|
58
|
+
disk: GenerateDisk = appDisk(root),
|
|
59
|
+
/** Locales whose catalog a dry run PLANS: on the real disk only once the run has written. */
|
|
60
|
+
planned: readonly string[] = [],
|
|
61
|
+
): Promise<IndexSync> {
|
|
62
|
+
const current = await disk.read(APP_CATALOGS_PATH);
|
|
63
|
+
if (current === undefined) return { registered: false, findings: [] };
|
|
64
|
+
const onDisk = [...new Set([...(await catalogLocales(root)), ...planned])].sort();
|
|
56
65
|
const imported = importedLocales(current);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
const write = async (next: string): Promise<IndexSync> => {
|
|
67
|
+
if (next === current) return { registered: true, findings: [] };
|
|
68
|
+
await disk.write(APP_CATALOGS_PATH, next);
|
|
69
|
+
return { registered: true, findings: [], edited: [APP_CATALOGS_PATH] };
|
|
70
|
+
};
|
|
71
|
+
if (current === i18nIndex(imported)) return write(i18nIndex(onDisk));
|
|
61
72
|
const missing = onDisk.filter((locale) => !imported.includes(locale));
|
|
62
73
|
if (missing.length === 0) return { registered: true, findings: [] };
|
|
63
74
|
const edited = withLocales(current, missing);
|
|
64
75
|
if (edited === undefined) return { registered: false, findings: [refusal(missing)] };
|
|
65
|
-
|
|
66
|
-
return { registered: true, findings: [] };
|
|
76
|
+
return write(edited);
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
/** Each import after the last catalog import (or at the top), each entry at the object's end. */
|
|
70
80
|
function withLocales(source: string, locales: readonly string[]): string | undefined {
|
|
71
|
-
|
|
81
|
+
// Found on the comment-stripped text (same offsets): a `locales: { … }` in a comment above the
|
|
82
|
+
// call is prose, and it was the one this edited.
|
|
83
|
+
const object = LOCALES_OBJECT.exec(stripComments(source));
|
|
72
84
|
if (object === null) return undefined;
|
|
73
|
-
const
|
|
85
|
+
const [whole, head = '', inner = ''] = object;
|
|
86
|
+
// A comment INSIDE the object cannot be carried through a one-line rewrite: refused, with the
|
|
87
|
+
// edit named, rather than dropped.
|
|
88
|
+
if (source.slice(object.index, object.index + whole.length) !== whole) return undefined;
|
|
89
|
+
const body = inner.trim().replace(/,$/, '');
|
|
74
90
|
const entries = [body, ...locales.map(localeEntry)].filter((part) => part !== '').join(', ');
|
|
75
|
-
const withEntries = source.
|
|
91
|
+
const withEntries = `${source.slice(0, object.index)}${head} ${entries} }${source.slice(object.index + whole.length)}`;
|
|
76
92
|
const imports = locales.map(localeImport).join('\n');
|
|
77
93
|
const lastImport = [...withEntries.matchAll(CATALOG_IMPORT)].at(-1);
|
|
78
94
|
if (lastImport === undefined) return `${imports}\n${withEntries}`;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// What `x verify` says about the query an `isr` route keys on. Advice and not a finding: the scan
|
|
2
|
+
// reads source text, so it can miss a read made through a helper and can name one that is not a
|
|
3
|
+
// query read at all — and a route that declared nothing works as it always did. Both are exactly
|
|
4
|
+
// what a reader should hear about before a visitor finds it.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no path-join primitive; Bun.file takes one already joined.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { stripComments } from '@ultimat3/core';
|
|
9
|
+
import { describePages } from '@ultimat3/render';
|
|
10
|
+
import { msg } from './messages';
|
|
11
|
+
|
|
12
|
+
const MEMBER_READ = /\bquery\s*\??\.\s*([A-Za-z_$][\w$]*)\b(?!\s*\()/g;
|
|
13
|
+
const BRACKET_READ = /\bquery\s*(?:\?\.)?\[\s*['"]([^'"]+)['"]\s*\]/g;
|
|
14
|
+
const SEARCH_PARAM_READ = /\bsearchParams\s*\??\.\s*(?:get|getAll|has)\(\s*['"]([^'"]+)['"]/g;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The query parameter names a route module reads, as far as its text says: `query.name`,
|
|
18
|
+
* `query?.name`, `query['name']` and `searchParams.get('name')` (also `getAll`, `has`). A call
|
|
19
|
+
* (`query.where(…)`) is a method, not a parameter. Sorted, each once.
|
|
20
|
+
*/
|
|
21
|
+
export function queryReads(source: string): readonly string[] {
|
|
22
|
+
const code = stripComments(source);
|
|
23
|
+
const names = new Set<string>();
|
|
24
|
+
for (const pattern of [MEMBER_READ, BRACKET_READ, SEARCH_PARAM_READ]) {
|
|
25
|
+
for (const match of code.matchAll(pattern)) {
|
|
26
|
+
if (match[1] !== undefined) names.add(match[1]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [...names].sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const listed = (names: readonly string[]): string => names.map((name) => `'${name}'`).join(', ');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One line per `isr` route with no `revalidate.query` — it renders and stores a page for every
|
|
36
|
+
* distinct query string, so `?x=1`, `?x=2`, … evict its real pages and cost a render each — and
|
|
37
|
+
* one per parameter a route's module reads and its declaration omits: the page is stored under a
|
|
38
|
+
* key without it, so every visitor gets the document rendered for whichever value arrived first.
|
|
39
|
+
*/
|
|
40
|
+
export async function isrQueryAdvice(root: string): Promise<readonly string[]> {
|
|
41
|
+
const advice: string[] = [];
|
|
42
|
+
for (const route of describePages()) {
|
|
43
|
+
if (route.mode !== 'isr') continue;
|
|
44
|
+
const declared = route.revalidateQuery ?? null;
|
|
45
|
+
if (declared === null) {
|
|
46
|
+
advice.push(msg('cli.verify.isrQueryUndeclared', { file: route.file }));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const module = Bun.file(join(root, route.file));
|
|
50
|
+
if (!(await module.exists())) continue;
|
|
51
|
+
for (const name of queryReads(await module.text())) {
|
|
52
|
+
if (declared.includes(name)) continue;
|
|
53
|
+
advice.push(
|
|
54
|
+
msg('cli.verify.isrQueryOmitted', {
|
|
55
|
+
file: route.file,
|
|
56
|
+
name,
|
|
57
|
+
declared: listed([...declared, name].sort()),
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return advice;
|
|
63
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The `query` object a page component is handed. For an `isr` route that declared
|
|
2
|
+
// `revalidate.query` it holds the declared parameters only — and, outside production, it says so
|
|
3
|
+
// once when the page reads any other one: that read is a document varying on something its stored
|
|
4
|
+
// key does not carry, which is one visitor's page answered to every visitor after.
|
|
5
|
+
|
|
6
|
+
import type { ResolveEnvironmentOptions } from '@ultimat3/core';
|
|
7
|
+
import { logger, tryResolveEnvironment } from '@ultimat3/core';
|
|
8
|
+
import type { RouteEntry } from '@ultimat3/render';
|
|
9
|
+
|
|
10
|
+
type Query = Readonly<Record<string, string>>;
|
|
11
|
+
|
|
12
|
+
/** `<route file>\u0000<parameter>` already told — bounded by what the app's source can read. */
|
|
13
|
+
const told = new Set<string>();
|
|
14
|
+
|
|
15
|
+
/** Names every object answers and a renderer or a serializer may ask for: never a parameter. */
|
|
16
|
+
const isProbe = (name: string): boolean =>
|
|
17
|
+
name in Object.prototype || name === 'then' || name === 'toJSON';
|
|
18
|
+
|
|
19
|
+
export function routeQuery(
|
|
20
|
+
entry: RouteEntry,
|
|
21
|
+
url: URL,
|
|
22
|
+
env?: ResolveEnvironmentOptions['env'],
|
|
23
|
+
): Query {
|
|
24
|
+
const query: Query = Object.fromEntries(url.searchParams);
|
|
25
|
+
const declared = entry.config.render === 'isr' ? entry.config.revalidate?.query : undefined;
|
|
26
|
+
if (declared === undefined) return query;
|
|
27
|
+
// Production pays nothing: the plain object, no trap. (`undefined`: an unreadable environment,
|
|
28
|
+
// which is not a development one either.)
|
|
29
|
+
const environment = tryResolveEnvironment(env === undefined ? undefined : { env });
|
|
30
|
+
if (environment === 'production' || environment === undefined) return query;
|
|
31
|
+
return new Proxy(query, {
|
|
32
|
+
get(target, name, receiver) {
|
|
33
|
+
if (typeof name === 'string' && !declared.includes(name) && !isProbe(name)) {
|
|
34
|
+
const key = `${entry.file}\u0000${name}`;
|
|
35
|
+
if (!told.has(key)) {
|
|
36
|
+
told.add(key);
|
|
37
|
+
const widened = [...declared, name].sort().map((one) => `'${one}'`);
|
|
38
|
+
logger.warn('isr.query.unkeyed-read', {
|
|
39
|
+
route: entry.file,
|
|
40
|
+
param: name,
|
|
41
|
+
cause: `the page read query.${name}, and revalidate.query does not list it — the stored page is keyed without it, so every visitor gets the document rendered for whichever value arrived first`,
|
|
42
|
+
fix: `query: [${widened.join(', ')}] // in revalidate, in ${entry.file}`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return Reflect.get(target, name, receiver);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
package/src/mcp-host.ts
CHANGED
|
@@ -105,7 +105,8 @@ export function lazyServices(input: DevHostInput): LazyServices {
|
|
|
105
105
|
// one connection wedged every later tool call in the session with that first error, and the only
|
|
106
106
|
// way out was restarting the host — `startServices` unwinds everything it started before it
|
|
107
107
|
// rejects, so there is nothing left over for a second attempt to collide with.
|
|
108
|
-
|
|
108
|
+
// `[]`: this host starts no role, so it only ever publishes to the bus and never waits for it.
|
|
109
|
+
const boot = retryMemo(() => startServices(services, input.env, undefined, 'apply', []));
|
|
109
110
|
let closed = false;
|
|
110
111
|
return {
|
|
111
112
|
services,
|
package/src/messages.ts
CHANGED
|
@@ -328,6 +328,10 @@ const CATALOG = {
|
|
|
328
328
|
// counts — so the line says how many and `x test <type>` prints the names.
|
|
329
329
|
'cli.verify.allSkipped': 'found {skipped} test(s) and every one skipped itself',
|
|
330
330
|
'cli.verify.warnings': '{count} warning(s) — --verbose or --json lists them',
|
|
331
|
+
'cli.verify.isrQueryUndeclared':
|
|
332
|
+
"{file}: render 'isr' with no revalidate.query — its stored pages are keyed on the whole query string, so any visitor can mint one per request. Declare revalidate: { query: [] } (or the parameters the page varies on)",
|
|
333
|
+
'cli.verify.isrQueryOmitted':
|
|
334
|
+
'{file}: reads the query parameter "{name}", which revalidate.query does not list — the page is stored under a key without it, so every visitor gets the document rendered for whichever value arrived first. Declare query: [{declared}], or stop reading it',
|
|
331
335
|
// The other shape the same counts can take: a suite that reported no test at all, skipped ones
|
|
332
336
|
// included. Its own line and not `allSkipped` with a zero, because the repair differs — there is
|
|
333
337
|
// no skipped test to read a reason off, so the files the step selected hold no test.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Single responsibility: what the `sync` role does when its node cannot start because the bus is
|
|
2
|
+
// away. A `ROLE=sync` container refuses its boot — it has nothing to serve without the bus.
|
|
3
|
+
// `x dev` runs every role in one process, so there the node stays mounted and NOT ready, and is
|
|
4
|
+
// started when the bus answers: the pages, the worker and the scheduler never went down with it.
|
|
5
|
+
|
|
6
|
+
import { isUltimateError, logger } from '@ultimat3/core';
|
|
7
|
+
import type { SyncNode, Transport } from '@ultimat3/realtime/server';
|
|
8
|
+
|
|
9
|
+
export interface BusWaitRuntime {
|
|
10
|
+
readonly transport: Pick<Transport, 'onReconnect'>;
|
|
11
|
+
/** `RunningServices.busTolerated`: the boot is `x dev`, and a bus that is away is waited for. */
|
|
12
|
+
readonly busTolerated?: boolean | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Start the node, or — tolerated, with the bus away — leave it unstarted and start it on the
|
|
17
|
+
* transport's next recovery. Until then an upgrade is shed with a 503 and a retry delay
|
|
18
|
+
* (`sync-upgrade.ts`), so an open page keeps asking and goes live with no restart. Any other
|
|
19
|
+
* refusal, and every refusal outside `x dev`, is the boot's. Returns what stops the wait.
|
|
20
|
+
*/
|
|
21
|
+
export async function startOrWaitForBus(
|
|
22
|
+
node: Pick<SyncNode, 'start'>,
|
|
23
|
+
runtime: BusWaitRuntime,
|
|
24
|
+
): Promise<() => void> {
|
|
25
|
+
const refused = await node.start().then(
|
|
26
|
+
() => undefined,
|
|
27
|
+
(error: unknown) => ({ error }),
|
|
28
|
+
);
|
|
29
|
+
if (refused === undefined) return () => undefined;
|
|
30
|
+
const { error } = refused;
|
|
31
|
+
if (
|
|
32
|
+
runtime.busTolerated !== true ||
|
|
33
|
+
!isUltimateError(error) ||
|
|
34
|
+
error.code !== 'X_TRANSPORT_UNAVAILABLE'
|
|
35
|
+
) {
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
// Once, with the refusal a container would have exited on.
|
|
39
|
+
logger.warn('sync node is waiting for the bus: sockets are refused until it answers', {
|
|
40
|
+
code: error.code,
|
|
41
|
+
cause: error.cause,
|
|
42
|
+
fix: error.fix,
|
|
43
|
+
});
|
|
44
|
+
let waiting = true;
|
|
45
|
+
let off = (): void => undefined;
|
|
46
|
+
const attempt = (): void => {
|
|
47
|
+
if (!waiting) return;
|
|
48
|
+
void node.start().then(
|
|
49
|
+
() => {
|
|
50
|
+
if (!waiting) return;
|
|
51
|
+
waiting = false;
|
|
52
|
+
off();
|
|
53
|
+
logger.info('sync node started: the bus answered');
|
|
54
|
+
},
|
|
55
|
+
// Still away, or away again: the next recovery asks once more.
|
|
56
|
+
() => undefined,
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
off = runtime.transport.onReconnect(attempt);
|
|
60
|
+
// The dial may have landed between the refusal and the line above; asking again costs nothing.
|
|
61
|
+
attempt();
|
|
62
|
+
return () => {
|
|
63
|
+
waiting = false;
|
|
64
|
+
off();
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/role-sync.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { neighbouringPort, PORT_RANGE, portPairAfter } from './flag-number';
|
|
27
27
|
import { portFree } from './port-probe';
|
|
28
28
|
import type { StartRolesOptions } from './role-start';
|
|
29
|
+
import { startOrWaitForBus } from './role-sync-bus-wait';
|
|
29
30
|
import type { Env } from './runtime-bindings';
|
|
30
31
|
import { syncAuthenticator } from './sync-authenticator';
|
|
31
32
|
import { syncOriginsFrom } from './sync-url';
|
|
@@ -327,14 +328,17 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
327
328
|
ttlMs: options.runtime.presenceTtlMs,
|
|
328
329
|
}),
|
|
329
330
|
});
|
|
330
|
-
await node.
|
|
331
|
+
const stopWaiting = await startOrWaitForBus(node, options.runtime);
|
|
331
332
|
return {
|
|
332
333
|
node,
|
|
333
334
|
registry,
|
|
334
335
|
// The node's OWN path, asked rather than restated: `SyncNodeOptions.path` is settable and a
|
|
335
336
|
// second copy of `/_x/sync` here is the copy that stays behind when it moves.
|
|
336
337
|
mount: { path: node.path, fetch: node.fetch, websocket: node.websocket },
|
|
337
|
-
stop: () =>
|
|
338
|
+
stop: async () => {
|
|
339
|
+
stopWaiting();
|
|
340
|
+
await node.stop();
|
|
341
|
+
},
|
|
338
342
|
listen: async (appUrl) => {
|
|
339
343
|
origins.push(...devOrigins(options, origins, appUrl));
|
|
340
344
|
return await listen(options, node, { registry, hub }, appUrl);
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Single responsibility: the fanout bus as ONE boot holds it — what the roles it starts do with
|
|
2
|
+
// the bus, the dial that calls for, the readiness check, and the label the boot line prints.
|
|
3
|
+
//
|
|
4
|
+
// The rule is the role's, not the transport's. A `sync` node serves sockets from the bus and a
|
|
5
|
+
// replicator feeds them: neither can work without it, so each awaits the dial and refuses its
|
|
6
|
+
// boot, coded, inside a bounded wait. `web`, `worker` and `scheduler` only PUBLISH — channel
|
|
7
|
+
// events and cache invalidations, both of which mean "re-read" — so for them the bus is dialled in
|
|
8
|
+
// the background and a dead one is `degraded`: the pod still boots, serves and runs jobs. Until
|
|
9
|
+
// 2026-10 every role awaited the dial and the JetStream bucket, so a web pod that restarted while
|
|
10
|
+
// NATS was down took the app's pages, webhooks and send jobs down with a feature none of them use.
|
|
11
|
+
//
|
|
12
|
+
// `x dev` is the one boot that TOLERATES a missing bus for a role that serves from it: it runs
|
|
13
|
+
// every role in one process, so the `sync` role's refusal took the pages, the worker and the
|
|
14
|
+
// scheduler down with it — the same outage, on the developer's own machine. There the refusal is
|
|
15
|
+
// logged once, coded, the dial goes on in the background, and the pages run without live until it
|
|
16
|
+
// lands. A `ROLE=sync` container keeps the hard refusal: it has nothing else to serve.
|
|
17
|
+
|
|
18
|
+
import type { RealtimeConfig, Role } from '@ultimat3/core';
|
|
19
|
+
import { isUltimateError, logger, registerReadinessCheck } from '@ultimat3/core';
|
|
20
|
+
import type { BusUse, SelectTransportOptions, Transport } from '@ultimat3/realtime/server';
|
|
21
|
+
import { selectTransport, setChannelTransport } from '@ultimat3/realtime/server';
|
|
22
|
+
import type { Env } from './runtime-bindings';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What a process running `roles` does with the bus. `undefined` — a boot that names no roles — is
|
|
26
|
+
* held to the strictest reading, the one every boot had. `realtime.enabled: false` starts neither
|
|
27
|
+
* realtime role (`role-realtime.ts`), so nothing in such a process waits for the bus.
|
|
28
|
+
*/
|
|
29
|
+
export function busUseFor(
|
|
30
|
+
roles: readonly Role[] | undefined,
|
|
31
|
+
realtime: Pick<RealtimeConfig, 'enabled'>,
|
|
32
|
+
): BusUse {
|
|
33
|
+
if (roles === undefined) return 'sockets';
|
|
34
|
+
if (!realtime.enabled) return 'publish';
|
|
35
|
+
if (roles.includes('sync')) return 'sockets';
|
|
36
|
+
return roles.includes('replicator') ? 'feed' : 'publish';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A transport that says whether it is connected. NATS does; the in-process bus has nothing to be. */
|
|
40
|
+
interface ConnectableTransport {
|
|
41
|
+
readonly connected: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const saysConnected = (transport: Transport): transport is Transport & ConnectableTransport =>
|
|
45
|
+
typeof (transport as Partial<ConnectableTransport>).connected === 'boolean';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The boot line's `bus=` value: `nats(up)`, `nats(connecting)`, or the bare name of a bus with no
|
|
49
|
+
* connection to lose. A fixed vocabulary a script may parse, never a catalog lookup.
|
|
50
|
+
*/
|
|
51
|
+
export function busLabel(transport: Transport): string {
|
|
52
|
+
if (!saysConnected(transport)) return transport.name;
|
|
53
|
+
return `${transport.name}(${transport.connected ? 'up' : 'connecting'})`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RunningBus {
|
|
57
|
+
readonly transport: Transport;
|
|
58
|
+
/** The env key that selected the bus — never the url behind it — or `runtime override`. */
|
|
59
|
+
readonly detail: string;
|
|
60
|
+
/** Everything `start()` acquired, newest first; the first failure is rethrown, every step runs. */
|
|
61
|
+
stop(): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* How long `x dev` waits for a bus its realtime roles need before it boots without one. Short on
|
|
66
|
+
* purpose: a NATS started beside it is reachable by then, and one that is not is dialled in the
|
|
67
|
+
* background anyway.
|
|
68
|
+
*/
|
|
69
|
+
export const DEV_BUS_WAIT_MS = 3_000;
|
|
70
|
+
|
|
71
|
+
export interface SelectedBus {
|
|
72
|
+
readonly use: BusUse;
|
|
73
|
+
/**
|
|
74
|
+
* `x dev` with a role that serves from the bus: a bus that is away is waited for in the
|
|
75
|
+
* background rather than refused (`RunningServices.busTolerated`).
|
|
76
|
+
*/
|
|
77
|
+
readonly tolerant: boolean;
|
|
78
|
+
/** What the sync role must give `PresenceRegistry` (`RunningServices.presenceTtlMs`). */
|
|
79
|
+
readonly presenceTtlMs: number;
|
|
80
|
+
/**
|
|
81
|
+
* Dial as `use` calls for, install the transport as the process's channel bus, and register its
|
|
82
|
+
* readiness check. Rejects — with nothing left registered or open — only for a use that cannot
|
|
83
|
+
* work without the bus.
|
|
84
|
+
*/
|
|
85
|
+
start(): Promise<RunningBus>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SelectBusInput {
|
|
89
|
+
readonly env: Env;
|
|
90
|
+
readonly realtime: RealtimeConfig;
|
|
91
|
+
/** The roles this process will start; `undefined` when the caller does not know. */
|
|
92
|
+
readonly roles: readonly Role[] | undefined;
|
|
93
|
+
/** `RuntimeOverrides.transport`: already connected, and its owner's to close. */
|
|
94
|
+
readonly override?: Transport | undefined;
|
|
95
|
+
/** Injected by a test: a fake dial, a short wait. */
|
|
96
|
+
readonly select?: SelectTransportOptions | undefined;
|
|
97
|
+
/** The boot is `x dev`: every role in one process, on the developer's machine. */
|
|
98
|
+
readonly dev?: boolean | undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Selection is pure — it parses env and constructs, it dials nothing — so a config and an
|
|
103
|
+
* environment that disagree refuse here, before any service starts (`selectTransport`).
|
|
104
|
+
*/
|
|
105
|
+
export function selectBus(input: SelectBusInput): SelectedBus {
|
|
106
|
+
const use = busUseFor(input.roles, input.realtime);
|
|
107
|
+
const tolerant = input.dev === true && use !== 'publish';
|
|
108
|
+
const selection = selectTransport(input.env, input.realtime, {
|
|
109
|
+
...(tolerant ? { connectWithinMs: DEV_BUS_WAIT_MS } : {}),
|
|
110
|
+
...input.select,
|
|
111
|
+
use,
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
use,
|
|
115
|
+
tolerant,
|
|
116
|
+
presenceTtlMs: selection.presenceTtlMs,
|
|
117
|
+
async start(): Promise<RunningBus> {
|
|
118
|
+
const started: (() => void | Promise<void>)[] = [];
|
|
119
|
+
const stop = async (): Promise<void> => {
|
|
120
|
+
let failure: { readonly error: unknown } | undefined;
|
|
121
|
+
for (const step of started.splice(0).reverse()) {
|
|
122
|
+
try {
|
|
123
|
+
await step();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
failure ??= { error };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (failure !== undefined) throw failure.error;
|
|
129
|
+
};
|
|
130
|
+
try {
|
|
131
|
+
// A supplied transport is already connected and is NOT closed here: whoever built it owns
|
|
132
|
+
// its socket, which is why the override skips both halves rather than only the dial.
|
|
133
|
+
let transport = input.override;
|
|
134
|
+
if (transport === undefined) {
|
|
135
|
+
// Before the dial, not after: a dial refused for being late is still in flight inside
|
|
136
|
+
// the client library, and closing the transport is what releases whatever it lands as.
|
|
137
|
+
started.push(() => selection.transport.close());
|
|
138
|
+
try {
|
|
139
|
+
await selection.connect();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (!tolerant || !isUltimateError(error) || error.code !== 'X_TRANSPORT_UNAVAILABLE') {
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
// Said ONCE, with the refusal a container would have exited on. The transport is not
|
|
145
|
+
// closed: its dial loop goes on, and `ultimate bus` is the line that says it landed.
|
|
146
|
+
logger.warn('ultimate bus is away: x dev serves without live until it answers', {
|
|
147
|
+
code: error.code,
|
|
148
|
+
cause: error.cause,
|
|
149
|
+
fix: error.fix,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
transport = selection.transport;
|
|
153
|
+
}
|
|
154
|
+
// The bus an app's own code publishes a channel event on (`publishChannelEvent`), in EVERY
|
|
155
|
+
// role: the hub is the `sync` role's, so a job or an action reached none, and an
|
|
156
|
+
// events-only channel — the one kind that needs no replicator — could be fed by nothing
|
|
157
|
+
// but a socket.
|
|
158
|
+
started.push(setChannelTransport(transport));
|
|
159
|
+
// Only a transport that can be disconnected gets a check. `NatsTransport.connected` is a
|
|
160
|
+
// synchronous getter over the client's own state, so no probe is needed; the in-process
|
|
161
|
+
// bus has nothing to lose a connection to, and a check that can only answer `true` is a
|
|
162
|
+
// number in `registered` that means nothing.
|
|
163
|
+
if (saysConnected(transport)) {
|
|
164
|
+
const connectable = transport;
|
|
165
|
+
started.push(
|
|
166
|
+
registerReadinessCheck(
|
|
167
|
+
'transport',
|
|
168
|
+
() => connectable.connected,
|
|
169
|
+
// A publisher serves without the bus, so losing it must not pull the pod from the
|
|
170
|
+
// ingress — it is reported, and `/readyz?deep=1` is where a monitor reads it.
|
|
171
|
+
// `x dev` serves its pages from the same process: its bus is a degradation too.
|
|
172
|
+
{ onFailure: use === 'publish' || tolerant ? 'degraded' : 'failing' },
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
// Each time the bus comes (back) up, on one line: the boot line may have said
|
|
176
|
+
// `connecting`, and this is the other half of it.
|
|
177
|
+
started.push(
|
|
178
|
+
connectable.onReconnect(() => {
|
|
179
|
+
logger.info('ultimate bus', { bus: busLabel(connectable), use });
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
transport,
|
|
185
|
+
// The env key that selected the bus — or the honest answer that no env key did, because
|
|
186
|
+
// a boot line reading `NATS_URL` over a transport the host handed in is a lie a script
|
|
187
|
+
// parses.
|
|
188
|
+
detail: input.override === undefined ? selection.detail : 'runtime override',
|
|
189
|
+
stop,
|
|
190
|
+
};
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// The refusal is the failure worth reporting, not a release on the way out.
|
|
193
|
+
await stop().catch(() => undefined);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|