@platformatic/next 3.4.1 → 3.5.0

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.
@@ -0,0 +1,382 @@
1
+ import { buildPinoFormatters, buildPinoTimestamp, ensureLoggableError } from '@platformatic/foundation'
2
+ import { Redis } from 'iovalkey'
3
+ import { pack, unpack } from 'msgpackr'
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import { hostname } from 'node:os'
6
+ import { resolve } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { pino } from 'pino'
9
+
10
+ const CACHE_HIT_METRIC = { name: 'next_cache_valkey_hit_count', help: 'Next.js Cache (Valkey) Hit Count' }
11
+ const CACHE_MISS_METRIC = { name: 'next_cache_valkey_miss_count', help: 'Next.js Cache (Valkey) Miss Count' }
12
+
13
+ const clients = new Map()
14
+
15
+ export const MAX_BATCH_SIZE = 100
16
+
17
+ export const sections = {
18
+ values: 'values',
19
+ tags: 'tags'
20
+ }
21
+
22
+ export function keyFor (prefix, subprefix, section, key) {
23
+ return [prefix, 'cache:next', subprefix, section, key ? Buffer.from(key).toString('base64url') : undefined]
24
+ .filter(c => c)
25
+ .join(':')
26
+ }
27
+
28
+ export function getConnection (url) {
29
+ let client = clients.get(url)
30
+
31
+ if (!client) {
32
+ client = new Redis(url, { enableAutoPipelining: true })
33
+ clients.set(url, client)
34
+
35
+ globalThis.platformatic.events.on('plt:next:close', () => {
36
+ client.disconnect(false)
37
+ })
38
+ }
39
+
40
+ return client
41
+ }
42
+
43
+ export class CacheHandler {
44
+ #standalone
45
+ #config
46
+ #logger
47
+ #store
48
+ #prefix
49
+ #subprefix
50
+ #meta
51
+ #maxTTL
52
+ #cacheHitMetric
53
+ #cacheMissMetric
54
+
55
+ constructor (options) {
56
+ options ??= {}
57
+
58
+ this.#standalone = options.standalone
59
+ this.#config = options.config
60
+ this.#logger = options.logger
61
+ this.#store = options.store
62
+ this.#maxTTL = options.maxTTL
63
+ this.#prefix = options.prefix
64
+ this.#subprefix = options.subprefix
65
+ this.#meta = options.meta
66
+
67
+ if (!this.#standalone && globalThis.platformatic) {
68
+ this.#config ??= globalThis.platformatic.config.cache
69
+ this.#logger ??= this.#createPlatformaticLogger()
70
+ this.#store ??= getConnection(this.#config.url)
71
+ this.#maxTTL ??= this.#config.maxTTL
72
+ this.#prefix ??= this.#config.prefix
73
+ this.#subprefix ??= this.#getPlatformaticSubprefix()
74
+ this.#meta ??= this.#getPlatformaticMeta()
75
+ } else {
76
+ this.#config ??= {}
77
+ this.#maxTTL ??= 86_400
78
+ this.#prefix ??= ''
79
+ this.#subprefix ??= ''
80
+ this.#meta ??= {}
81
+ }
82
+
83
+ if (!this.#config) {
84
+ throw new Error('Please provide a the "config" option.')
85
+ }
86
+
87
+ if (!this.#logger) {
88
+ throw new Error('Please provide a the "logger" option.')
89
+ }
90
+
91
+ if (!this.#store) {
92
+ throw new Error('Please provide a the "store" option.')
93
+ }
94
+
95
+ if (globalThis.platformatic) {
96
+ this.#registerMetrics()
97
+ }
98
+ }
99
+
100
+ async get (cacheKey, _, isRedisKey) {
101
+ this.#logger.trace({ key: cacheKey }, 'cache get')
102
+
103
+ const key = this.#standalone || isRedisKey ? cacheKey : this.#keyFor(cacheKey, sections.values)
104
+
105
+ let rawValue
106
+ try {
107
+ rawValue = await this.#store.get(key)
108
+
109
+ if (!rawValue) {
110
+ this.#cacheMissMetric?.inc()
111
+ return
112
+ }
113
+ } catch (e) {
114
+ this.#cacheMissMetric?.inc()
115
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot read cache value from Valkey')
116
+ throw new Error('Cannot read cache value from Valkey', { cause: e })
117
+ }
118
+
119
+ let value
120
+ try {
121
+ value = this.#deserialize(rawValue)
122
+ } catch (e) {
123
+ this.#cacheMissMetric?.inc()
124
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot deserialize cache value from Valkey')
125
+
126
+ // Avoid useless reads the next time
127
+ // Note that since the value was unserializable, we don't know its tags and thus
128
+ // we cannot remove it from the tags sets. TTL will take care of them.
129
+ await this.#store.del(key)
130
+
131
+ throw new Error('Cannot deserialize cache value from Valkey', { cause: e })
132
+ }
133
+
134
+ if (this.#maxTTL < value.revalidate) {
135
+ try {
136
+ await this.#refreshKey(key, value)
137
+ } catch (e) {
138
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot refresh cache key expiration in Valkey')
139
+
140
+ // We don't throw here since we want to use the cached value anyway
141
+ }
142
+ }
143
+
144
+ this.#cacheHitMetric?.inc()
145
+ return value
146
+ }
147
+
148
+ async set (cacheKey, value, ctx, isRedisKey) {
149
+ const tags = ctx.tags
150
+ const revalidate = ctx.revalidate ?? ctx.cacheControl?.revalidate ?? value.revalidate ?? 0
151
+
152
+ this.#logger.trace({ key: cacheKey, value, tags, revalidate }, 'cache set')
153
+
154
+ const key = this.#standalone || isRedisKey ? cacheKey : this.#keyFor(cacheKey, sections.values)
155
+
156
+ try {
157
+ // Compute the parameters to save
158
+ const data = this.#serialize({
159
+ value,
160
+ tags,
161
+ lastModified: Date.now(),
162
+ revalidate,
163
+ maxTTL: this.#maxTTL,
164
+ ...this.#meta
165
+ })
166
+ const expire = Math.min(revalidate, this.#maxTTL)
167
+
168
+ if (expire < 1) {
169
+ return
170
+ }
171
+
172
+ // Enqueue all the operations to perform in Valkey
173
+
174
+ const promises = []
175
+ promises.push(this.#store.set(key, data, 'EX', expire))
176
+
177
+ // As Next.js limits tags to 64, we don't need to manage batches here
178
+ if (Array.isArray(tags)) {
179
+ for (const tag of tags) {
180
+ const tagsKey = this.#keyFor(tag, sections.tags)
181
+ promises.push(this.#store.sadd(tagsKey, key))
182
+ promises.push(this.#store.expire(tagsKey, expire))
183
+ }
184
+ }
185
+
186
+ // Execute all the operations
187
+ await Promise.all(promises)
188
+ } catch (e) {
189
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot write cache value in Valkey')
190
+ throw new Error('Cannot write cache value in Valkey', { cause: e })
191
+ }
192
+ }
193
+
194
+ async remove (cacheKey, isRedisKey) {
195
+ this.#logger.trace({ key: cacheKey }, 'cache remove')
196
+
197
+ const key = this.#standalone || isRedisKey ? cacheKey : this.#keyFor(cacheKey, sections.values)
198
+
199
+ let rawValue
200
+ try {
201
+ rawValue = await this.#store.get(key)
202
+
203
+ if (!rawValue) {
204
+ return
205
+ }
206
+ } catch (e) {
207
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot read cache value from Valkey')
208
+ throw new Error('Cannot read cache value from Valkey', { cause: e })
209
+ }
210
+
211
+ let value
212
+ try {
213
+ value = this.#deserialize(rawValue)
214
+ } catch (e) {
215
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot deserialize cache value from Valkey')
216
+
217
+ // Avoid useless reads the next time
218
+ // Note that since the value was unserializable, we don't know its tags and thus
219
+ // we cannot remove it from the tags sets. TTL will take care of them.
220
+ await this.#store.del(key)
221
+
222
+ throw new Error('Cannot deserialize cache value from Valkey', { cause: e })
223
+ }
224
+
225
+ try {
226
+ const promises = []
227
+ promises.push(this.#store.del(key))
228
+
229
+ if (Array.isArray(value.tags)) {
230
+ for (const tag of value.tags) {
231
+ const tagsKey = this.#keyFor(tag, sections.tags)
232
+ promises.push(this.#store.srem(tagsKey, key))
233
+ }
234
+ }
235
+
236
+ // Execute all the operations
237
+ await Promise.all(promises)
238
+ } catch (e) {
239
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot remove cache value from Valkey')
240
+ throw new Error('Cannot remove cache value from Valkey', { cause: e })
241
+ }
242
+ }
243
+
244
+ async revalidateTag (tags) {
245
+ this.#logger.trace({ tags }, 'cache revalidateTag')
246
+
247
+ if (typeof tags === 'string') {
248
+ tags = [tags]
249
+ }
250
+
251
+ try {
252
+ let promises = []
253
+
254
+ for (const tag of tags) {
255
+ const tagsKey = this.#keyFor(tag, sections.tags)
256
+
257
+ // For each key in the tag set, expire the key
258
+ for await (const keys of this.#store.sscanStream(tagsKey)) {
259
+ for (const key of keys) {
260
+ promises.push(this.#store.del(key))
261
+
262
+ // Batch full, execute it
263
+ if (promises.length >= MAX_BATCH_SIZE) {
264
+ await Promise.all(promises)
265
+ promises = []
266
+ }
267
+ }
268
+ }
269
+
270
+ // Delete the set, this will also take care of executing pending operation for a non full batch
271
+ promises.push(this.#store.del(tagsKey))
272
+ await Promise.all(promises)
273
+ promises = []
274
+ }
275
+ } catch (e) {
276
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot expire cache tags in Valkey')
277
+ throw new Error('Cannot expire cache tags in Valkey', { cause: e })
278
+ }
279
+ }
280
+
281
+ async #refreshKey (key, value) {
282
+ const life = Math.round((Date.now() - value.lastModified) / 1000)
283
+ const expire = Math.min(value.revalidate - life, this.#maxTTL)
284
+
285
+ if (expire < 1) {
286
+ return
287
+ }
288
+
289
+ const promises = []
290
+ promises.push(this.#store.expire(key, expire, 'gt'))
291
+
292
+ if (Array.isArray(value.tags)) {
293
+ for (const tag of value.tags) {
294
+ const tagsKey = this.#keyFor(tag, sections.tags)
295
+ promises.push(this.#store.expire(tagsKey, expire, 'gt'))
296
+ }
297
+ }
298
+
299
+ await Promise.all(promises)
300
+ }
301
+
302
+ #createPlatformaticLogger () {
303
+ const loggerConfig = globalThis.platformatic?.config?.logger
304
+
305
+ const pinoOptions = {
306
+ ...loggerConfig,
307
+ level: globalThis.platformatic?.logLevel ?? loggerConfig?.level ?? 'info'
308
+ }
309
+ if (pinoOptions.formatters) {
310
+ pinoOptions.formatters = buildPinoFormatters(pinoOptions.formatters)
311
+ }
312
+ if (pinoOptions.timestamp) {
313
+ pinoOptions.timestamp = buildPinoTimestamp(pinoOptions.timestamp)
314
+ }
315
+
316
+ if (this.applicationId) {
317
+ pinoOptions.name = `cache:${this.applicationId}`
318
+ }
319
+
320
+ if (pinoOptions.base !== null && typeof globalThis.platformatic.workerId !== 'undefined') {
321
+ pinoOptions.base = {
322
+ ...(pinoOptions.base ?? {}),
323
+ pid: process.pid,
324
+ hostname: hostname(),
325
+ worker: this.workerId
326
+ }
327
+ } else if (pinoOptions.base === null) {
328
+ pinoOptions.base = undefined
329
+ }
330
+
331
+ return pino(pinoOptions)
332
+ }
333
+
334
+ #getPlatformaticSubprefix () {
335
+ const root = fileURLToPath(globalThis.platformatic.root)
336
+
337
+ return existsSync(resolve(root, '.next/BUILD_ID'))
338
+ ? (this.#subprefix = readFileSync(resolve(root, '.next/BUILD_ID'), 'utf-8').trim())
339
+ : 'development'
340
+ }
341
+
342
+ #getPlatformaticMeta () {
343
+ return {
344
+ applicationId: globalThis.platformatic.applicationId,
345
+ workerId: globalThis.platformatic.workerId
346
+ }
347
+ }
348
+
349
+ #keyFor (key, section) {
350
+ return keyFor(this.#prefix, this.#subprefix, section, key)
351
+ }
352
+
353
+ #serialize (data) {
354
+ return pack(data).toString('base64url')
355
+ }
356
+
357
+ #deserialize (data) {
358
+ return unpack(Buffer.from(data, 'base64url'))
359
+ }
360
+
361
+ #registerMetrics () {
362
+ const { client, registry } = globalThis.platformatic.prometheus
363
+
364
+ this.#cacheHitMetric =
365
+ registry.getSingleMetric(CACHE_HIT_METRIC.name) ??
366
+ new client.Counter({
367
+ name: CACHE_HIT_METRIC.name,
368
+ help: CACHE_HIT_METRIC.help,
369
+ registers: [registry]
370
+ })
371
+
372
+ this.#cacheMissMetric =
373
+ registry.getSingleMetric(CACHE_MISS_METRIC.name) ??
374
+ new client.Counter({
375
+ name: CACHE_MISS_METRIC.name,
376
+ help: CACHE_MISS_METRIC.help,
377
+ registers: [registry]
378
+ })
379
+ }
380
+ }
381
+
382
+ export default CacheHandler
@@ -0,0 +1,339 @@
1
+ import {
2
+ BaseCapability,
3
+ ChildManager,
4
+ cleanBasePath,
5
+ createChildProcessListener,
6
+ createServerListener,
7
+ errors,
8
+ getServerUrl,
9
+ importFile,
10
+ resolvePackage
11
+ } from '@platformatic/basic'
12
+ import { ChildProcess } from 'node:child_process'
13
+ import { once } from 'node:events'
14
+ import { readFile, writeFile } from 'node:fs/promises'
15
+ import { dirname, resolve as resolvePath } from 'node:path'
16
+ import { pathToFileURL } from 'node:url'
17
+ import { parse, satisfies } from 'semver'
18
+ import { version } from './schema.js'
19
+
20
+ const supportedVersions = ['^14.0.0', '^15.0.0']
21
+
22
+ export class NextCapability extends BaseCapability {
23
+ #basePath
24
+ #next
25
+ #nextVersion
26
+ #child
27
+ #server
28
+
29
+ constructor (root, config, context) {
30
+ super('next', version, root, config, context)
31
+ }
32
+
33
+ async init () {
34
+ await super.init()
35
+
36
+ // This is needed to avoid Next.js to throw an error when the lockfile is not correct
37
+ // and the user is using npm but has pnpm in its $PATH.
38
+ //
39
+ // See: https://github.com/platformatic/composer-next-node-fastify/pull/3
40
+ //
41
+ // PS by Paolo: Sob.
42
+ process.env.NEXT_IGNORE_INCORRECT_LOCKFILE = 'true'
43
+
44
+ this.#next = resolvePath(dirname(resolvePackage(this.root, 'next')), '../..')
45
+ const nextPackage = JSON.parse(await readFile(resolvePath(this.#next, 'package.json'), 'utf-8'))
46
+ this.#nextVersion = parse(nextPackage.version)
47
+
48
+ if (this.#nextVersion.major < 15 || (this.#nextVersion.major <= 15 && this.#nextVersion.minor < 1)) {
49
+ await import('./create-context-patch.js')
50
+ }
51
+
52
+ /* c8 ignore next 3 */
53
+ if (!supportedVersions.some(v => satisfies(nextPackage.version, v))) {
54
+ throw new errors.UnsupportedVersion('next', nextPackage.version, supportedVersions)
55
+ }
56
+ }
57
+
58
+ async start ({ listen }) {
59
+ // Make this idempotent
60
+ if (this.url) {
61
+ return this.url
62
+ }
63
+
64
+ this.on('config', config => {
65
+ this.#basePath = config.basePath
66
+ })
67
+
68
+ if (this.isProduction) {
69
+ await this.#startProduction(listen)
70
+ } else {
71
+ await this.#startDevelopment(listen)
72
+ }
73
+
74
+ await this._collectMetrics()
75
+ }
76
+
77
+ async stop () {
78
+ await super.stop()
79
+
80
+ if (this.subprocess) {
81
+ return this.stopCommand()
82
+ }
83
+
84
+ globalThis.platformatic.events.emit('plt:next:close')
85
+
86
+ if (this.isProduction && this.#server) {
87
+ await new Promise((resolve, reject) => {
88
+ this.#server.close(error => {
89
+ /* c8 ignore next 3 */
90
+ if (error) {
91
+ return reject(error)
92
+ }
93
+
94
+ resolve()
95
+ })
96
+ })
97
+
98
+ await this.childManager.close()
99
+ } else if (this.#child) {
100
+ const exitPromise = once(this.#child, 'exit')
101
+ await this.childManager.close()
102
+ process.kill(this.#child.pid, 'SIGKILL')
103
+ await exitPromise
104
+ }
105
+ }
106
+
107
+ async build () {
108
+ if (!this.#nextVersion) {
109
+ await this.init()
110
+ }
111
+
112
+ const config = this.config
113
+ const loader = new URL('./loader.js', import.meta.url)
114
+ this.#basePath = config.application?.basePath ? cleanBasePath(config.application?.basePath) : ''
115
+
116
+ let command = config.application.commands.build
117
+
118
+ if (!command) {
119
+ await this.init()
120
+ command = ['node', resolvePath(this.#next, './dist/bin/next'), 'build', this.root]
121
+ }
122
+
123
+ await this.buildWithCommand(command, this.#basePath, { loader, scripts: this.#getChildManagerScripts() })
124
+
125
+ // This is need to avoid Next.js 15.4+ to throw an error as process.cwd() is not the root of the Next.js application
126
+ if (
127
+ config.cache?.adapter &&
128
+ (this.#nextVersion.major > 15 || (this.#nextVersion.major === 15 && this.#nextVersion.minor >= 4))
129
+ ) {
130
+ const distDir = resolvePath(this.root, '.next')
131
+ const requiredServerFilesPath = resolvePath(distDir, 'required-server-files.json')
132
+ const requiredServerFiles = JSON.parse(await readFile(requiredServerFilesPath, 'utf-8'))
133
+
134
+ if (requiredServerFiles.config.cacheHandler) {
135
+ requiredServerFiles.config.cacheHandler = resolvePath(distDir, requiredServerFiles.config.cacheHandler)
136
+ await writeFile(requiredServerFilesPath, JSON.stringify(requiredServerFiles, null, 2))
137
+ }
138
+ }
139
+ }
140
+
141
+ /* c8 ignore next 5 */
142
+ async getWatchConfig () {
143
+ return {
144
+ enabled: false,
145
+ path: this.root
146
+ }
147
+ }
148
+
149
+ getMeta () {
150
+ const gateway = { prefix: this.basePath ?? this.#basePath, wantsAbsoluteUrls: true, needsRootTrailingSlash: false }
151
+
152
+ if (this.url) {
153
+ gateway.tcp = true
154
+ gateway.url = this.url
155
+ }
156
+
157
+ return { gateway }
158
+ }
159
+
160
+ async getChildManagerContext (basePath) {
161
+ const context = await super.getChildManagerContext(basePath)
162
+
163
+ context.exitOnUnhandledErrors = false
164
+
165
+ return context
166
+ }
167
+
168
+ async #startDevelopment () {
169
+ const config = this.config
170
+ const loaderUrl = new URL('./loader.js', import.meta.url)
171
+ const command = this.config.application.commands.development
172
+
173
+ this.#basePath = config.application?.basePath ? cleanBasePath(config.application?.basePath) : ''
174
+
175
+ if (command) {
176
+ return this.startWithCommand(command, loaderUrl, this.#getChildManagerScripts())
177
+ }
178
+
179
+ const { hostname, port } = this.serverConfig ?? {}
180
+ const serverOptions = {
181
+ host: hostname || '127.0.0.1',
182
+ port: port || 0
183
+ }
184
+
185
+ const context = await this.getChildManagerContext(this.#basePath)
186
+
187
+ this.childManager = new ChildManager({
188
+ loader: loaderUrl,
189
+ context: {
190
+ ...context,
191
+ port: false,
192
+ wantsAbsoluteUrls: true
193
+ },
194
+ scripts: this.#getChildManagerScripts()
195
+ })
196
+
197
+ const promise = once(this.childManager, 'url')
198
+ await this.#startDevelopmentNext(serverOptions)
199
+ const [url, clientWs] = await promise
200
+ this.url = url
201
+ this.clientWs = clientWs
202
+ }
203
+
204
+ async #startDevelopmentNext (serverOptions) {
205
+ const { nextDev } = await importFile(resolvePath(this.#next, './dist/cli/next-dev.js'))
206
+
207
+ try {
208
+ await this.childManager.inject()
209
+ const childPromise = createChildProcessListener()
210
+
211
+ this.#ensurePipeableStreamsInFork()
212
+
213
+ if (this.#nextVersion.major === 14 && this.#nextVersion.minor < 2) {
214
+ await nextDev({
215
+ '--hostname': serverOptions.host,
216
+ '--port': serverOptions.port,
217
+ _: [this.root]
218
+ })
219
+ } else {
220
+ await nextDev(serverOptions, 'default', this.root)
221
+ }
222
+
223
+ this.#child = await childPromise
224
+ this.#child.stdout.setEncoding('utf8')
225
+ this.#child.stderr.setEncoding('utf8')
226
+
227
+ this.#child.stdout.pipe(process.stdout, { end: false })
228
+ this.#child.stderr.pipe(process.stderr, { end: false })
229
+ } finally {
230
+ await this.childManager.eject()
231
+ }
232
+ }
233
+
234
+ async #startProduction (listen) {
235
+ const config = this.config
236
+ const loaderUrl = new URL('./loader.js', import.meta.url)
237
+ const command = this.config.application.commands.production
238
+
239
+ this.#basePath = config.application?.basePath ? cleanBasePath(config.application?.basePath) : ''
240
+
241
+ if (command) {
242
+ const childManagerScripts = this.#getChildManagerScripts()
243
+
244
+ if (this.#nextVersion.major < 15 || (this.#nextVersion.major <= 15 && this.#nextVersion.minor < 1)) {
245
+ childManagerScripts.push(new URL('./create-context-patch.js', import.meta.url))
246
+ }
247
+ return this.startWithCommand(command, loaderUrl, childManagerScripts)
248
+ }
249
+
250
+ this.childManager = new ChildManager({
251
+ loader: loaderUrl,
252
+ context: {
253
+ config: this.config,
254
+ applicationId: this.applicationId,
255
+ workerId: this.workerId,
256
+ // Always use URL to avoid serialization problem in Windows
257
+ root: pathToFileURL(this.root).toString(),
258
+ basePath: this.#basePath,
259
+ logLevel: this.logger.level,
260
+ isEntrypoint: this.isEntrypoint,
261
+ runtimeBasePath: this.runtimeConfig.basePath,
262
+ wantsAbsoluteUrls: true,
263
+ telemetryConfig: this.telemetryConfig
264
+ },
265
+ scripts: this.#getChildManagerScripts()
266
+ })
267
+
268
+ this.verifyOutputDirectory(resolvePath(this.root, '.next'))
269
+ await this.#startProductionNext()
270
+ }
271
+
272
+ async #startProductionNext () {
273
+ try {
274
+ globalThis.platformatic.config = this.config
275
+ await this.childManager.inject()
276
+ const { nextStart } = await importFile(resolvePath(this.#next, './dist/cli/next-start.js'))
277
+
278
+ const { hostname, port } = this.serverConfig ?? {}
279
+ const serverOptions = {
280
+ hostname: hostname || '127.0.0.1',
281
+ port: port || 0
282
+ }
283
+
284
+ this.childManager.register()
285
+ const serverPromise = createServerListener(
286
+ (this.isEntrypoint ? serverOptions?.port : undefined) ?? true,
287
+ (this.isEntrypoint ? serverOptions?.hostname : undefined) ?? true
288
+ )
289
+
290
+ if (this.#nextVersion.major === 14 && this.#nextVersion.minor < 2) {
291
+ await nextStart({
292
+ '--hostname': serverOptions.host,
293
+ '--port': serverOptions.port,
294
+ _: [this.root]
295
+ })
296
+ } else {
297
+ await nextStart(serverOptions, this.root)
298
+ }
299
+
300
+ this.#server = await serverPromise
301
+ this.url = getServerUrl(this.#server)
302
+ } finally {
303
+ await this.childManager.eject()
304
+ }
305
+ }
306
+
307
+ #getChildManagerScripts () {
308
+ const scripts = []
309
+
310
+ if (this.#nextVersion.major === 15) {
311
+ scripts.push(new URL('./loader-next-15.cjs', import.meta.url))
312
+ }
313
+
314
+ return scripts
315
+ }
316
+
317
+ // In development mode, Next.js starts the dev server using child_process.fork with stdio set to 'inherit'.
318
+ // In order to capture the output, we need to ensure that the streams are pipeable and thus we perform a one-time
319
+ // monkey-patch of the ChildProcess.prototype.spawn method to override stdio[1] and stdio[2] to 'pipe'.
320
+ #ensurePipeableStreamsInFork () {
321
+ const originalSpawn = ChildProcess.prototype.spawn
322
+
323
+ // IMPORTANT: If Next.js code changes this might not work anymore. When this gives error, dig into Next.js code
324
+ // to evaluate the new path and/or if this is still necessary.
325
+ const startServerPath = resolvePath(this.#next, './dist/server/lib/start-server.js')
326
+
327
+ ChildProcess.prototype.spawn = function (options) {
328
+ if (options.args?.[1] === startServerPath) {
329
+ options.stdio[1] = 'pipe'
330
+ options.stdio[2] = 'pipe'
331
+
332
+ // Uninstall the patch
333
+ ChildProcess.prototype.spawn = originalSpawn
334
+ }
335
+
336
+ return originalSpawn.call(this, options)
337
+ }
338
+ }
339
+ }