proteum 2.1.7 → 2.1.9-1
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/AGENTS.md +16 -5
- package/README.md +5 -1
- package/agents/project/AGENTS.md +13 -2
- package/agents/project/diagnostics.md +4 -0
- package/agents/project/optimizations.md +1 -0
- package/cli/app/index.ts +33 -9
- package/cli/bin.js +0 -8
- package/cli/commands/build.ts +60 -9
- package/cli/commands/dev.ts +232 -5
- package/cli/compiler/artifacts/commands.ts +20 -5
- package/cli/compiler/client/index.ts +46 -23
- package/cli/compiler/common/bundleAnalysis.ts +56 -1
- package/cli/compiler/common/index.ts +16 -5
- package/cli/compiler/index.ts +12 -5
- package/cli/compiler/server/index.ts +39 -13
- package/cli/index.ts +43 -2
- package/cli/paths.ts +341 -10
- package/cli/presentation/commands.ts +30 -4
- package/cli/presentation/devSession.ts +27 -34
- package/cli/presentation/help.ts +4 -0
- package/cli/presentation/ink.ts +10 -5
- package/cli/presentation/welcome.ts +67 -0
- package/cli/runtime/commands.ts +40 -3
- package/cli/runtime/devSessions.ts +337 -0
- package/cli/scaffold/index.ts +27 -4
- package/cli/scaffold/templates.ts +34 -20
- package/cli/utils/check.ts +5 -11
- package/client/app/index.ts +17 -2
- package/client/app.tsconfig.json +11 -10
- package/common/connectedProjects.ts +7 -0
- package/common/dev/serverHotReload.ts +22 -1
- package/package.json +2 -1
- package/server/app.tsconfig.json +10 -9
- package/server/services/router/http/index.ts +72 -10
|
@@ -1,17 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import type { TServerReadyConnectedProject } from '../../common/dev/serverHotReload';
|
|
3
2
|
import { renderRows } from './layout';
|
|
4
|
-
import { renderInk } from './ink';
|
|
5
|
-
|
|
6
|
-
const ProteumWordmark = [
|
|
7
|
-
String.raw` ____ ____ ___ _____ _____ _ _ __ __`,
|
|
8
|
-
String.raw`| _ \| _ \ / _ \_ _| ____| | | | \/ |`,
|
|
9
|
-
String.raw`| |_) | |_) | | | || | | _| | | | | |\/| |`,
|
|
10
|
-
String.raw`| __/| _ <| |_| || | | |___| |_| | | | |`,
|
|
11
|
-
String.raw`|_| |_| \_\\___/ |_| |_____|\___/|_| |_|`,
|
|
12
|
-
];
|
|
3
|
+
import { CliReact, renderInk } from './ink';
|
|
4
|
+
import { renderWelcomePanel } from './welcome';
|
|
13
5
|
|
|
14
|
-
const
|
|
6
|
+
const formatConnectedProjectLabel = (connectedProject: TServerReadyConnectedProject) =>
|
|
7
|
+
`${connectedProject.namespace} -> ${connectedProject.name}`;
|
|
15
8
|
|
|
16
9
|
export const renderDevSession = async ({
|
|
17
10
|
appName,
|
|
@@ -19,6 +12,7 @@ export const renderDevSession = async ({
|
|
|
19
12
|
routerPort,
|
|
20
13
|
devEventPort,
|
|
21
14
|
connectedProjects,
|
|
15
|
+
proteumInstallSummary,
|
|
22
16
|
proteumVersion,
|
|
23
17
|
}: {
|
|
24
18
|
appName: string;
|
|
@@ -26,24 +20,14 @@ export const renderDevSession = async ({
|
|
|
26
20
|
routerPort: number;
|
|
27
21
|
devEventPort: number;
|
|
28
22
|
connectedProjects?: Array<{ namespace: string; urlInternal: string }>;
|
|
23
|
+
proteumInstallSummary?: string;
|
|
29
24
|
proteumVersion: string;
|
|
30
25
|
}) =>
|
|
31
26
|
[
|
|
32
|
-
await
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
);
|
|
37
|
-
const versionLabel = proteumVersion ? `v${proteumVersion}` : '';
|
|
38
|
-
|
|
39
|
-
return createElement(
|
|
40
|
-
Box,
|
|
41
|
-
{ borderStyle: 'round', borderColor: 'blue', paddingX: 2, paddingY: 0, flexDirection: 'column' },
|
|
42
|
-
createElement(Text, { bold: true, backgroundColor: 'blue', color: 'white' }, ' WELCOME TO '),
|
|
43
|
-
createElement(Box, { flexDirection: 'column' }, ...wordmark),
|
|
44
|
-
versionLabel ? createElement(Text, { bold: true, color: 'blue' }, versionLabel) : null,
|
|
45
|
-
createElement(Text, { dimColor: true }, ProteumTagline),
|
|
46
|
-
);
|
|
27
|
+
await renderWelcomePanel({
|
|
28
|
+
installSummary: proteumInstallSummary,
|
|
29
|
+
version: proteumVersion,
|
|
30
|
+
tagline: 'Agent-first SSR compiler and server loop.',
|
|
47
31
|
}),
|
|
48
32
|
renderRows(
|
|
49
33
|
[
|
|
@@ -61,7 +45,8 @@ export const renderDevSession = async ({
|
|
|
61
45
|
{ label: 'perf', value: `proteum perf top --port ${routerPort}` },
|
|
62
46
|
{ label: 'trace', value: `proteum trace latest --port ${routerPort}` },
|
|
63
47
|
{ label: 'trace deep', value: `proteum trace arm --capture deep --port ${routerPort}` },
|
|
64
|
-
{ label: '
|
|
48
|
+
{ label: 'reload', value: 'CTRL+R' },
|
|
49
|
+
{ label: 'shutdown', value: 'CTRL+C' },
|
|
65
50
|
],
|
|
66
51
|
{ minLabelWidth: 12, maxLabelWidth: 12 },
|
|
67
52
|
),
|
|
@@ -71,15 +56,16 @@ export const renderServerReadyBanner = async ({
|
|
|
71
56
|
appName,
|
|
72
57
|
publicUrl,
|
|
73
58
|
routerPort,
|
|
74
|
-
|
|
59
|
+
connectedProjects,
|
|
75
60
|
}: {
|
|
76
61
|
appName: string;
|
|
77
62
|
publicUrl: string;
|
|
78
63
|
routerPort: number;
|
|
79
|
-
|
|
64
|
+
connectedProjects?: TServerReadyConnectedProject[];
|
|
80
65
|
}) =>
|
|
81
66
|
renderInk(({ Box, Text }) => {
|
|
82
|
-
const createElement =
|
|
67
|
+
const createElement = CliReact.createElement;
|
|
68
|
+
const verifiedConnectedProjects = connectedProjects || [];
|
|
83
69
|
|
|
84
70
|
return createElement(
|
|
85
71
|
Box,
|
|
@@ -88,13 +74,20 @@ export const renderServerReadyBanner = async ({
|
|
|
88
74
|
createElement(Text, { bold: true, color: 'green' }, appName),
|
|
89
75
|
createElement(Text, { bold: true }, publicUrl),
|
|
90
76
|
createElement(Text, { dimColor: true }, 'SSR server is listening for requests and hot reloads.'),
|
|
91
|
-
|
|
77
|
+
verifiedConnectedProjects.length > 0
|
|
92
78
|
? createElement(
|
|
93
79
|
Text,
|
|
94
80
|
{ dimColor: true },
|
|
95
|
-
`Connected
|
|
81
|
+
`Connected apps: ${verifiedConnectedProjects.map((connectedProject) => formatConnectedProjectLabel(connectedProject)).join(', ')}`,
|
|
96
82
|
)
|
|
97
83
|
: null,
|
|
84
|
+
...verifiedConnectedProjects.map((connectedProject) =>
|
|
85
|
+
createElement(
|
|
86
|
+
Text,
|
|
87
|
+
{ key: `connected-ping-${connectedProject.namespace}`, dimColor: true },
|
|
88
|
+
`Ping OK (/ping): ${formatConnectedProjectLabel(connectedProject)}`,
|
|
89
|
+
),
|
|
90
|
+
),
|
|
98
91
|
createElement(Text, { dimColor: true }, `Diagnose /: proteum diagnose / --port ${routerPort}`),
|
|
99
92
|
createElement(Text, { dimColor: true }, `Perf top: proteum perf top --port ${routerPort}`),
|
|
100
93
|
createElement(Text, { dimColor: true }, `Trace latest: proteum trace latest --port ${routerPort}`),
|
|
@@ -103,7 +96,7 @@ export const renderServerReadyBanner = async ({
|
|
|
103
96
|
|
|
104
97
|
export const renderDevShutdownBanner = async () =>
|
|
105
98
|
renderInk(({ Box, Text }) => {
|
|
106
|
-
const createElement =
|
|
99
|
+
const createElement = CliReact.createElement;
|
|
107
100
|
|
|
108
101
|
return createElement(
|
|
109
102
|
Box,
|
package/cli/presentation/help.ts
CHANGED
|
@@ -135,6 +135,10 @@ export const renderCliOverview = async ({
|
|
|
135
135
|
indent: ' ',
|
|
136
136
|
nextIndent: ' ',
|
|
137
137
|
}),
|
|
138
|
+
wrapText('Every Proteum CLI invocation prints the welcome banner. `proteum dev` is the only command that clears the interactive terminal before rendering its session UI.', {
|
|
139
|
+
indent: ' ',
|
|
140
|
+
nextIndent: ' ',
|
|
141
|
+
}),
|
|
138
142
|
wrapText('Legacy single-dash flags and positional booleans remain accepted for older app scripts, but new docs should prefer modern long flags.', {
|
|
139
143
|
indent: ' ',
|
|
140
144
|
nextIndent: ' ',
|
package/cli/presentation/ink.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
2
|
|
|
3
3
|
import { importEsm } from '../runtime/importEsm';
|
|
4
4
|
import { getTerminalWidth } from './layout';
|
|
@@ -13,6 +13,9 @@ type TInkRuntime = {
|
|
|
13
13
|
StatusMessage: TInkUiModule['StatusMessage'];
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
// Keep the CLI renderer on the exact React instance Ink resolved for this install shape.
|
|
17
|
+
const CliReact = createRequire(require.resolve('ink'))('react') as typeof import('react');
|
|
18
|
+
|
|
16
19
|
let inkRuntimePromise: Promise<TInkRuntime> | undefined;
|
|
17
20
|
|
|
18
21
|
const loadInkRuntime = () => {
|
|
@@ -41,7 +44,7 @@ export const renderInk = async (
|
|
|
41
44
|
|
|
42
45
|
export const renderTitle = async (title: string, subtitle?: string) =>
|
|
43
46
|
renderInk(({ Box, Text }) => {
|
|
44
|
-
const createElement =
|
|
47
|
+
const createElement = CliReact.createElement;
|
|
45
48
|
|
|
46
49
|
return createElement(
|
|
47
50
|
Box,
|
|
@@ -52,18 +55,20 @@ export const renderTitle = async (title: string, subtitle?: string) =>
|
|
|
52
55
|
});
|
|
53
56
|
|
|
54
57
|
export const renderSection = async (title: string, body: string) => {
|
|
55
|
-
const heading = await renderInk(({ Text }) =>
|
|
58
|
+
const heading = await renderInk(({ Text }) => CliReact.createElement(Text, { bold: true }, title));
|
|
56
59
|
return `${heading}\n${body}`;
|
|
57
60
|
};
|
|
58
61
|
|
|
59
62
|
export const renderStep = async (label: string, message: string) =>
|
|
60
|
-
renderInk(({ Text }) =>
|
|
63
|
+
renderInk(({ Text }) => CliReact.createElement(Text, { color: 'cyan' }, `${label} ${message}`));
|
|
61
64
|
|
|
62
65
|
const renderStatusMessage = async (variant: 'success' | 'warning' | 'error', message: string) =>
|
|
63
|
-
renderInk(({ StatusMessage }) =>
|
|
66
|
+
renderInk(({ StatusMessage }) => CliReact.createElement(StatusMessage, { variant }, message));
|
|
64
67
|
|
|
65
68
|
export const renderSuccess = (message: string) => renderStatusMessage('success', message);
|
|
66
69
|
|
|
67
70
|
export const renderWarning = (message: string) => renderStatusMessage('warning', message);
|
|
68
71
|
|
|
69
72
|
export const renderDanger = (message: string) => renderStatusMessage('error', message);
|
|
73
|
+
|
|
74
|
+
export { CliReact };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { renderRows } from './layout';
|
|
2
|
+
import { CliReact, renderInk } from './ink';
|
|
3
|
+
|
|
4
|
+
const ProteumWordmark = [
|
|
5
|
+
String.raw` ____ ____ ___ _____ _____ _ _ __ __`,
|
|
6
|
+
String.raw`| _ \| _ \ / _ \_ _| ____| | | | \/ |`,
|
|
7
|
+
String.raw`| |_) | |_) | | | || | | _| | | | | |\/| |`,
|
|
8
|
+
String.raw`| __/| _ <| |_| || | | |___| |_| | | | |`,
|
|
9
|
+
String.raw`|_| |_| \_\\___/ |_| |_____|\___/|_| |_|`,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export const clearInteractiveConsole = () => {
|
|
13
|
+
if (process.stdout.isTTY !== true || process.env.TERM === 'dumb') return;
|
|
14
|
+
|
|
15
|
+
process.stdout.write('\x1B[2J\x1B[3J\x1B[H');
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const renderWelcomePanel = async ({
|
|
19
|
+
installSummary,
|
|
20
|
+
version,
|
|
21
|
+
tagline,
|
|
22
|
+
}: {
|
|
23
|
+
installSummary?: string;
|
|
24
|
+
version: string;
|
|
25
|
+
tagline: string;
|
|
26
|
+
}) =>
|
|
27
|
+
renderInk(({ Box, Text }) => {
|
|
28
|
+
const createElement = CliReact.createElement;
|
|
29
|
+
const wordmark = ProteumWordmark.map((line) =>
|
|
30
|
+
createElement(Text, { key: line, bold: true, color: 'blue' }, line),
|
|
31
|
+
);
|
|
32
|
+
const versionLabel = version ? `v${version}` : '';
|
|
33
|
+
|
|
34
|
+
return createElement(
|
|
35
|
+
Box,
|
|
36
|
+
{ borderStyle: 'round', borderColor: 'blue', paddingX: 2, paddingY: 0, flexDirection: 'column' },
|
|
37
|
+
createElement(Text, { bold: true, backgroundColor: 'blue', color: 'white' }, ' WELCOME TO '),
|
|
38
|
+
createElement(Box, { flexDirection: 'column' }, ...wordmark),
|
|
39
|
+
versionLabel ? createElement(Text, { bold: true, color: 'blue' }, versionLabel) : null,
|
|
40
|
+
installSummary ? createElement(Text, { dimColor: true }, `Installed via ${installSummary}`) : null,
|
|
41
|
+
createElement(Text, { dimColor: true }, tagline),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const renderCliWelcomeBanner = async ({
|
|
46
|
+
command,
|
|
47
|
+
installSummary,
|
|
48
|
+
version,
|
|
49
|
+
}: {
|
|
50
|
+
command: string;
|
|
51
|
+
installSummary?: string;
|
|
52
|
+
version: string;
|
|
53
|
+
}) =>
|
|
54
|
+
[
|
|
55
|
+
await renderWelcomePanel({
|
|
56
|
+
installSummary,
|
|
57
|
+
version,
|
|
58
|
+
tagline: 'Explicit SSR / SEO / TypeScript framework for agent-friendly apps.',
|
|
59
|
+
}),
|
|
60
|
+
renderRows(
|
|
61
|
+
[
|
|
62
|
+
{ label: 'command', value: command },
|
|
63
|
+
{ label: 'shutdown', value: 'CTRL+C' },
|
|
64
|
+
],
|
|
65
|
+
{ minLabelWidth: 10, maxLabelWidth: 10 },
|
|
66
|
+
),
|
|
67
|
+
].join('\n\n');
|
package/cli/runtime/commands.ts
CHANGED
|
@@ -79,13 +79,38 @@ class DevCommand extends ProteumCommand {
|
|
|
79
79
|
|
|
80
80
|
public static usage = buildUsage('dev');
|
|
81
81
|
|
|
82
|
+
public json = Option.Boolean('--json', false, { description: 'Print machine-readable dev session output.' });
|
|
82
83
|
public port = Option.String('--port', { description: 'Override the router port.' });
|
|
83
84
|
public cache = Option.Boolean('--cache', true, { description: 'Enable filesystem caching.' });
|
|
84
|
-
public
|
|
85
|
+
public sessionFile = Option.String('--session-file', {
|
|
86
|
+
description: 'Override the dev session file path used for list, stop, or the active dev server.',
|
|
87
|
+
});
|
|
88
|
+
public replaceExisting = Option.Boolean('--replace-existing', false, {
|
|
89
|
+
description: 'Stop the existing matching dev session before starting a new one.',
|
|
90
|
+
});
|
|
91
|
+
public all = Option.Boolean('--all', false, {
|
|
92
|
+
description: 'When used with `dev stop`, stop every tracked dev session for the current app root.',
|
|
93
|
+
});
|
|
94
|
+
public stale = Option.Boolean('--stale', false, {
|
|
95
|
+
description: 'Filter `dev list` or `dev stop --all` to stale tracked sessions only.',
|
|
96
|
+
});
|
|
97
|
+
public args = Option.Rest();
|
|
85
98
|
|
|
86
99
|
public async execute() {
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
const [maybeAction = '', ...restArgs] = this.args;
|
|
101
|
+
const action = maybeAction === 'list' || maybeAction === 'stop' ? maybeAction : '';
|
|
102
|
+
|
|
103
|
+
assertNoLegacyArgs('dev', action ? restArgs : this.args);
|
|
104
|
+
this.setCliArgs({
|
|
105
|
+
action: action || 'start',
|
|
106
|
+
port: this.port ?? '',
|
|
107
|
+
cache: this.cache,
|
|
108
|
+
json: this.json,
|
|
109
|
+
sessionFile: this.sessionFile ?? '',
|
|
110
|
+
replaceExisting: this.replaceExisting,
|
|
111
|
+
all: this.all,
|
|
112
|
+
stale: this.stale,
|
|
113
|
+
});
|
|
89
114
|
await runCommandModule(() => import('../commands/dev'));
|
|
90
115
|
}
|
|
91
116
|
}
|
|
@@ -113,6 +138,15 @@ class BuildCommand extends ProteumCommand {
|
|
|
113
138
|
public prod = Option.Boolean('--prod', false, { description: 'Build in production mode.' });
|
|
114
139
|
public cache = Option.Boolean('--cache', false, { description: 'Enable filesystem caching during the build.' });
|
|
115
140
|
public analyze = Option.Boolean('--analyze', false, { description: 'Emit the client bundle analysis report.' });
|
|
141
|
+
public analyzeServe = Option.Boolean('--analyze-serve', false, {
|
|
142
|
+
description: 'Serve the bundle analysis over HTTP instead of only writing a static report.',
|
|
143
|
+
});
|
|
144
|
+
public analyzeHost = Option.String('--analyze-host', {
|
|
145
|
+
description: 'Host used by the analyzer HTTP server when `--analyze-serve` is enabled.',
|
|
146
|
+
});
|
|
147
|
+
public analyzePort = Option.String('--analyze-port', {
|
|
148
|
+
description: 'Port used by the analyzer HTTP server when `--analyze-serve` is enabled. Use `auto` for an ephemeral port.',
|
|
149
|
+
});
|
|
116
150
|
public strict = Option.Boolean('--strict', false, {
|
|
117
151
|
description: 'Refresh generated typings and fail the build if TypeScript reports any error.',
|
|
118
152
|
});
|
|
@@ -125,6 +159,9 @@ class BuildCommand extends ProteumCommand {
|
|
|
125
159
|
prod: this.prod,
|
|
126
160
|
cache: this.cache,
|
|
127
161
|
analyze: this.analyze,
|
|
162
|
+
analyzeServe: this.analyzeServe,
|
|
163
|
+
analyzeHost: this.analyzeHost ?? '',
|
|
164
|
+
analyzePort: this.analyzePort ?? '',
|
|
128
165
|
strict: this.strict,
|
|
129
166
|
} satisfies TArgsObject;
|
|
130
167
|
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
|
|
5
|
+
export const devSessionRegistryVersion = 1 as const;
|
|
6
|
+
|
|
7
|
+
export type TDevSessionState = 'starting' | 'ready';
|
|
8
|
+
|
|
9
|
+
export type TDevSessionRecord = {
|
|
10
|
+
version: typeof devSessionRegistryVersion;
|
|
11
|
+
pid: number;
|
|
12
|
+
appRoot: string;
|
|
13
|
+
routerPort: number;
|
|
14
|
+
publicUrl: string;
|
|
15
|
+
startedAt: string;
|
|
16
|
+
updatedAt: string;
|
|
17
|
+
sessionFilePath: string;
|
|
18
|
+
state: TDevSessionState;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type TDevSessionInspection = {
|
|
22
|
+
sessionFilePath: string;
|
|
23
|
+
record: TDevSessionRecord | null;
|
|
24
|
+
live: boolean;
|
|
25
|
+
stale: boolean;
|
|
26
|
+
invalid: boolean;
|
|
27
|
+
parseError: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TStopDevSessionResult = {
|
|
31
|
+
sessionFilePath: string;
|
|
32
|
+
pid: number | null;
|
|
33
|
+
routerPort: number | null;
|
|
34
|
+
publicUrl: string;
|
|
35
|
+
state: TDevSessionState | '';
|
|
36
|
+
matched: boolean;
|
|
37
|
+
stopped: boolean;
|
|
38
|
+
removed: boolean;
|
|
39
|
+
stale: boolean;
|
|
40
|
+
live: boolean;
|
|
41
|
+
invalid: boolean;
|
|
42
|
+
parseError: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const defaultRegistryDirectoryParts = ['var', 'run', 'proteum', 'dev'];
|
|
46
|
+
|
|
47
|
+
const sleep = async (durationMs: number) => await new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
48
|
+
|
|
49
|
+
const isRecordShape = (value: unknown): value is TDevSessionRecord => {
|
|
50
|
+
if (!value || typeof value !== 'object') return false;
|
|
51
|
+
|
|
52
|
+
const candidate = value as Partial<TDevSessionRecord>;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
candidate.version === devSessionRegistryVersion &&
|
|
56
|
+
typeof candidate.pid === 'number' &&
|
|
57
|
+
Number.isInteger(candidate.pid) &&
|
|
58
|
+
candidate.pid > 0 &&
|
|
59
|
+
typeof candidate.appRoot === 'string' &&
|
|
60
|
+
candidate.appRoot.length > 0 &&
|
|
61
|
+
typeof candidate.routerPort === 'number' &&
|
|
62
|
+
Number.isInteger(candidate.routerPort) &&
|
|
63
|
+
candidate.routerPort > 0 &&
|
|
64
|
+
typeof candidate.publicUrl === 'string' &&
|
|
65
|
+
typeof candidate.startedAt === 'string' &&
|
|
66
|
+
typeof candidate.updatedAt === 'string' &&
|
|
67
|
+
typeof candidate.sessionFilePath === 'string' &&
|
|
68
|
+
candidate.sessionFilePath.length > 0 &&
|
|
69
|
+
(candidate.state === 'starting' || candidate.state === 'ready')
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const canSignalProcess = (pid: number, signal: NodeJS.Signals | 0) => {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(pid, signal);
|
|
76
|
+
return true;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const errno = error as NodeJS.ErrnoException;
|
|
79
|
+
|
|
80
|
+
if (errno.code === 'ESRCH') return false;
|
|
81
|
+
if (errno.code === 'EPERM') return true;
|
|
82
|
+
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const isProcessAlive = (pid: number) => canSignalProcess(pid, 0);
|
|
88
|
+
|
|
89
|
+
const waitForProcessExit = async (pid: number, timeoutMs: number) => {
|
|
90
|
+
const deadline = Date.now() + timeoutMs;
|
|
91
|
+
|
|
92
|
+
while (Date.now() < deadline) {
|
|
93
|
+
if (!isProcessAlive(pid)) return true;
|
|
94
|
+
await sleep(100);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return !isProcessAlive(pid);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const getDevSessionRegistryDirectory = (appRoot: string) => path.join(appRoot, ...defaultRegistryDirectoryParts);
|
|
101
|
+
|
|
102
|
+
export const resolveDevSessionFilePath = ({
|
|
103
|
+
appRoot,
|
|
104
|
+
port,
|
|
105
|
+
sessionFilePath,
|
|
106
|
+
}: {
|
|
107
|
+
appRoot: string;
|
|
108
|
+
port: number;
|
|
109
|
+
sessionFilePath?: string;
|
|
110
|
+
}) => {
|
|
111
|
+
if (sessionFilePath && sessionFilePath.trim()) {
|
|
112
|
+
return path.isAbsolute(sessionFilePath)
|
|
113
|
+
? path.normalize(sessionFilePath)
|
|
114
|
+
: path.resolve(appRoot, sessionFilePath);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return path.join(getDevSessionRegistryDirectory(appRoot), `${port}.json`);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const createDevSessionRecord = ({
|
|
121
|
+
appRoot,
|
|
122
|
+
port,
|
|
123
|
+
sessionFilePath,
|
|
124
|
+
}: {
|
|
125
|
+
appRoot: string;
|
|
126
|
+
port: number;
|
|
127
|
+
sessionFilePath: string;
|
|
128
|
+
}): TDevSessionRecord => {
|
|
129
|
+
const timestamp = new Date().toISOString();
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
version: devSessionRegistryVersion,
|
|
133
|
+
pid: process.pid,
|
|
134
|
+
appRoot,
|
|
135
|
+
routerPort: port,
|
|
136
|
+
publicUrl: '',
|
|
137
|
+
startedAt: timestamp,
|
|
138
|
+
updatedAt: timestamp,
|
|
139
|
+
sessionFilePath,
|
|
140
|
+
state: 'starting',
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const writeDevSessionRecord = async (record: TDevSessionRecord) => {
|
|
145
|
+
await fs.ensureDir(path.dirname(record.sessionFilePath));
|
|
146
|
+
await fs.writeJson(record.sessionFilePath, record, { spaces: 2 });
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const updateDevSessionRecord = async ({
|
|
150
|
+
sessionFilePath,
|
|
151
|
+
patch,
|
|
152
|
+
}: {
|
|
153
|
+
sessionFilePath: string;
|
|
154
|
+
patch: Partial<Omit<TDevSessionRecord, 'version' | 'pid' | 'appRoot' | 'routerPort' | 'startedAt' | 'sessionFilePath'>>;
|
|
155
|
+
}) => {
|
|
156
|
+
const inspection = await inspectDevSessionFile(sessionFilePath);
|
|
157
|
+
if (!inspection || !inspection.record) return;
|
|
158
|
+
|
|
159
|
+
await writeDevSessionRecord({
|
|
160
|
+
...inspection.record,
|
|
161
|
+
...patch,
|
|
162
|
+
updatedAt: new Date().toISOString(),
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const removeDevSessionRecord = async (sessionFilePath: string) => {
|
|
167
|
+
await fs.remove(sessionFilePath);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const removeDevSessionRecordSync = (sessionFilePath: string) => {
|
|
171
|
+
try {
|
|
172
|
+
fs.removeSync(sessionFilePath);
|
|
173
|
+
} catch {
|
|
174
|
+
// Best-effort cleanup during process exit.
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const inspectDevSessionFile = async (sessionFilePath: string): Promise<TDevSessionInspection | null> => {
|
|
179
|
+
if (!(await fs.pathExists(sessionFilePath))) return null;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const rawValue = await fs.readJson(sessionFilePath);
|
|
183
|
+
if (!isRecordShape(rawValue)) {
|
|
184
|
+
return {
|
|
185
|
+
sessionFilePath,
|
|
186
|
+
record: null,
|
|
187
|
+
live: false,
|
|
188
|
+
stale: true,
|
|
189
|
+
invalid: true,
|
|
190
|
+
parseError: 'Session file contents do not match the Proteum dev session schema.',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const record = rawValue;
|
|
195
|
+
const live = isProcessAlive(record.pid);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
sessionFilePath,
|
|
199
|
+
record,
|
|
200
|
+
live,
|
|
201
|
+
stale: !live,
|
|
202
|
+
invalid: false,
|
|
203
|
+
parseError: '',
|
|
204
|
+
};
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return {
|
|
207
|
+
sessionFilePath,
|
|
208
|
+
record: null,
|
|
209
|
+
live: false,
|
|
210
|
+
stale: true,
|
|
211
|
+
invalid: true,
|
|
212
|
+
parseError: error instanceof Error ? error.message : String(error),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export const listDevSessionFiles = async ({
|
|
218
|
+
appRoot,
|
|
219
|
+
sessionFilePath,
|
|
220
|
+
}: {
|
|
221
|
+
appRoot: string;
|
|
222
|
+
sessionFilePath?: string;
|
|
223
|
+
}) => {
|
|
224
|
+
if (sessionFilePath && sessionFilePath.trim())
|
|
225
|
+
return [resolveDevSessionFilePath({ appRoot, port: 1, sessionFilePath })];
|
|
226
|
+
|
|
227
|
+
const registryDirectory = getDevSessionRegistryDirectory(appRoot);
|
|
228
|
+
if (!(await fs.pathExists(registryDirectory))) return [];
|
|
229
|
+
|
|
230
|
+
const entries = await fs.readdir(registryDirectory);
|
|
231
|
+
|
|
232
|
+
return entries
|
|
233
|
+
.filter((entry) => entry.endsWith('.json'))
|
|
234
|
+
.sort((left, right) => left.localeCompare(right))
|
|
235
|
+
.map((entry) => path.join(registryDirectory, entry));
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const listDevSessionInspections = async ({
|
|
239
|
+
appRoot,
|
|
240
|
+
sessionFilePath,
|
|
241
|
+
}: {
|
|
242
|
+
appRoot: string;
|
|
243
|
+
sessionFilePath?: string;
|
|
244
|
+
}) => {
|
|
245
|
+
const sessionFilePaths = await listDevSessionFiles({ appRoot, sessionFilePath });
|
|
246
|
+
const inspections = await Promise.all(sessionFilePaths.map((entryPath) => inspectDevSessionFile(entryPath)));
|
|
247
|
+
|
|
248
|
+
return inspections.filter((inspection): inspection is TDevSessionInspection => inspection !== null);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export const stopDevSessionFile = async (sessionFilePath: string): Promise<TStopDevSessionResult> => {
|
|
252
|
+
const inspection = await inspectDevSessionFile(sessionFilePath);
|
|
253
|
+
|
|
254
|
+
if (!inspection) {
|
|
255
|
+
return {
|
|
256
|
+
sessionFilePath,
|
|
257
|
+
pid: null,
|
|
258
|
+
routerPort: null,
|
|
259
|
+
publicUrl: '',
|
|
260
|
+
state: '',
|
|
261
|
+
matched: false,
|
|
262
|
+
stopped: false,
|
|
263
|
+
removed: false,
|
|
264
|
+
stale: false,
|
|
265
|
+
live: false,
|
|
266
|
+
invalid: false,
|
|
267
|
+
parseError: '',
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!inspection.record) {
|
|
272
|
+
await removeDevSessionRecord(sessionFilePath);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
sessionFilePath,
|
|
276
|
+
pid: null,
|
|
277
|
+
routerPort: null,
|
|
278
|
+
publicUrl: '',
|
|
279
|
+
state: '',
|
|
280
|
+
matched: true,
|
|
281
|
+
stopped: true,
|
|
282
|
+
removed: true,
|
|
283
|
+
stale: true,
|
|
284
|
+
live: false,
|
|
285
|
+
invalid: true,
|
|
286
|
+
parseError: inspection.parseError,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const { record } = inspection;
|
|
291
|
+
|
|
292
|
+
if (!inspection.live) {
|
|
293
|
+
await removeDevSessionRecord(sessionFilePath);
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
sessionFilePath,
|
|
297
|
+
pid: record.pid,
|
|
298
|
+
routerPort: record.routerPort,
|
|
299
|
+
publicUrl: record.publicUrl,
|
|
300
|
+
state: record.state,
|
|
301
|
+
matched: true,
|
|
302
|
+
stopped: true,
|
|
303
|
+
removed: true,
|
|
304
|
+
stale: true,
|
|
305
|
+
live: false,
|
|
306
|
+
invalid: false,
|
|
307
|
+
parseError: '',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (canSignalProcess(record.pid, 'SIGTERM')) {
|
|
312
|
+
const exitedAfterTerm = await waitForProcessExit(record.pid, 5000);
|
|
313
|
+
if (!exitedAfterTerm && canSignalProcess(record.pid, 'SIGKILL')) {
|
|
314
|
+
await waitForProcessExit(record.pid, 2000);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const live = isProcessAlive(record.pid);
|
|
319
|
+
if (!live) {
|
|
320
|
+
await removeDevSessionRecord(sessionFilePath);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
sessionFilePath,
|
|
325
|
+
pid: record.pid,
|
|
326
|
+
routerPort: record.routerPort,
|
|
327
|
+
publicUrl: record.publicUrl,
|
|
328
|
+
state: record.state,
|
|
329
|
+
matched: true,
|
|
330
|
+
stopped: !live,
|
|
331
|
+
removed: !live,
|
|
332
|
+
stale: !live,
|
|
333
|
+
live,
|
|
334
|
+
invalid: false,
|
|
335
|
+
parseError: '',
|
|
336
|
+
};
|
|
337
|
+
};
|