@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.
- package/config.d.ts +348 -3
- package/index.js +25 -249
- package/lib/caching/valkey.js +382 -0
- package/lib/capability.js +339 -0
- package/lib/create-context-patch.js +31 -0
- package/lib/loader-next-15.cjs +52 -0
- package/lib/loader.js +48 -19
- package/lib/schema.js +55 -6
- package/package.json +23 -17
- package/schema.json +1404 -7
|
@@ -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
|
+
}
|