-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrspress.ts
More file actions
290 lines (268 loc) · 9.63 KB
/
rspress.ts
File metadata and controls
290 lines (268 loc) · 9.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { Server } from 'node:http'
import { platform } from 'node:os'
import { dev, build, serve } from '@rspress/core'
import type { Paths, ZpressConfig } from '@zpress/core'
import { createRspressConfig } from '@zpress/ui'
import getPort, { portNumbers } from 'get-port'
import { match } from 'ts-pattern'
import { toError } from './error'
/**
* Default port for the dev server.
* Falls back to the next available port in range DEV_PORT..DEV_PORT + DEV_PORT_RANGE.
*/
export const DEV_PORT = 6174
const DEV_PORT_RANGE = 5
/** Default port for the static preview server. Errors if occupied. */
export const SERVE_PORT = 8080
interface ServerOptions {
readonly config: ZpressConfig
readonly paths: Paths
readonly port?: number
readonly vscode?: boolean
readonly theme?: string
readonly colorMode?: string
}
/**
* Server instance returned by Rspress dev().
*
* Rspress only declares `close()` in its public types, but the
* underlying Rsbuild server exposes `httpServer` at runtime.
* We access it via {@link getHttpServer} to avoid a type mismatch.
*/
interface ServerInstance {
readonly close: () => Promise<void>
}
/**
* Internal options for `startServer` that control rebuild behaviour.
*/
interface StartServerOptions {
/** When true, disables the persistent build cache for this invocation. */
readonly skipBuildCache: boolean
}
/**
* Callback invoked when the dev server should restart due to config changes.
*/
export type OnConfigReload = (newConfig: ZpressConfig) => Promise<void>
/**
* Start the Rspress dev server with zpress configuration.
*
* Returns a callback that will restart the server when invoked with updated config.
* The callback closes the current server instance and starts a new one with the
* fresh configuration values.
*
* @param options - Dev server configuration including config and paths
* @returns An async callback to invoke when config changes with new config (restarts server)
*/
export async function startDevServer(
options: ServerOptions
): Promise<(newConfig: ZpressConfig) => Promise<void>> {
const { paths } = options
// Resolve port once so restarts reuse the same port
const preferred = options.port ?? DEV_PORT
const port = await getPort({ port: portNumbers(preferred, preferred + DEV_PORT_RANGE) })
// oxlint-disable-next-line functional/no-let -- mutable server instance for restart capability
let serverInstance: ServerInstance | null = null
async function startServer(
config: ZpressConfig,
internalOptions: StartServerOptions
): Promise<boolean> {
const rspressConfig = createRspressConfig({
config,
paths,
vscode: options.vscode,
themeOverride: options.theme,
colorModeOverride: options.colorMode,
})
try {
serverInstance = await dev({
appDirectory: paths.repoRoot,
docDirectory: paths.contentDir,
config: rspressConfig,
configFilePath: '',
extraBuilderConfig: {
server: {
port,
strictPort: true,
},
// Disable persistent build cache on config-reload restarts.
// Rspress's cacheDigest only covers sidebar/nav structure,
// so changes to title, theme, colors, source.define values
// etc. would serve stale cached output without this.
...buildCacheOverride(internalOptions),
},
})
return true
} catch (error) {
process.stderr.write(`Dev server error: ${toError(error).message}\n`)
return false
}
}
// Start initial server — exit if it fails on first boot
const started = await startServer(options.config, { skipBuildCache: false })
if (!started) {
process.exit(1)
}
// Return callback that restarts server with new config
return async (newConfig: ZpressConfig) => {
process.stdout.write('\n🔄 Config changed — restarting dev server...\n')
// Close existing server and wait for port release
if (serverInstance) {
const httpServer = getHttpServer(serverInstance)
// Register the close listener before close() so we don't miss the
// event (once() only observes future emissions)
const closeEvent = createCloseEvent(httpServer)
try {
await serverInstance.close()
} catch (error) {
process.stderr.write(`Error closing server: ${toError(error).message}\n`)
}
// Rsbuild's close() destroys tracked sockets and calls httpServer.close(),
// but the 'close' event fires only once the port is actually freed.
if (closeEvent) {
const PORT_RELEASE_TIMEOUT = 5_000
await Promise.race([
closeEvent,
// oxlint-disable-next-line no-promise-executor-return -- timeout resolve is intentional
new Promise((resolve) => setTimeout(resolve, PORT_RELEASE_TIMEOUT)),
])
}
serverInstance = null
}
// Start new server with fresh config (bypass persistent cache)
const restarted = await startServer(newConfig, { skipBuildCache: true })
if (restarted) {
process.stdout.write('✅ Dev server restarted\n\n')
} else {
process.stderr.write('⚠️ Dev server failed to restart — fix the config and save again\n\n')
}
}
}
/**
* Build the Rspress site with zpress configuration.
*
* @param options - Build configuration including config and paths
* @returns A promise that resolves when the build completes
*/
export async function buildSite(options: ServerOptions): Promise<void> {
const rspressConfig = createRspressConfig({ config: options.config, paths: options.paths })
await build({
docDirectory: options.paths.contentDir,
config: rspressConfig,
configFilePath: '',
})
}
/**
* Build the Rspress site for check/validation purposes.
*
* Uses the standard Rspress build (no log-level suppression) so that
* `remarkLink`'s deadlink diagnostics are written to stderr and can be
* captured by the calling code. The caller is responsible for swallowing
* stderr output so it doesn't reach the terminal.
*
* @param options - Build configuration including config and paths
* @returns A promise that resolves when the build completes
*/
export async function buildSiteForCheck(options: ServerOptions): Promise<void> {
const rspressConfig = createRspressConfig({ config: options.config, paths: options.paths })
await build({
docDirectory: options.paths.contentDir,
config: rspressConfig,
configFilePath: '',
})
}
/**
* Serve the built Rspress site (static preview).
*
* @param options - Serve configuration including config and paths
* @returns The port the server is listening on
*/
export async function serveSite(options: ServerOptions): Promise<number> {
const rspressConfig = createRspressConfig({
config: options.config,
paths: options.paths,
vscode: options.vscode,
themeOverride: options.theme,
colorModeOverride: options.colorMode,
})
const preferredPort = options.port ?? SERVE_PORT
const port = await getPort({ port: portNumbers(preferredPort, preferredPort + DEV_PORT_RANGE) })
await serve({
config: rspressConfig,
configFilePath: '',
port,
})
return port
}
/**
* Open a URL in the default browser (cross-platform).
*
* @param url - The URL to open in the default browser
*/
export function openBrowser(url: string): void {
const os = platform()
const { cmd, args } = match(os)
.with('darwin', () => ({ cmd: 'open', args: [url] }))
.with('win32', () => ({ cmd: 'cmd', args: ['/c', 'start', url] }))
.otherwise(() => ({ cmd: 'xdg-open', args: [url] }))
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref()
}
// ---------------------------------------------------------------------------
/**
* Create a close event promise if the server is actively listening.
*
* Must be called before `close()` so the listener is registered
* before the event fires — `once()` only observes future emissions.
*
* @private
* @param httpServer - The HTTP server to listen on, or null
* @returns A promise that resolves when 'close' fires, or null if not listening
*/
function createCloseEvent(httpServer: Server | null): Promise<unknown[]> | null {
if (httpServer === null) {
return null
}
if (!httpServer.listening) {
return null
}
return once(httpServer, 'close')
}
/**
* Return a performance config override that disables persistent build cache
* on config-reload restarts.
*
* Rspress's persistent cache (`buildCache.cacheDigest`) only tracks sidebar/nav
* structure. Changes to title, theme, colors, and `source.define` values are
* invisible to it, causing stale cached output. Disabling the cache on restart
* forces a fresh Rsbuild compilation with the updated config values.
*
* @private
* @param options - Internal server options
* @returns Partial Rsbuild config with cache override, or empty object
*/
function buildCacheOverride(options: StartServerOptions): Record<string, unknown> {
if (options.skipBuildCache) {
return { performance: { buildCache: false } }
}
return {}
}
/**
* Extract the underlying HTTP server from a Rspress/Rsbuild server instance.
*
* Rspress's public `ServerInstance` type only declares `close()`, but the
* runtime object is a Rsbuild dev server which exposes `httpServer`.
* This helper performs a runtime property check to safely extract it.
*
* @private
* @param instance - The server instance returned by Rspress dev()
* @returns The HTTP server if present, otherwise null
*/
function getHttpServer(instance: ServerInstance): Server | null {
const record = instance as unknown as Record<string, unknown>
const value = record['httpServer']
if (value instanceof Server) {
return value
}
return null
}