catalyst-core 1.0.2-canary.1 → 1.0.2
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/bin/catalyst.js +105 -1
- package/changelog.md +34 -0
- package/dist/cli/README.md +119 -0
- package/dist/cli/appConfig.js +51 -0
- package/dist/cli/buildErrors.js +170 -0
- package/dist/cli/buildWarnings.js +28 -0
- package/dist/cli/devOutput.js +524 -0
- package/dist/cli/diagnostic.js +388 -0
- package/dist/cli/hints.js +69 -0
- package/dist/cli/package.json +3 -0
- package/dist/cli/spinner.js +141 -0
- package/dist/cli/theme.js +155 -0
- package/dist/errors/index.js +7 -3
- package/dist/logger.js +61 -6
- package/dist/native/androidProject/app/build.gradle.kts +2 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
- package/dist/native/androidSetup.js +46 -57
- package/dist/native/buildAndroid/assets.js +22 -13
- package/dist/native/buildAndroid/build.js +98 -26
- package/dist/native/buildAndroid/config.js +43 -12
- package/dist/native/buildAndroid/index.js +49 -191
- package/dist/native/buildAppAndroid.js +19 -8
- package/dist/native/buildAppIos.js +35 -16
- package/dist/native/buildIos/assets.js +1 -6
- package/dist/native/buildIos/build.js +66 -95
- package/dist/native/buildIos/config.js +26 -8
- package/dist/native/buildIos/index.js +143 -64
- package/dist/native/buildIos/plugins.js +12 -15
- package/dist/native/internalPluginUtils.js +0 -1
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
- package/dist/native/pluginComposerAndroid.js +2 -3
- package/dist/native/renameAndroidProject.js +42 -20
- package/dist/native/setupEmulatorIos.js +28 -49
- package/dist/native/setupServer.js +12 -15
- package/dist/native/terminalProgress.js +186 -147
- package/dist/native/utils.js +340 -54
- package/dist/otel.js +15 -9
- package/dist/scripts/build.js +313 -42
- package/dist/scripts/generateOfflineManifest.js +11 -1
- package/dist/scripts/loadEnvironmentVariables.js +24 -2
- package/dist/scripts/preServerInit.js +32 -10
- package/dist/scripts/preflight.js +35 -11
- package/dist/scripts/scriptUtils.js +34 -0
- package/dist/scripts/serve.js +64 -15
- package/dist/scripts/start.js +58 -21
- package/dist/server/expressServer.js +167 -32
- package/dist/server/renderer/handler.jsx +87 -11
- package/dist/server/utils/validator.js +14 -11
- package/dist/vite/manifest-categorization-plugin.js +7 -5
- package/dist/vite/resolveDevServerConfig.js +9 -2
- package/dist/vite/vite.config.client.js +18 -0
- package/dist/vite/vite.config.js +73 -13
- package/dist/vite/vite.config.server.js +21 -0
- package/dist/web-router/components/MetaTag.jsx +15 -2
- package/dist/web-router/components/MetaTag.test.jsx +71 -2
- package/dist/webmcp/WebMcpProvider.jsx +3 -2
- package/dist/webmcp/useTool.js +4 -1
- package/mcp_v2/conversion-tasks.json +6 -5
- package/mcp_v2/knowledge-base.json +124 -7
- package/mcp_v2/test/config.test.ts +59 -0
- package/mcp_v2/tools/build.js +6 -6
- package/mcp_v2/tools/config.js +35 -18
- package/mcp_v2/tools/conversion.js +7 -13
- package/package.json +5 -5
- package/test/cli/buildErrors.test.ts +173 -0
- package/test/cli/buildWarnings.test.cjs +62 -0
- package/test/cli/devOutput.test.ts +549 -0
- package/test/cli/diagnostic.test.ts +434 -0
- package/test/cli/output.test.cjs +90 -0
- package/test/cli/spinner.test.ts +177 -0
- package/test/cli/theme.test.ts +163 -0
- package/test/native/redactSecrets.test.ts +72 -0
- package/test/native/utils.test.ts +532 -0
- package/test/scripts/build.test.ts +275 -0
- package/test/scripts/serve.test.ts +102 -0
- package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
- package/test/server/handler.test.ts +17 -0
- package/test/server/logger.test.ts +185 -0
- package/test/server/otel.test.ts +556 -0
- package/test/server/validator.test.ts +19 -8
- package/dist/scripts/checkVersion.js +0 -3
- package/dist/scripts/cleanCache.js +0 -30
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const { GUTTER, glyph, t, ANSI, isVerbose } = require("./theme.js")
|
|
4
|
+
|
|
5
|
+
// Fences the dev server writes around its banner block, so the wrapper can
|
|
6
|
+
// forward those lines verbatim rather than guessing from their text. Colour
|
|
7
|
+
// codes made text-matching unreliable. Chosen to be inert if they ever reach
|
|
8
|
+
// a terminal unstripped.
|
|
9
|
+
const BANNER_START = "\u0000catalyst:banner:start"
|
|
10
|
+
const BANNER_END = "\u0000catalyst:banner:end"
|
|
11
|
+
// The server knows which command comes next, but not whether notices will
|
|
12
|
+
// land under the banner. It hands the line over fenced and the wrapper prints
|
|
13
|
+
// it last, so the one actionable line is never buried under advisories.
|
|
14
|
+
const NEXT_STEP = "\u0000catalyst:next:"
|
|
15
|
+
|
|
16
|
+
// How long an error-like startup line may wait for the banner before it is
|
|
17
|
+
// printed anyway.
|
|
18
|
+
const EARLY_FLUSH_MS = 500
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Shape the dev server's own output so `catalyst start` reads as one thing.
|
|
22
|
+
*
|
|
23
|
+
* The server is a child process that prints in three unrelated voices: Node's
|
|
24
|
+
* multi-line process warnings, Vite's timestamped notices, and our own banner.
|
|
25
|
+
* Left alone they interleave and the banner reads as an afterthought. This
|
|
26
|
+
* classifies each line so the command can present them consistently.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Node process warnings arrive as a block: a `(node:123) [CODE] Warning: ...`
|
|
31
|
+
* line, then continuation lines, then a `(Use \`node --trace-warnings ...\`)`
|
|
32
|
+
* footer. They are advisory and almost never actionable mid-session, so the
|
|
33
|
+
* block collapses to a single line.
|
|
34
|
+
*/
|
|
35
|
+
const WARNING_START = /^\(node:\d+\)\s*(?:\[([A-Z_]+)\]\s*)?Warning:\s*(.*)$/
|
|
36
|
+
const WARNING_FOOTER = /^\(Use `node --trace-warnings/
|
|
37
|
+
|
|
38
|
+
// Vite prefixes its notices with a wall-clock time and a channel, e.g.
|
|
39
|
+
// "8:00:53 pm [vite] (client) Re-optimizing dependencies...". The timestamp is
|
|
40
|
+
// noise in a session that just started; the message is worth keeping.
|
|
41
|
+
const VITE_NOTICE = /^\d{1,2}:\d{2}:\d{2}\s*(?:am|pm)?\s*\[vite\]\s*(?:\(([^)]+)\)\s*)?(.*)$/i
|
|
42
|
+
|
|
43
|
+
// The inspector URL is worth keeping -- people copy it into a debugger -- but
|
|
44
|
+
// the "For help, see:" line that follows it is boilerplate.
|
|
45
|
+
const INSPECTOR_URL = /^Debugger listening on (ws:\/\/\S+)$/
|
|
46
|
+
|
|
47
|
+
// Vite bookkeeping that is true but not news: the dependency pre-bundler
|
|
48
|
+
// re-running, and catalyst's optimizeDeps list naming optional packages this
|
|
49
|
+
// app does not use. --verbose keeps them.
|
|
50
|
+
const VERBOSE_ONLY = [
|
|
51
|
+
/^Re-optimizing dependencies because/i,
|
|
52
|
+
/^new dependencies optimized:/i,
|
|
53
|
+
/^optimized dependencies changed\. reloading/i,
|
|
54
|
+
/^Forced re-optimization of dependencies/i,
|
|
55
|
+
/^Failed to resolve dependency: .+ present in .*'optimizeDeps\.include'/i,
|
|
56
|
+
/^\[catalyst-core\/ai\] catalyst-ai not installed/,
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
// Vite's reload notices, reworded: what changed is the news, not the channel.
|
|
60
|
+
const VITE_RELOAD = /^(page reload|hmr update)\s+(.+?)(\s+\(x\d+\))?$/i
|
|
61
|
+
|
|
62
|
+
// React Router's warning for an unknown URL, shortened to what it means.
|
|
63
|
+
const NO_ROUTE = /^No routes matched location "([^"]*)"/
|
|
64
|
+
|
|
65
|
+
// Two dev servers cannot share Vite's HMR socket; the second one still serves
|
|
66
|
+
// pages but never live-reloads, which is worth one clear line.
|
|
67
|
+
const HMR_PORT_TAKEN = /^WebSocket server error: Port (\d+) is already in use/
|
|
68
|
+
|
|
69
|
+
// Lines the user can do nothing about and that say nothing about their app.
|
|
70
|
+
const DROP = [
|
|
71
|
+
/^\(Use `node --trace-warnings/,
|
|
72
|
+
/^Reparsing as ES module because module syntax was detected/,
|
|
73
|
+
/^To eliminate this warning, add "type": "module"/,
|
|
74
|
+
/^For help, see: https:\/\/nodejs\.org\/en\/docs\/inspector/,
|
|
75
|
+
/^Debugger attached\.$/,
|
|
76
|
+
/^Waiting for the debugger to disconnect/,
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
// A crashing server dumps a raw stack: the Error line, indented `at ...`
|
|
80
|
+
// frames, and an object literal of errno/syscall/address fields. The first
|
|
81
|
+
// line is the diagnosis; the rest is noise unless someone is debugging Node
|
|
82
|
+
// itself.
|
|
83
|
+
const STACK_FRAME = /^\s+at\s/
|
|
84
|
+
const ERROR_LINE = /^(?:Uncaught\s+)?(?:[A-Z]\w*)?Error:\s*(.+)$/
|
|
85
|
+
const ERROR_DETAIL_FIELD = /^\s*(code|errno|syscall|address|port|stack):\s/
|
|
86
|
+
const ERROR_OBJECT_EDGE = /^\s*[{}]\s*\w*$/
|
|
87
|
+
const CAUGHT_EXCEPTION = /^(?:Caught exception|Exception origin)/
|
|
88
|
+
const UNHANDLED_REJECTION = /^unhandledRejection in Catalyst\s*(.*)$/
|
|
89
|
+
|
|
90
|
+
// The house-style diagnostic the server itself renders: " error <scope>",
|
|
91
|
+
// then indented detail. Recognised so it is forwarded verbatim.
|
|
92
|
+
const DIAGNOSTIC_LINE = /^\s{2}(error|hint|docs|log|at)\b/
|
|
93
|
+
|
|
94
|
+
// Colour codes sit in front of the text, so every pattern below must match
|
|
95
|
+
// against the uncoloured form or it silently stops matching the moment the
|
|
96
|
+
// child starts emitting colour.
|
|
97
|
+
const uncolour = (line) => line.replace(ANSI, "")
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Turn one raw line from the dev server into what should be shown.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} line
|
|
103
|
+
* @param {{seenWarnings: Set<string>, lastError?: string}} state
|
|
104
|
+
* @returns {string|null} the line to print, or null to swallow it
|
|
105
|
+
*/
|
|
106
|
+
function formatDevLine(line, state) {
|
|
107
|
+
const plain = uncolour(line)
|
|
108
|
+
const trimmed = plain.trimEnd()
|
|
109
|
+
const verbose = state.verbose ?? isVerbose()
|
|
110
|
+
const unprefixed = VITE_NOTICE.exec(trimmed)?.[2] ?? trimmed
|
|
111
|
+
|
|
112
|
+
// Vite logs a failed SSR module evaluation itself -- message, plugin,
|
|
113
|
+
// file, frame and its own stack -- and then the request handler reports
|
|
114
|
+
// the same error with the app's file and source. Keep the one report.
|
|
115
|
+
// Checked first: Vite indents the block, which would otherwise pass as
|
|
116
|
+
// part of an earlier diagnostic.
|
|
117
|
+
if (!verbose && /^Error when evaluating SSR module/.test(unprefixed.trim())) {
|
|
118
|
+
state.inViteError = true
|
|
119
|
+
state.viteErrorLines = 0
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
if (state.inViteError) {
|
|
123
|
+
// Ends at our own report, at Vite's next notice, or -- so a report that
|
|
124
|
+
// never arrives cannot swallow the session -- after a bounded run.
|
|
125
|
+
state.viteErrorLines = (state.viteErrorLines || 0) + 1
|
|
126
|
+
const ends = DIAGNOSTIC_LINE.test(plain) || VITE_NOTICE.test(trimmed) || state.viteErrorLines > 60
|
|
127
|
+
if (!ends) return null
|
|
128
|
+
state.inViteError = false
|
|
129
|
+
// The blank line that opens our report was swallowed with Vite's block.
|
|
130
|
+
state.pendingBlank = true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Blank lines are layout inside a diagnostic block; elsewhere they are
|
|
134
|
+
// just gaps between unrelated chatter -- except the one that opens a
|
|
135
|
+
// diagnostic, which is remembered and kept if a block follows.
|
|
136
|
+
if (!trimmed.trim()) {
|
|
137
|
+
if (state.sawDiagnostic) return ""
|
|
138
|
+
state.pendingBlank = true
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
const openedWithBlank = state.pendingBlank
|
|
142
|
+
state.pendingBlank = false
|
|
143
|
+
|
|
144
|
+
// Lines the server already rendered as a diagnostic are in the house
|
|
145
|
+
// style: pass them through untouched rather than re-parsing them as raw
|
|
146
|
+
// error output and swallowing them.
|
|
147
|
+
if (DIAGNOSTIC_LINE.test(plain)) {
|
|
148
|
+
state.sawDiagnostic = true
|
|
149
|
+
return openedWithBlank ? `\n${line}` : line
|
|
150
|
+
}
|
|
151
|
+
if (state.sawDiagnostic && /^\s{2,}/.test(plain)) return line
|
|
152
|
+
|
|
153
|
+
if (DROP.some((pattern) => pattern.test(trimmed))) return null
|
|
154
|
+
if (WARNING_FOOTER.test(trimmed)) return null
|
|
155
|
+
|
|
156
|
+
if (!verbose && VERBOSE_ONLY.some((pattern) => pattern.test(unprefixed.trim()))) return null
|
|
157
|
+
|
|
158
|
+
const reload = VITE_RELOAD.exec(unprefixed.trim())
|
|
159
|
+
if (reload) {
|
|
160
|
+
// The ssr and client channels both report the same file; one line is enough.
|
|
161
|
+
const key = `${reload[2]}@${Math.floor(Date.now() / 500)}`
|
|
162
|
+
if (state.lastReload === key) return null
|
|
163
|
+
state.lastReload = key
|
|
164
|
+
return `${GUTTER}${t.dim("↻")} ${t.dim(reload[2])}${t.dim(reload[3] || "")}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const noRoute = NO_ROUTE.exec(trimmed)
|
|
168
|
+
if (noRoute) return `${GUTTER}${t.warn(glyph.warn)} ${t.dim(`No route matches ${noRoute[1]}`)}`
|
|
169
|
+
|
|
170
|
+
const hmrTaken = HMR_PORT_TAKEN.exec(trimmed)
|
|
171
|
+
if (hmrTaken) {
|
|
172
|
+
return `${GUTTER}${t.warn(glyph.warn)} ${t.dim(`Live reload is off: port ${hmrTaken[1]} is used by another dev server`)}`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Swallow the machinery around a crash; the command reports it once, as a
|
|
176
|
+
// diagnostic, using the message captured below. After the banner there is
|
|
177
|
+
// no such report -- the process keeps running -- so the frames are the only
|
|
178
|
+
// thing saying *where* a live crash happened. Before it, --verbose keeps
|
|
179
|
+
// them: a non-fatal startup error has no other way to say where it came from.
|
|
180
|
+
if (STACK_FRAME.test(trimmed)) {
|
|
181
|
+
return state.flushed || verbose ? `${GUTTER}${t.dim(trimmed.trim())}` : null
|
|
182
|
+
}
|
|
183
|
+
if (ERROR_DETAIL_FIELD.test(trimmed)) return null
|
|
184
|
+
if (ERROR_OBJECT_EDGE.test(trimmed)) return null
|
|
185
|
+
if (CAUGHT_EXCEPTION.test(trimmed)) return null
|
|
186
|
+
|
|
187
|
+
// The server logs this once and stays alive, so there is no exit handler
|
|
188
|
+
// to report it later -- swallowing it lost the failure entirely.
|
|
189
|
+
const rejection = UNHANDLED_REJECTION.exec(trimmed)
|
|
190
|
+
if (rejection) {
|
|
191
|
+
const detail = rejection[1] && rejection[1] !== "{}" ? ` ${rejection[1]}` : ""
|
|
192
|
+
state.lastError = `Unhandled promise rejection${detail}`
|
|
193
|
+
// Same rule as a raw Error line below.
|
|
194
|
+
state.lastErrorLine = `${GUTTER}${t.bad(glyph.fail)} Unhandled promise rejection${t.dim(detail)}`
|
|
195
|
+
return state.lastErrorLine
|
|
196
|
+
}
|
|
197
|
+
// A bare number is what `console.log(process.stderr.fd)` in the server's
|
|
198
|
+
// uncaughtException handler emits.
|
|
199
|
+
if (/^\d+$/.test(trimmed)) return null
|
|
200
|
+
|
|
201
|
+
const error = ERROR_LINE.exec(trimmed)
|
|
202
|
+
if (error) {
|
|
203
|
+
state.lastError = error[1]
|
|
204
|
+
// Always shown: a `console.error(err)` the server survives -- a cache or
|
|
205
|
+
// API down at startup -- has no other report. When the error instead
|
|
206
|
+
// ends a failed start, the exit handler renders it as the diagnostic and
|
|
207
|
+
// drops this line (see lastErrorLine) so it is not said twice.
|
|
208
|
+
state.lastErrorLine = `${GUTTER}${t.bad(glyph.fail)} ${error[1]}`
|
|
209
|
+
return state.lastErrorLine
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const inspector = INSPECTOR_URL.exec(trimmed)
|
|
213
|
+
if (inspector) {
|
|
214
|
+
return `${GUTTER}${t.dim("Inspect".padEnd(9))}${t.accent(inspector[1])}`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const warning = WARNING_START.exec(trimmed)
|
|
218
|
+
if (warning) {
|
|
219
|
+
const [, code, message] = warning
|
|
220
|
+
// Node reparsing an app file as ESM costs a few ms at startup and
|
|
221
|
+
// nothing else; it is not worth a line on every start.
|
|
222
|
+
if (code === "MODULE_TYPELESS_PACKAGE_JSON" && !verbose) return null
|
|
223
|
+
// The same warning often fires once per worker; show it once.
|
|
224
|
+
const key = code || message
|
|
225
|
+
if (state.seenWarnings.has(key)) return null
|
|
226
|
+
state.seenWarnings.add(key)
|
|
227
|
+
|
|
228
|
+
return `${GUTTER}${t.warn(glyph.warn)} ${t.dim(summariseNodeWarning(code, message))}`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const notice = VITE_NOTICE.exec(trimmed)
|
|
232
|
+
if (notice) {
|
|
233
|
+
const [, channel, message] = notice
|
|
234
|
+
// The channel (client/ssr) is the useful half of the prefix -- it says
|
|
235
|
+
// which bundle is being rebuilt -- so keep it, dimmed, as a label.
|
|
236
|
+
const scope = channel ? `${t.dim(`${channel} `)}` : ""
|
|
237
|
+
return `${GUTTER}${t.dim(glyph.info)} ${scope}${t.dim(message)}`
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return `${GUTTER}${t.dim(glyph.info)} ${t.dim(trimmed)}`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* One actionable sentence for the process warnings we recognise.
|
|
245
|
+
* Anything unrecognised keeps Node's own wording.
|
|
246
|
+
*/
|
|
247
|
+
function summariseNodeWarning(code, message) {
|
|
248
|
+
if (code === "MODULE_TYPELESS_PACKAGE_JSON") {
|
|
249
|
+
return `Add "type": "module" to package.json to silence a Node reparse warning`
|
|
250
|
+
}
|
|
251
|
+
return message
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A line-splitting wrapper: feeds whole lines to `onLine`, holding back a
|
|
256
|
+
* trailing partial until its newline arrives.
|
|
257
|
+
*/
|
|
258
|
+
function createLineReader(onLine) {
|
|
259
|
+
let partial = ""
|
|
260
|
+
return {
|
|
261
|
+
push(chunk) {
|
|
262
|
+
const lines = (partial + chunk.toString()).split("\n")
|
|
263
|
+
partial = lines.pop()
|
|
264
|
+
for (const line of lines) onLine(line)
|
|
265
|
+
},
|
|
266
|
+
flush() {
|
|
267
|
+
if (partial) {
|
|
268
|
+
onLine(partial)
|
|
269
|
+
partial = ""
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Wire a spawned dev/production server's output into the house style.
|
|
277
|
+
*
|
|
278
|
+
* Shared by `start` and `serve` rather than copied: they present the same
|
|
279
|
+
* screen and both need the banner fences consumed, the startup chatter held
|
|
280
|
+
* back until the banner has landed, and Node's multi-line warnings collapsed.
|
|
281
|
+
*
|
|
282
|
+
* @param {import('child_process').ChildProcess} child
|
|
283
|
+
* @returns {{state: object, flushRemaining: (options?: {dropLastError?: boolean}) => void}}
|
|
284
|
+
*/
|
|
285
|
+
function wireServerOutput(child) {
|
|
286
|
+
const state = { seenWarnings: new Set() }
|
|
287
|
+
|
|
288
|
+
// Startup chatter is held back and replayed under the banner. The URL is
|
|
289
|
+
// what someone is waiting for, so it should not be pushed down the screen
|
|
290
|
+
// by notices about lockfiles and package.json fields.
|
|
291
|
+
const pending = []
|
|
292
|
+
let flushed = false
|
|
293
|
+
let flushScheduled = false
|
|
294
|
+
let inBanner = false
|
|
295
|
+
|
|
296
|
+
// Held back until after the notices so the actionable line lands last.
|
|
297
|
+
let nextStep = null
|
|
298
|
+
|
|
299
|
+
// A startup that fails without exiting -- listen() erroring while Vite
|
|
300
|
+
// keeps the process alive -- never reaches the banner, so its diagnostic
|
|
301
|
+
// would sit in `pending` behind a blank screen forever. Once something
|
|
302
|
+
// error-like is queued, print the queue shortly after, without waiting.
|
|
303
|
+
let earlyTimer = null
|
|
304
|
+
const printEarly = () => {
|
|
305
|
+
earlyTimer = null
|
|
306
|
+
if (flushed) return
|
|
307
|
+
for (const line of pending) console.log(line)
|
|
308
|
+
pending.length = 0
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const flushPending = () => {
|
|
312
|
+
clearTimeout(earlyTimer)
|
|
313
|
+
flushed = true
|
|
314
|
+
state.flushed = true
|
|
315
|
+
if (pending.length) {
|
|
316
|
+
console.log(`\n${GUTTER}${t.dim("Notices")}`)
|
|
317
|
+
for (const line of pending) console.log(line)
|
|
318
|
+
pending.length = 0
|
|
319
|
+
}
|
|
320
|
+
if (nextStep) {
|
|
321
|
+
// Live events (reloads, request errors) follow; a gap keeps them
|
|
322
|
+
// from reading as part of the banner.
|
|
323
|
+
console.log(`\n${nextStep}\n`)
|
|
324
|
+
nextStep = null
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const route = (line) => {
|
|
329
|
+
// The next-step line arrives inside the banner but must print after the
|
|
330
|
+
// notices, so it is held rather than forwarded in place.
|
|
331
|
+
const nextAt = line.indexOf(NEXT_STEP)
|
|
332
|
+
if (nextAt !== -1) {
|
|
333
|
+
nextStep = line.slice(nextAt + NEXT_STEP.length)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
// Fenced banner: forward verbatim, fences themselves never shown.
|
|
337
|
+
if (line.includes(BANNER_START)) {
|
|
338
|
+
inBanner = true
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
if (line.includes(BANNER_END)) {
|
|
342
|
+
inBanner = false
|
|
343
|
+
// Give late-arriving startup notices a moment to land so they join
|
|
344
|
+
// the same group rather than trailing in one at a time after it.
|
|
345
|
+
if (!flushScheduled) {
|
|
346
|
+
flushScheduled = true
|
|
347
|
+
setTimeout(flushPending, 250).unref?.()
|
|
348
|
+
}
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
if (inBanner) {
|
|
352
|
+
console.log(line)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const formatted = formatDevLine(line, state)
|
|
357
|
+
if (formatted === null) return
|
|
358
|
+
|
|
359
|
+
// Before the banner, hold everything back. After it, print as it
|
|
360
|
+
// happens -- an HMR update or a request error is a live event.
|
|
361
|
+
if (flushed) {
|
|
362
|
+
console.log(formatted)
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
pending.push(formatted)
|
|
366
|
+
// Short enough to read as prompt, long enough for a crash's exit to
|
|
367
|
+
// win the race, so a fatal error is still reported once by the exit
|
|
368
|
+
// handler rather than here as well.
|
|
369
|
+
if (!earlyTimer && (state.sawDiagnostic || formatted === state.lastErrorLine)) {
|
|
370
|
+
earlyTimer = setTimeout(printEarly, EARLY_FLUSH_MS)
|
|
371
|
+
earlyTimer.unref?.()
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const stdout = createLineReader(route)
|
|
376
|
+
const stderr = createLineReader(route)
|
|
377
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk))
|
|
378
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk))
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
state,
|
|
382
|
+
// On a failed start the banner never arrives, so anything held back is
|
|
383
|
+
// still queued -- including the server's own diagnostic. With
|
|
384
|
+
// `dropLastError` the exit handler is about to render the last error
|
|
385
|
+
// as a diagnostic, so its one-line form is left out.
|
|
386
|
+
flushRemaining({ dropLastError = false } = {}) {
|
|
387
|
+
clearTimeout(earlyTimer)
|
|
388
|
+
stdout.flush()
|
|
389
|
+
stderr.flush()
|
|
390
|
+
const skip = dropLastError && !state.sawDiagnostic ? state.lastErrorLine : undefined
|
|
391
|
+
for (const line of pending) if (line !== skip) console.log(line)
|
|
392
|
+
pending.length = 0
|
|
393
|
+
nextStep = null
|
|
394
|
+
},
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Supervise a spawned server: wire its output, forward signals, and report its
|
|
400
|
+
* exit exactly once.
|
|
401
|
+
*
|
|
402
|
+
* Shared by `start` and `serve` rather than copied. The two differ only in what
|
|
403
|
+
* they call the thing that failed, and when the silent-crash bug was fixed the
|
|
404
|
+
* same block had to be edited identically in both files -- which is the
|
|
405
|
+
* argument for it living in one place.
|
|
406
|
+
*
|
|
407
|
+
* @param {import("child_process").ChildProcess} child
|
|
408
|
+
* @param {object} options
|
|
409
|
+
* @param {string} options.scope - names the failing part, e.g. "dev server"
|
|
410
|
+
* @param {string} options.label - what could not be started, for the spawn error
|
|
411
|
+
* @param {boolean} [options.passthrough] - the child inherits stdio; its output
|
|
412
|
+
* is not wired or reworded, and only signals and the exit code are handled
|
|
413
|
+
*/
|
|
414
|
+
function superviseServer(child, { scope, label, passthrough = false }) {
|
|
415
|
+
const { diagnostic } = require("./diagnostic.js")
|
|
416
|
+
const { hintFor } = require("./hints.js")
|
|
417
|
+
|
|
418
|
+
const { state, flushRemaining } = passthrough
|
|
419
|
+
? { state: {}, flushRemaining() {} }
|
|
420
|
+
: wireServerOutput(child)
|
|
421
|
+
|
|
422
|
+
// Ctrl-C reaches the child through the shared process group; forward the
|
|
423
|
+
// rest so a `kill` of this wrapper takes the server with it.
|
|
424
|
+
for (const signal of ["SIGTERM", "SIGHUP"]) {
|
|
425
|
+
process.on(signal, () => child.kill(signal))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
child.on("error", (error) => {
|
|
429
|
+
process.stderr.write(diagnostic({ message: `Could not start the ${label}: ${error.message}` }))
|
|
430
|
+
process.exit(1)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
child.on("close", (code, signal) => {
|
|
434
|
+
// Ctrl-C is the normal way to stop a server, not a failure.
|
|
435
|
+
const stopped = signal === "SIGINT" || signal === "SIGTERM"
|
|
436
|
+
flushRemaining({ dropLastError: !stopped && code !== 0 })
|
|
437
|
+
if (stopped) process.exit(0)
|
|
438
|
+
|
|
439
|
+
if (code !== 0) {
|
|
440
|
+
// Report unless the server already printed its own diagnostic.
|
|
441
|
+
// Gating this on a matching hint meant an unrecognised crash
|
|
442
|
+
// exited silently -- the case where the user most needs telling.
|
|
443
|
+
// A passthrough child's own output already said why, unaltered.
|
|
444
|
+
if (!passthrough && !state.sawDiagnostic) {
|
|
445
|
+
const known = hintFor(state.lastError || "")
|
|
446
|
+
process.stderr.write(
|
|
447
|
+
diagnostic({
|
|
448
|
+
message: state.lastError || `The server exited unexpectedly (code ${code})`,
|
|
449
|
+
scope,
|
|
450
|
+
hint: known?.hint,
|
|
451
|
+
docs: known?.docs,
|
|
452
|
+
})
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
process.exit(code ?? 1)
|
|
456
|
+
}
|
|
457
|
+
process.exit(0)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
return state
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Fail before spawning the server when its port is taken.
|
|
465
|
+
*
|
|
466
|
+
* Checked up front because the child only learns it at listen(), after Vite
|
|
467
|
+
* and the app have loaded -- by which point it has also printed startup
|
|
468
|
+
* notices and grabbed (or failed to grab) the HMR port, so the real problem
|
|
469
|
+
* arrived buried under two unrelated lines and Node's raw EADDRINUSE text.
|
|
470
|
+
*/
|
|
471
|
+
function ensurePortFree({ port, host, scope }) {
|
|
472
|
+
const net = require("net")
|
|
473
|
+
const { diagnostic } = require("./diagnostic.js")
|
|
474
|
+
const numeric = Number(port)
|
|
475
|
+
if (!Number.isInteger(numeric) || numeric <= 0) return Promise.resolve()
|
|
476
|
+
|
|
477
|
+
return new Promise((resolve) => {
|
|
478
|
+
const probe = net.createServer()
|
|
479
|
+
probe.once("error", (error) => {
|
|
480
|
+
if (error.code !== "EADDRINUSE") return resolve()
|
|
481
|
+
const owner = portOwner(numeric)
|
|
482
|
+
process.stderr.write(
|
|
483
|
+
diagnostic({
|
|
484
|
+
message: `Port ${numeric} is already in use${owner ? ` by ${owner.command} (pid ${owner.pid})` : ""}`,
|
|
485
|
+
scope,
|
|
486
|
+
hint: owner
|
|
487
|
+
? `Stop it with \`kill ${owner.pid}\`, or change NODE_SERVER_PORT and PUBLIC_STATIC_ASSET_URL in config/config.json.`
|
|
488
|
+
: "Stop whatever is using it, or change NODE_SERVER_PORT and PUBLIC_STATIC_ASSET_URL in config/config.json.",
|
|
489
|
+
})
|
|
490
|
+
)
|
|
491
|
+
process.exit(1)
|
|
492
|
+
})
|
|
493
|
+
probe.once("listening", () => probe.close(() => resolve()))
|
|
494
|
+
probe.listen({ port: numeric, host: host || undefined })
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** The process listening on a port, via lsof; null where lsof is unavailable. */
|
|
499
|
+
function portOwner(port) {
|
|
500
|
+
try {
|
|
501
|
+
const { execFileSync } = require("child_process")
|
|
502
|
+
const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc"], {
|
|
503
|
+
encoding: "utf8",
|
|
504
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
505
|
+
timeout: 3000,
|
|
506
|
+
})
|
|
507
|
+
const pid = /^p(\d+)$/m.exec(out)?.[1]
|
|
508
|
+
const command = /^c(.+)$/m.exec(out)?.[1]
|
|
509
|
+
return pid ? { pid, command: command || "another process" } : null
|
|
510
|
+
} catch (error) {
|
|
511
|
+
return null
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
module.exports = {
|
|
516
|
+
ensurePortFree,
|
|
517
|
+
formatDevLine,
|
|
518
|
+
createLineReader,
|
|
519
|
+
wireServerOutput,
|
|
520
|
+
superviseServer,
|
|
521
|
+
BANNER_START,
|
|
522
|
+
BANNER_END,
|
|
523
|
+
NEXT_STEP,
|
|
524
|
+
}
|