@pixel-point/toolcraft 0.0.8 → 0.0.11
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/README.md +42 -9
- package/package.json +1 -1
- package/src/generate.mjs +42 -5
- package/src/generate.test.mjs +40 -0
- package/src/package-json.mjs +15 -0
- package/src/package-json.test.mjs +14 -1
- package/templates/runtime/contracts/component-contracts.test.ts +251 -47
- package/templates/runtime/contracts/component-contracts.ts +130 -57
- package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
- package/templates/runtime/contracts/decision-contracts.ts +22 -9
- package/templates/runtime/export/export.test.ts +63 -0
- package/templates/runtime/export/export.ts +55 -0
- package/templates/runtime/index.ts +1 -0
- package/templates/runtime/react/canvas-shell.test.tsx +58 -4
- package/templates/runtime/react/canvas-shell.tsx +31 -9
- package/templates/runtime/react/controls-panel.test.tsx +916 -108
- package/templates/runtime/react/controls-panel.tsx +136 -30
- package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
- package/templates/runtime/react/settings-transfer.test.ts +4 -0
- package/templates/runtime/react/settings-transfer.ts +6 -1
- package/templates/runtime/react/timeline-panel.test.tsx +14 -0
- package/templates/runtime/react/timeline-panel.tsx +44 -7
- package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
- package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
- package/templates/runtime/react/toolcraft-app.tsx +56 -37
- package/templates/runtime/schema/define-toolcraft.test.ts +266 -170
- package/templates/runtime/schema/define-toolcraft.ts +140 -246
- package/templates/runtime/schema/runtime-targets.ts +21 -0
- package/templates/runtime/schema/types.ts +44 -0
- package/templates/runtime/state/create-template-state.test.ts +156 -0
- package/templates/runtime/state/create-template-state.ts +38 -8
- package/templates/runtime/state/media-defaults.ts +105 -0
- package/templates/runtime/state/persistence.test.ts +58 -0
- package/templates/runtime/state/persistence.ts +105 -1
- package/templates/runtime/state/reducer.test.ts +280 -4
- package/templates/runtime/state/reducer.ts +195 -9
- package/templates/runtime/state/timeline-loop.test.ts +71 -0
- package/templates/runtime/state/timeline-loop.ts +35 -0
- package/templates/runtime/state/types.ts +27 -0
- package/templates/runtime/testing/performance.test.ts +810 -21
- package/templates/runtime/testing/performance.ts +823 -53
- package/templates/starter/AGENTS.md +24 -18
- package/templates/starter/docs/toolcraft/README.md +8 -4
- package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
- package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
- package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
- package/templates/starter/docs/toolcraft/component-rules.md +75 -42
- package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
- package/templates/starter/docs/toolcraft/performance.md +37 -11
- package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
- package/templates/starter/docs/toolcraft/workflow.md +5 -2
- package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
- package/templates/starter/e2e/app-performance.spec.ts +55 -2
- package/templates/starter/e2e/performance-helpers.ts +45 -0
- package/templates/starter/gitignore +1 -0
- package/templates/starter/index.html +1 -0
- package/templates/starter/package.json +3 -0
- package/templates/starter/playwright.config.ts +1 -1
- package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
- package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
- package/templates/starter/scripts/toolcraft-port.mjs +280 -0
- package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
- package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
- package/templates/starter/src/app/starter-acceptance.ts +1453 -97
- package/templates/starter/src/app/starter-performance.test.ts +111 -7
- package/templates/starter/src/app/starter-performance.ts +5 -0
- package/templates/starter/src/app/starter-schema.test.ts +32 -7
- package/templates/starter/src/app/starter-schema.ts +6 -2
- package/templates/starter/vite.config.ts +58 -2
- package/templates/ui/components/control-layout/index.tsx +8 -3
- package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
- package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
- package/templates/ui/components/controls/color/index.ts +4 -1
- package/templates/ui/components/controls/color/palette-control.tsx +34 -4
- package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
- package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
- package/templates/ui/components/controls/file-drop/index.ts +6 -1
- package/templates/ui/components/controls/index.ts +4 -0
- package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
- package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
- package/templates/ui/components/controls/select/select-control.tsx +8 -25
- package/templates/ui/components/controls/slider/slider-value.ts +0 -1
- package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
- package/templates/ui/components/controls/vector/index.ts +1 -0
- package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
- package/templates/ui/components/panel/panel-actions.tsx +1 -1
- package/templates/ui/components/panel/panel-section.tsx +29 -5
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"ai:check": "node scripts/check-ai-skills.mjs",
|
|
8
8
|
"dev": "node scripts/run-vite-on-free-port.mjs dev",
|
|
9
|
+
"dev:restart": "node scripts/run-vite-on-free-port.mjs dev --toolcraft-restart",
|
|
9
10
|
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
|
10
11
|
"preview": "node scripts/run-vite-on-free-port.mjs preview",
|
|
12
|
+
"preview:restart": "node scripts/run-vite-on-free-port.mjs preview --toolcraft-restart",
|
|
11
13
|
"docs:check": "node scripts/check-toolcraft-docs.mjs",
|
|
12
14
|
"test": "node scripts/check-toolcraft-docs.mjs && node scripts/check-toolcraft-integrity.mjs && node --test scripts/*.test.mjs && vitest run src --passWithNoTests",
|
|
13
15
|
"test:browser": "playwright install chromium && playwright test",
|
|
@@ -16,6 +18,7 @@
|
|
|
16
18
|
"verify:quick": "pnpm ai:check && pnpm test",
|
|
17
19
|
"verify:ui": "pnpm test:browser",
|
|
18
20
|
"verify:perf": "pnpm test:browser:perf",
|
|
21
|
+
"verify:perf:playwright": "pnpm test:browser:perf",
|
|
19
22
|
"verify:final": "pnpm ai:check && pnpm test && pnpm build && pnpm test:browser"
|
|
20
23
|
},
|
|
21
24
|
"dependencies": {
|
|
@@ -35,7 +35,7 @@ export default defineConfig({
|
|
|
35
35
|
trace: "retain-on-failure",
|
|
36
36
|
},
|
|
37
37
|
webServer: {
|
|
38
|
-
command: `pnpm exec vite dev --port ${testPort}`,
|
|
38
|
+
command: `pnpm exec vite dev --host 127.0.0.1 --port ${testPort} --strictPort`,
|
|
39
39
|
reuseExistingServer: false,
|
|
40
40
|
timeout: 60_000,
|
|
41
41
|
url: testBaseUrl,
|
|
@@ -2,47 +2,148 @@
|
|
|
2
2
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
findAvailablePort,
|
|
7
|
+
isPortAvailableOnLoopback,
|
|
8
|
+
killListeningProcessesOnPort,
|
|
9
|
+
readPreferredPort,
|
|
10
|
+
readSavedToolcraftPort,
|
|
11
|
+
waitForToolcraftAppOnPort,
|
|
12
|
+
waitForPortAvailable,
|
|
13
|
+
writeSavedToolcraftPort,
|
|
14
|
+
} from "./toolcraft-port.mjs";
|
|
6
15
|
|
|
7
16
|
const viteCommand = process.argv[2] ?? "dev";
|
|
8
|
-
const
|
|
17
|
+
const restartArg = "--toolcraft-restart";
|
|
18
|
+
const rawPassthroughArgs = process.argv.slice(3).filter((arg) => arg !== "--");
|
|
19
|
+
const restartSamePort =
|
|
20
|
+
process.env.TOOLCRAFT_RESTART === "1" || rawPassthroughArgs.includes(restartArg);
|
|
21
|
+
const passthroughArgs = rawPassthroughArgs.filter((arg) => arg !== restartArg);
|
|
9
22
|
const preferredPort = readPreferredPort([
|
|
10
23
|
"TOOLCRAFT_DEV_PORT",
|
|
11
24
|
"TOOLCRAFT_PORT",
|
|
12
25
|
"PORT",
|
|
13
26
|
]);
|
|
14
|
-
const
|
|
27
|
+
const savedPort = await readSavedToolcraftPort();
|
|
28
|
+
let port = savedPort;
|
|
15
29
|
|
|
16
|
-
if (
|
|
17
|
-
console.log(`[toolcraft]
|
|
30
|
+
if (savedPort) {
|
|
31
|
+
console.log(`[toolcraft] Using saved port ${savedPort}.`);
|
|
32
|
+
|
|
33
|
+
if (restartSamePort && !(await isPortAvailableOnLoopback(savedPort))) {
|
|
34
|
+
console.log(`[toolcraft] Port ${savedPort} is busy; stopping the existing listener first.`);
|
|
35
|
+
await killListeningProcessesOnPort(savedPort);
|
|
36
|
+
|
|
37
|
+
if (!(await waitForPortAvailable(savedPort))) {
|
|
38
|
+
console.log(`[toolcraft] Port ${savedPort} is still busy; forcing listener shutdown.`);
|
|
39
|
+
await killListeningProcessesOnPort(savedPort, { signal: "SIGKILL" });
|
|
40
|
+
|
|
41
|
+
if (!(await waitForPortAvailable(savedPort, { timeoutMs: 1_000 }))) {
|
|
42
|
+
throw new Error(`Port ${savedPort} is still busy after forced restart cleanup.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} else if (!restartSamePort && !(await isPortAvailableOnLoopback(savedPort))) {
|
|
46
|
+
try {
|
|
47
|
+
const existingApp = await waitForToolcraftAppOnPort(savedPort, { timeoutMs: 1_200 });
|
|
48
|
+
console.log(`[toolcraft] This app is already running on ${existingApp.url}`);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(
|
|
52
|
+
`Saved Toolcraft port ${savedPort} is busy, but it is not serving this app. Use pnpm dev:restart or pnpm preview:restart to reclaim the saved port, or remove .toolcraft/server-port.json to assign a new port. ${error?.message ?? ""}`.trim(),
|
|
53
|
+
);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} else if (restartSamePort) {
|
|
58
|
+
console.log("[toolcraft] Restart mode requested, but no saved port exists yet.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!port) {
|
|
62
|
+
port = await findAvailablePort(preferredPort);
|
|
63
|
+
|
|
64
|
+
if (port !== preferredPort) {
|
|
65
|
+
console.log(`[toolcraft] Port ${preferredPort} is busy; using ${port} instead.`);
|
|
66
|
+
}
|
|
18
67
|
}
|
|
19
68
|
|
|
20
69
|
const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
|
21
|
-
const child = spawn(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
70
|
+
const child = spawn(
|
|
71
|
+
pnpmCommand,
|
|
72
|
+
[
|
|
73
|
+
"exec",
|
|
74
|
+
"vite",
|
|
75
|
+
viteCommand,
|
|
76
|
+
"--host",
|
|
77
|
+
"127.0.0.1",
|
|
78
|
+
"--port",
|
|
79
|
+
String(port),
|
|
80
|
+
"--strictPort",
|
|
81
|
+
...passthroughArgs,
|
|
82
|
+
],
|
|
83
|
+
{
|
|
84
|
+
env: {
|
|
85
|
+
...process.env,
|
|
86
|
+
TOOLCRAFT_ACTIVE_PORT: String(port),
|
|
87
|
+
PORT: String(port),
|
|
88
|
+
},
|
|
89
|
+
stdio: "inherit",
|
|
26
90
|
},
|
|
27
|
-
|
|
28
|
-
});
|
|
91
|
+
);
|
|
29
92
|
|
|
30
93
|
const signalExitCodes = {
|
|
31
94
|
SIGINT: 130,
|
|
32
95
|
SIGTERM: 143,
|
|
33
96
|
};
|
|
34
97
|
|
|
98
|
+
let childExit = null;
|
|
99
|
+
const childExitPromise = new Promise((resolve) => {
|
|
100
|
+
child.on("exit", (code, signal) => {
|
|
101
|
+
childExit = { code, signal };
|
|
102
|
+
resolve(childExit);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
35
106
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
36
107
|
process.on(signal, () => {
|
|
37
108
|
child.kill(signal);
|
|
38
109
|
});
|
|
39
110
|
}
|
|
40
111
|
|
|
41
|
-
|
|
112
|
+
function exitFromChild(code, signal) {
|
|
42
113
|
if (signal) {
|
|
43
114
|
process.exit(signalExitCodes[signal] ?? 1);
|
|
44
115
|
return;
|
|
45
116
|
}
|
|
46
117
|
|
|
47
118
|
process.exit(code ?? 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const startupResult = await Promise.race([
|
|
123
|
+
waitForToolcraftAppOnPort(port),
|
|
124
|
+
childExitPromise.then(() => "exited"),
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
if (startupResult === "exited") {
|
|
128
|
+
throw new Error(`Vite exited before serving this Toolcraft app on port ${port}.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await writeSavedToolcraftPort(port);
|
|
132
|
+
console.log(`[toolcraft] Verified this app on ${startupResult.url}`);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error(`[toolcraft] ${error?.message ?? error}`);
|
|
135
|
+
|
|
136
|
+
if (!childExit) {
|
|
137
|
+
child.kill("SIGTERM");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (childExit) {
|
|
144
|
+
exitFromChild(childExit.code, childExit.signal);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
childExitPromise.then(({ code, signal }) => {
|
|
148
|
+
exitFromChild(code, signal);
|
|
48
149
|
});
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
1
3
|
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
2
6
|
|
|
3
7
|
export const DEFAULT_TOOLCRAFT_PORT = 3002;
|
|
8
|
+
export const TOOLCRAFT_APP_TITLE_META_NAME = "toolcraft-app-title";
|
|
9
|
+
export const TOOLCRAFT_SERVER_IDENTITY_PATH = "/.toolcraft/server-identity.json";
|
|
4
10
|
const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const PORT_STATE_DIR = ".toolcraft";
|
|
13
|
+
const PORT_STATE_FILE = "server-port.json";
|
|
5
14
|
|
|
6
15
|
export function readPreferredPort(names, fallback = DEFAULT_TOOLCRAFT_PORT, env = process.env) {
|
|
7
16
|
for (const name of names) {
|
|
@@ -52,3 +61,274 @@ export async function findAvailablePort(startPort = DEFAULT_TOOLCRAFT_PORT) {
|
|
|
52
61
|
|
|
53
62
|
throw new Error(`No free port found at or above ${startPort}.`);
|
|
54
63
|
}
|
|
64
|
+
|
|
65
|
+
function isValidPort(port) {
|
|
66
|
+
return Number.isInteger(port) && port > 0 && port <= 65_535;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getToolcraftPortStatePath(cwd = process.cwd()) {
|
|
70
|
+
return path.join(cwd, PORT_STATE_DIR, PORT_STATE_FILE);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function readSavedToolcraftPort(cwd = process.cwd()) {
|
|
74
|
+
try {
|
|
75
|
+
const source = await fs.readFile(getToolcraftPortStatePath(cwd), "utf8");
|
|
76
|
+
const state = JSON.parse(source);
|
|
77
|
+
|
|
78
|
+
return isValidPort(state.port) ? state.port : null;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error?.code === "ENOENT" || error instanceof SyntaxError) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function writeSavedToolcraftPort(port, cwd = process.cwd()) {
|
|
89
|
+
if (!isValidPort(port)) {
|
|
90
|
+
throw new Error(`Cannot save invalid Toolcraft port: ${port}.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const statePath = getToolcraftPortStatePath(cwd);
|
|
94
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
95
|
+
await fs.writeFile(
|
|
96
|
+
statePath,
|
|
97
|
+
`${JSON.stringify({ port, updatedAt: new Date().toISOString() }, null, 2)}\n`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function getListeningProcessIds(port, execFileImpl = execFileAsync) {
|
|
102
|
+
if (!isValidPort(port)) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const { stdout } =
|
|
108
|
+
process.platform === "win32"
|
|
109
|
+
? await execFileImpl("powershell", [
|
|
110
|
+
"-NoProfile",
|
|
111
|
+
"-Command",
|
|
112
|
+
`Get-NetTCPConnection -LocalPort ${port} -State Listen | Select-Object -ExpandProperty OwningProcess -Unique`,
|
|
113
|
+
])
|
|
114
|
+
: await execFileImpl("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
|
|
115
|
+
|
|
116
|
+
return [...new Set(String(stdout).split(/\s+/).map(Number).filter(Number.isInteger))].filter(
|
|
117
|
+
(pid) => pid > 0 && pid !== process.pid,
|
|
118
|
+
);
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function killListeningProcessesOnPort(port, options = {}) {
|
|
125
|
+
const {
|
|
126
|
+
execFileImpl = execFileAsync,
|
|
127
|
+
killProcess = process.kill,
|
|
128
|
+
signal = "SIGTERM",
|
|
129
|
+
} = options;
|
|
130
|
+
const pids = await getListeningProcessIds(port, execFileImpl);
|
|
131
|
+
|
|
132
|
+
for (const pid of pids) {
|
|
133
|
+
try {
|
|
134
|
+
killProcess(pid, signal);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error?.code !== "ESRCH") {
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return pids;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function waitForPortAvailable(port, options = {}) {
|
|
146
|
+
const { intervalMs = 100, timeoutMs = 3_000 } = options;
|
|
147
|
+
const deadline = Date.now() + timeoutMs;
|
|
148
|
+
|
|
149
|
+
while (Date.now() <= deadline) {
|
|
150
|
+
if (await isPortAvailableOnLoopback(port)) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function extractAttribute(tag, name) {
|
|
161
|
+
const pattern = new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, "i");
|
|
162
|
+
return tag.match(pattern)?.[1] ?? null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function readToolcraftAppIdentityFromHtml(source) {
|
|
166
|
+
const html = String(source ?? "");
|
|
167
|
+
const title = html.match(/<title>(.*?)<\/title>/is)?.[1]?.trim() ?? null;
|
|
168
|
+
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
|
|
169
|
+
const appTitleMeta = metaTags.find(
|
|
170
|
+
(tag) => extractAttribute(tag, "name")?.toLowerCase() === TOOLCRAFT_APP_TITLE_META_NAME,
|
|
171
|
+
);
|
|
172
|
+
const appTitle = appTitleMeta ? extractAttribute(appTitleMeta, "content") : null;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
appTitle,
|
|
176
|
+
title,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function getRealPath(filePath) {
|
|
181
|
+
try {
|
|
182
|
+
return await fs.realpath(filePath);
|
|
183
|
+
} catch {
|
|
184
|
+
return path.resolve(filePath);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function readExpectedToolcraftAppIdentity(cwd = process.cwd()) {
|
|
189
|
+
const indexPath = path.join(cwd, "index.html");
|
|
190
|
+
const source = await fs.readFile(indexPath, "utf8");
|
|
191
|
+
const identity = readToolcraftAppIdentityFromHtml(source);
|
|
192
|
+
const expectedLabel = identity.appTitle ?? identity.title;
|
|
193
|
+
|
|
194
|
+
if (!expectedLabel) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Cannot verify the Toolcraft server for ${cwd}: index.html has no ${TOOLCRAFT_APP_TITLE_META_NAME} meta tag or title.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
...identity,
|
|
202
|
+
root: await getRealPath(cwd),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function toolcraftAppIdentityMatches(actual, expected) {
|
|
207
|
+
if (actual.root && expected.root) {
|
|
208
|
+
return actual.root === expected.root;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (expected.appTitle) {
|
|
212
|
+
return actual.appTitle === expected.appTitle;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return actual.title === expected.title;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function getToolcraftIdentityLabel(identity) {
|
|
219
|
+
return identity.appTitle ?? identity.title ?? "unknown app";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function getToolcraftIdentityDescription(identity) {
|
|
223
|
+
const label = getToolcraftIdentityLabel(identity);
|
|
224
|
+
|
|
225
|
+
return identity.root ? `${label} at ${identity.root}` : label;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function fetchTextWithTimeout(url, options = {}) {
|
|
229
|
+
const { fetchImpl = globalThis.fetch, timeoutMs = 1_000 } = options;
|
|
230
|
+
|
|
231
|
+
if (typeof fetchImpl !== "function") {
|
|
232
|
+
throw new Error("Cannot verify Toolcraft server identity because fetch is unavailable.");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const response = await fetchImpl(url, {
|
|
240
|
+
cache: "no-store",
|
|
241
|
+
signal: controller.signal,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
throw new Error(`HTTP ${response.status}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return await response.text();
|
|
249
|
+
} finally {
|
|
250
|
+
clearTimeout(timeout);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function fetchToolcraftServerIdentity(baseUrl, options = {}) {
|
|
255
|
+
const source = await fetchTextWithTimeout(
|
|
256
|
+
`${baseUrl}${TOOLCRAFT_SERVER_IDENTITY_PATH}?toolcraft-port-check=${Date.now()}`,
|
|
257
|
+
options,
|
|
258
|
+
);
|
|
259
|
+
const parsed = JSON.parse(source);
|
|
260
|
+
|
|
261
|
+
if (!parsed || typeof parsed !== "object") {
|
|
262
|
+
throw new Error("Toolcraft server identity endpoint returned invalid JSON.");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (typeof parsed.root !== "string" || parsed.root.trim() === "") {
|
|
266
|
+
throw new Error("Toolcraft server identity endpoint did not return a project root.");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
appTitle: typeof parsed.appTitle === "string" ? parsed.appTitle : null,
|
|
271
|
+
root: await getRealPath(parsed.root),
|
|
272
|
+
title: null,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function createHttpBaseUrl(host, port) {
|
|
277
|
+
const formattedHost = host.includes(":") ? `[${host}]` : host;
|
|
278
|
+
|
|
279
|
+
return `http://${formattedHost}:${port}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function waitForToolcraftAppOnPort(port, options = {}) {
|
|
283
|
+
const {
|
|
284
|
+
cwd = process.cwd(),
|
|
285
|
+
fetchImpl = globalThis.fetch,
|
|
286
|
+
host = "127.0.0.1",
|
|
287
|
+
hosts = [host, "::1"],
|
|
288
|
+
intervalMs = 150,
|
|
289
|
+
timeoutMs = 10_000,
|
|
290
|
+
} = options;
|
|
291
|
+
const expected = await readExpectedToolcraftAppIdentity(cwd);
|
|
292
|
+
const expectedDescription = getToolcraftIdentityDescription(expected);
|
|
293
|
+
const deadline = Date.now() + timeoutMs;
|
|
294
|
+
const candidateHosts = [...new Set(hosts)];
|
|
295
|
+
let lastError = null;
|
|
296
|
+
|
|
297
|
+
while (Date.now() <= deadline) {
|
|
298
|
+
for (const candidateHost of candidateHosts) {
|
|
299
|
+
const baseUrl = createHttpBaseUrl(candidateHost, port);
|
|
300
|
+
const url = `${baseUrl}/`;
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const actual = await fetchToolcraftServerIdentity(baseUrl, { fetchImpl });
|
|
304
|
+
|
|
305
|
+
if (!toolcraftAppIdentityMatches(actual, expected)) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Port ${port} is serving ${getToolcraftIdentityDescription(
|
|
308
|
+
actual,
|
|
309
|
+
)} instead of this app ${expectedDescription}.`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
expected,
|
|
315
|
+
url,
|
|
316
|
+
};
|
|
317
|
+
} catch (error) {
|
|
318
|
+
lastError = error;
|
|
319
|
+
|
|
320
|
+
if (/is serving/.test(String(error?.message))) {
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
throw new Error(
|
|
330
|
+
`Timed out waiting for this Toolcraft app ${expectedDescription} on port ${port}. Last error: ${
|
|
331
|
+
lastError?.message ?? "none"
|
|
332
|
+
}`,
|
|
333
|
+
);
|
|
334
|
+
}
|