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.
Files changed (90) hide show
  1. package/bin/catalyst.js +105 -1
  2. package/changelog.md +34 -0
  3. package/dist/cli/README.md +119 -0
  4. package/dist/cli/appConfig.js +51 -0
  5. package/dist/cli/buildErrors.js +170 -0
  6. package/dist/cli/buildWarnings.js +28 -0
  7. package/dist/cli/devOutput.js +524 -0
  8. package/dist/cli/diagnostic.js +388 -0
  9. package/dist/cli/hints.js +69 -0
  10. package/dist/cli/package.json +3 -0
  11. package/dist/cli/spinner.js +141 -0
  12. package/dist/cli/theme.js +155 -0
  13. package/dist/errors/index.js +7 -3
  14. package/dist/logger.js +61 -6
  15. package/dist/native/androidProject/app/build.gradle.kts +2 -1
  16. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
  17. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
  18. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
  20. package/dist/native/androidSetup.js +46 -57
  21. package/dist/native/buildAndroid/assets.js +22 -13
  22. package/dist/native/buildAndroid/build.js +98 -26
  23. package/dist/native/buildAndroid/config.js +43 -12
  24. package/dist/native/buildAndroid/index.js +49 -191
  25. package/dist/native/buildAppAndroid.js +19 -8
  26. package/dist/native/buildAppIos.js +35 -16
  27. package/dist/native/buildIos/assets.js +1 -6
  28. package/dist/native/buildIos/build.js +66 -95
  29. package/dist/native/buildIos/config.js +26 -8
  30. package/dist/native/buildIos/index.js +143 -64
  31. package/dist/native/buildIos/plugins.js +12 -15
  32. package/dist/native/internalPluginUtils.js +0 -1
  33. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
  34. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
  35. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
  36. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
  37. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
  38. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
  39. package/dist/native/pluginComposerAndroid.js +2 -3
  40. package/dist/native/renameAndroidProject.js +42 -20
  41. package/dist/native/setupEmulatorIos.js +28 -49
  42. package/dist/native/setupServer.js +12 -15
  43. package/dist/native/terminalProgress.js +186 -147
  44. package/dist/native/utils.js +340 -54
  45. package/dist/otel.js +15 -9
  46. package/dist/scripts/build.js +313 -42
  47. package/dist/scripts/generateOfflineManifest.js +11 -1
  48. package/dist/scripts/loadEnvironmentVariables.js +24 -2
  49. package/dist/scripts/preServerInit.js +32 -10
  50. package/dist/scripts/preflight.js +35 -11
  51. package/dist/scripts/scriptUtils.js +34 -0
  52. package/dist/scripts/serve.js +64 -15
  53. package/dist/scripts/start.js +58 -21
  54. package/dist/server/expressServer.js +167 -32
  55. package/dist/server/renderer/handler.jsx +87 -11
  56. package/dist/server/utils/validator.js +14 -11
  57. package/dist/vite/manifest-categorization-plugin.js +7 -5
  58. package/dist/vite/resolveDevServerConfig.js +9 -2
  59. package/dist/vite/vite.config.client.js +18 -0
  60. package/dist/vite/vite.config.js +73 -13
  61. package/dist/vite/vite.config.server.js +21 -0
  62. package/dist/web-router/components/MetaTag.jsx +15 -2
  63. package/dist/web-router/components/MetaTag.test.jsx +71 -2
  64. package/dist/webmcp/WebMcpProvider.jsx +3 -2
  65. package/dist/webmcp/useTool.js +4 -1
  66. package/mcp_v2/conversion-tasks.json +6 -5
  67. package/mcp_v2/knowledge-base.json +124 -7
  68. package/mcp_v2/test/config.test.ts +59 -0
  69. package/mcp_v2/tools/build.js +6 -6
  70. package/mcp_v2/tools/config.js +35 -18
  71. package/mcp_v2/tools/conversion.js +7 -13
  72. package/package.json +5 -5
  73. package/test/cli/buildErrors.test.ts +173 -0
  74. package/test/cli/buildWarnings.test.cjs +62 -0
  75. package/test/cli/devOutput.test.ts +549 -0
  76. package/test/cli/diagnostic.test.ts +434 -0
  77. package/test/cli/output.test.cjs +90 -0
  78. package/test/cli/spinner.test.ts +177 -0
  79. package/test/cli/theme.test.ts +163 -0
  80. package/test/native/redactSecrets.test.ts +72 -0
  81. package/test/native/utils.test.ts +532 -0
  82. package/test/scripts/build.test.ts +275 -0
  83. package/test/scripts/serve.test.ts +102 -0
  84. package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
  85. package/test/server/handler.test.ts +17 -0
  86. package/test/server/logger.test.ts +185 -0
  87. package/test/server/otel.test.ts +556 -0
  88. package/test/server/validator.test.ts +19 -8
  89. package/dist/scripts/checkVersion.js +0 -3
  90. 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
+ }