@platformatic/next 3.16.0 → 3.18.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 CHANGED
@@ -523,6 +523,7 @@ export interface PlatformaticNextJsConfig {
523
523
  adapter: "redis" | "valkey";
524
524
  url: string;
525
525
  prefix?: string;
526
+ cacheComponents?: boolean;
526
527
  maxTTL?: number | string;
527
528
  };
528
529
  }
package/index.js CHANGED
@@ -1,8 +1,14 @@
1
1
  import { transform as basicTransform, resolve, validationOptions } from '@platformatic/basic'
2
2
  import { kMetadata, loadConfiguration as utilsLoadConfiguration } from '@platformatic/foundation'
3
+ import { sep } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
3
5
  import { NextCapability } from './lib/capability.js'
4
6
  import { schema } from './lib/schema.js'
5
7
 
8
+ function getCacheHandlerPath (name) {
9
+ return fileURLToPath(new URL(`./lib/caching/${name}.js`, import.meta.url)).replaceAll(sep, '/')
10
+ }
11
+
6
12
  /* c8 ignore next 9 */
7
13
  export async function transform (config, schema, options) {
8
14
  config = await basicTransform(config, schema, options)
@@ -15,6 +21,40 @@ export async function transform (config, schema, options) {
15
21
  return config
16
22
  }
17
23
 
24
+ export async function enhanceNextConfig (nextConfig, ...args) {
25
+ // This is to avoid https://github.com/vercel/next.js/issues/76981
26
+ Headers.prototype[Symbol.for('nodejs.util.inspect.custom')] = undefined
27
+
28
+ if (typeof nextConfig === 'function') {
29
+ nextConfig = await nextConfig(...args)
30
+ }
31
+
32
+ const { basePath, config, nextVersion } = globalThis.platformatic
33
+
34
+ if (typeof nextConfig.basePath === 'undefined') {
35
+ nextConfig.basePath = basePath
36
+ }
37
+
38
+ if (config.cache?.adapter) {
39
+ if (nextVersion.major > 15 && config.cache?.cacheComponents && typeof nextConfig.cacheComponents === 'undefined') {
40
+ nextConfig.cacheComponents = true
41
+ nextConfig.cacheHandler = getCacheHandlerPath('null-isr')
42
+ nextConfig.cacheHandlers = { default: getCacheHandlerPath(`${config.cache.adapter}-components`) }
43
+ nextConfig.cacheMaxMemorySize = 0
44
+ } else if (typeof nextConfig.cacheHandler === 'undefined') {
45
+ nextConfig.cacheHandler = getCacheHandlerPath(`${config.cache.adapter}-isr`)
46
+ nextConfig.cacheMaxMemorySize = 0
47
+ }
48
+ }
49
+
50
+ if (config.next?.trailingSlash && typeof nextConfig.trailingSlash === 'undefined') {
51
+ nextConfig.trailingSlash = true
52
+ }
53
+
54
+ globalThis.platformatic.notifyConfig(nextConfig)
55
+ return nextConfig
56
+ }
57
+
18
58
  export async function loadConfiguration (configOrRoot, sourceOrConfig, context) {
19
59
  const { root, source } = await resolve(configOrRoot, sourceOrConfig, 'application')
20
60
 
@@ -32,6 +72,6 @@ export async function create (configOrRoot, sourceOrConfig, context) {
32
72
  return new NextCapability(config[kMetadata].root, config, context)
33
73
  }
34
74
 
35
- export * as cachingValkey from './lib/caching/valkey.js'
75
+ export * as cachingValkey from './lib/caching/valkey-isr.js'
36
76
  export * from './lib/capability.js'
37
77
  export { packageJson, schema, schemaComponents, version } from './lib/schema.js'
@@ -0,0 +1,12 @@
1
+ // This is provided to allow users to only rely on Cache Components and not the ISR cache
2
+ export class CacheHandler {
3
+ async get () {}
4
+
5
+ async set () {}
6
+
7
+ async remove () {}
8
+
9
+ async revalidateTag () {}
10
+ }
11
+
12
+ export default CacheHandler
@@ -0,0 +1,101 @@
1
+ import { buildPinoFormatters, buildPinoTimestamp } 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
+ globalThis.platformatic ??= {}
11
+ globalThis.platformatic.valkeyClients = new Map()
12
+
13
+ export function keyFor (prefix, subprefix, section, key) {
14
+ let result = prefix?.length ? prefix + ':' : ''
15
+
16
+ result += 'cache:next'
17
+
18
+ if (subprefix?.length) {
19
+ result += ':' + subprefix
20
+ }
21
+
22
+ if (section?.length) {
23
+ result += ':' + section
24
+ }
25
+
26
+ if (key?.length) {
27
+ result += Buffer.from(key).toString('base64url')
28
+ }
29
+
30
+ return result
31
+ }
32
+
33
+ export function getConnection (url) {
34
+ let client = globalThis.platformatic.valkeyClients.get(url)
35
+
36
+ if (!client) {
37
+ client = new Redis(url, { enableAutoPipelining: true })
38
+ globalThis.platformatic.valkeyClients.set(url, client)
39
+
40
+ globalThis.platformatic.events.on('plt:next:close', () => {
41
+ client.disconnect(false)
42
+ })
43
+ }
44
+
45
+ return client
46
+ }
47
+
48
+ export function createPlatformaticLogger () {
49
+ const loggerConfig = globalThis.platformatic?.config?.logger
50
+
51
+ const pinoOptions = {
52
+ ...loggerConfig,
53
+ level: globalThis.platformatic?.logLevel ?? loggerConfig?.level ?? 'info'
54
+ }
55
+ if (pinoOptions.formatters) {
56
+ pinoOptions.formatters = buildPinoFormatters(pinoOptions.formatters)
57
+ }
58
+ if (pinoOptions.timestamp) {
59
+ pinoOptions.timestamp = buildPinoTimestamp(pinoOptions.timestamp)
60
+ }
61
+
62
+ if (globalThis.platformatic?.applicationId) {
63
+ pinoOptions.name = `cache:${globalThis.platformatic.applicationId}`
64
+ }
65
+
66
+ if (pinoOptions.base !== null) {
67
+ pinoOptions.base = {
68
+ ...(pinoOptions.base ?? {}),
69
+ pid: process.pid,
70
+ hostname: hostname(),
71
+ worker: globalThis.platformatic?.workerId
72
+ }
73
+ } else if (pinoOptions.base === null) {
74
+ pinoOptions.base = undefined
75
+ }
76
+
77
+ return pino(pinoOptions)
78
+ }
79
+
80
+ export function getPlatformaticSubprefix () {
81
+ const root = fileURLToPath(globalThis.platformatic.root)
82
+
83
+ return existsSync(resolve(root, '.next/BUILD_ID'))
84
+ ? readFileSync(resolve(root, '.next/BUILD_ID'), 'utf-8').trim()
85
+ : 'development'
86
+ }
87
+
88
+ export function getPlatformaticMeta () {
89
+ return {
90
+ applicationId: globalThis.platformatic.applicationId,
91
+ workerId: globalThis.platformatic.workerId
92
+ }
93
+ }
94
+
95
+ export function serialize (data) {
96
+ return pack(data).toString('base64url')
97
+ }
98
+
99
+ export function deserialize (data) {
100
+ return unpack(Buffer.from(data, 'base64url'))
101
+ }
@@ -0,0 +1,231 @@
1
+ import { ensureLoggableError } from '@platformatic/foundation'
2
+ import { ReadableStream } from 'node:stream/web'
3
+ import {
4
+ createPlatformaticLogger,
5
+ deserialize,
6
+ getConnection,
7
+ getPlatformaticMeta,
8
+ getPlatformaticSubprefix,
9
+ keyFor,
10
+ serialize
11
+ } from './valkey-common.js'
12
+
13
+ export const CACHE_HIT_METRIC = {
14
+ name: 'next_components_cache_valkey_hit_count',
15
+ help: 'Next.js Components Cache (Valkey) Hit Count'
16
+ }
17
+ export const CACHE_MISS_METRIC = {
18
+ name: 'next_components_cache_valkey_miss_count',
19
+ help: 'Next.js Components Cache (Valkey) Miss Count'
20
+ }
21
+ export const MAX_BATCH_SIZE = 100
22
+
23
+ export const sections = {
24
+ values: 'components:values',
25
+ tags: 'components:tags'
26
+ }
27
+
28
+ export class CacheHandler {
29
+ #config
30
+ #logger
31
+ #store
32
+ #prefix
33
+ #subprefix
34
+ #meta
35
+ #maxTTL
36
+ #cacheHitMetric
37
+ #cacheMissMetric
38
+
39
+ constructor () {
40
+ this.#config ??= globalThis.platformatic.config.cache
41
+ this.#logger ??= createPlatformaticLogger()
42
+ this.#store ??= getConnection(this.#config.url)
43
+ this.#maxTTL ??= this.#config.maxTTL
44
+ this.#prefix ??= this.#config.prefix
45
+ this.#subprefix ??= getPlatformaticSubprefix()
46
+ this.#meta ??= getPlatformaticMeta()
47
+
48
+ if (!this.#config) {
49
+ throw new Error('Please provide a the "config" option.')
50
+ }
51
+
52
+ if (!this.#logger) {
53
+ throw new Error('Please provide a the "logger" option.')
54
+ }
55
+
56
+ if (!this.#store) {
57
+ throw new Error('Please provide a the "store" option.')
58
+ }
59
+
60
+ if (globalThis.platformatic) {
61
+ this.#registerMetrics()
62
+ }
63
+ }
64
+
65
+ async get (cacheKey, _, isRedisKey) {
66
+ this.#logger.trace({ key: cacheKey }, 'cache get')
67
+
68
+ const key = isRedisKey ? cacheKey : this.#keyFor(cacheKey, sections.values)
69
+
70
+ let rawValue
71
+ try {
72
+ rawValue = await this.#store.get(key)
73
+
74
+ if (!rawValue) {
75
+ this.#cacheMissMetric?.inc()
76
+ return
77
+ }
78
+ } catch (e) {
79
+ this.#cacheMissMetric?.inc()
80
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot read cache value from Valkey')
81
+
82
+ return
83
+ }
84
+
85
+ let raw
86
+ try {
87
+ raw = deserialize(rawValue)
88
+ } catch (e) {
89
+ this.#cacheMissMetric?.inc()
90
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot deserialize cache value from Valkey')
91
+
92
+ // Avoid useless reads the next time
93
+ // Note that since the value was unserializable, we don't know its tags and thus
94
+ // we cannot remove it from the tags sets. TTL will take care of them.
95
+ await this.#store.del(key)
96
+
97
+ return
98
+ }
99
+
100
+ const { maxTTL: _maxTTL, meta: _meta, ...value } = raw
101
+
102
+ if (this.#maxTTL < raw.revalidate) {
103
+ try {
104
+ await this.#refreshKey(key, value)
105
+ } catch (e) {
106
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot refresh cache key expiration in Valkey')
107
+ }
108
+ }
109
+
110
+ // Convert the value back to a web ReadableStream. Sob.
111
+ const buffer = value.value
112
+ value.value = new ReadableStream({
113
+ start (controller) {
114
+ controller.enqueue(buffer)
115
+ controller.close()
116
+ }
117
+ })
118
+
119
+ this.#cacheHitMetric?.inc()
120
+ return value
121
+ }
122
+
123
+ async set (cacheKey, dataPromise, isRedisKey) {
124
+ const { value, ...data } = await dataPromise
125
+ const { expire: expireSec, tags, revalidate } = data
126
+
127
+ this.#logger.trace({ key: cacheKey, value, tags, revalidate }, 'cache set')
128
+
129
+ const key = isRedisKey ? cacheKey : this.#keyFor(cacheKey, sections.values)
130
+
131
+ try {
132
+ // Gather the value
133
+ const chunks = []
134
+ for await (const chunk of value) {
135
+ chunks.push(chunk)
136
+ }
137
+
138
+ // Compute the parameters to save
139
+ const toSerialize = serialize({
140
+ maxTTL: this.#maxTTL,
141
+ meta: this.#meta,
142
+ value: Buffer.concat(chunks),
143
+ ...data
144
+ })
145
+ const expire = Math.min(revalidate, expireSec, this.#maxTTL)
146
+
147
+ if (expire < 1) {
148
+ return
149
+ }
150
+
151
+ // Enqueue all the operations to perform in Valkey
152
+ const promises = []
153
+ promises.push(this.#store.set(key, toSerialize, 'EX', expire))
154
+
155
+ // As Next.js limits tags to 64, we don't need to manage batches here
156
+ if (Array.isArray(tags)) {
157
+ for (const tag of tags) {
158
+ const tagsKey = this.#keyFor(tag, sections.tags)
159
+ promises.push(this.#store.sadd(tagsKey, key))
160
+ promises.push(this.#store.expire(tagsKey, expire))
161
+ }
162
+ }
163
+
164
+ // Execute all the operations
165
+ await Promise.all(promises)
166
+ } catch (e) {
167
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot write cache value in Valkey')
168
+ throw new Error('Cannot write cache value in Valkey', { cause: e })
169
+ }
170
+ }
171
+
172
+ async refreshTags () {
173
+ // this.#logger.trace('refreshTags - Not implemented')
174
+ }
175
+
176
+ getExpiration (_tags) {
177
+ // this.#logger.trace({ tags: _tags }, 'getExpiration - Not implemented')
178
+ return Number.POSITIVE_INFINITY
179
+ }
180
+
181
+ updateTags (_tags) {
182
+ // this.#logger.trace({ tags: _tags }, 'updateTags - Not implemented')
183
+ }
184
+
185
+ async #refreshKey (key, value) {
186
+ const life = Math.round((Date.now() - value.timestamp) / 1000)
187
+ const expire = Math.min(value.revalidate - life, this.#maxTTL)
188
+
189
+ if (expire < 1) {
190
+ return
191
+ }
192
+
193
+ const promises = []
194
+ promises.push(this.#store.expire(key, expire, 'gt'))
195
+
196
+ if (Array.isArray(value.tags)) {
197
+ for (const tag of value.tags) {
198
+ const tagsKey = this.#keyFor(tag, sections.tags)
199
+ promises.push(this.#store.expire(tagsKey, expire, 'gt'))
200
+ }
201
+ }
202
+
203
+ await Promise.all(promises)
204
+ }
205
+
206
+ #keyFor (key, section) {
207
+ return keyFor(this.#prefix, this.#subprefix, section, key)
208
+ }
209
+
210
+ #registerMetrics () {
211
+ const { client, registry } = globalThis.platformatic.prometheus
212
+
213
+ this.#cacheHitMetric =
214
+ registry.getSingleMetric(CACHE_HIT_METRIC.name) ??
215
+ new client.Counter({
216
+ name: CACHE_HIT_METRIC.name,
217
+ help: CACHE_HIT_METRIC.help,
218
+ registers: [registry]
219
+ })
220
+
221
+ this.#cacheMissMetric =
222
+ registry.getSingleMetric(CACHE_MISS_METRIC.name) ??
223
+ new client.Counter({
224
+ name: CACHE_MISS_METRIC.name,
225
+ help: CACHE_MISS_METRIC.help,
226
+ registers: [registry]
227
+ })
228
+ }
229
+ }
230
+
231
+ export default new CacheHandler()
@@ -1,17 +1,16 @@
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
-
1
+ import { ensureLoggableError } from '@platformatic/foundation'
2
+ import {
3
+ createPlatformaticLogger,
4
+ deserialize,
5
+ getConnection,
6
+ getPlatformaticMeta,
7
+ getPlatformaticSubprefix,
8
+ keyFor,
9
+ serialize
10
+ } from './valkey-common.js'
11
+
12
+ export const CACHE_HIT_METRIC = { name: 'next_cache_valkey_hit_count', help: 'Next.js Cache (Valkey) Hit Count' }
13
+ export const CACHE_MISS_METRIC = { name: 'next_cache_valkey_miss_count', help: 'Next.js Cache (Valkey) Miss Count' }
15
14
  export const MAX_BATCH_SIZE = 100
16
15
 
17
16
  export const sections = {
@@ -19,27 +18,6 @@ export const sections = {
19
18
  tags: 'tags'
20
19
  }
21
20
 
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
21
  export class CacheHandler {
44
22
  #standalone
45
23
  #config
@@ -64,14 +42,14 @@ export class CacheHandler {
64
42
  this.#subprefix = options.subprefix
65
43
  this.#meta = options.meta
66
44
 
67
- if (!this.#standalone && globalThis.platformatic) {
45
+ if (!this.#standalone && globalThis.platformatic?.config) {
68
46
  this.#config ??= globalThis.platformatic.config.cache
69
- this.#logger ??= this.#createPlatformaticLogger()
47
+ this.#logger ??= createPlatformaticLogger()
70
48
  this.#store ??= getConnection(this.#config.url)
71
49
  this.#maxTTL ??= this.#config.maxTTL
72
50
  this.#prefix ??= this.#config.prefix
73
- this.#subprefix ??= this.#getPlatformaticSubprefix()
74
- this.#meta ??= this.#getPlatformaticMeta()
51
+ this.#subprefix ??= getPlatformaticSubprefix()
52
+ this.#meta ??= getPlatformaticMeta()
75
53
  } else {
76
54
  this.#config ??= {}
77
55
  this.#maxTTL ??= 86_400
@@ -92,7 +70,7 @@ export class CacheHandler {
92
70
  throw new Error('Please provide a the "store" option.')
93
71
  }
94
72
 
95
- if (globalThis.platformatic) {
73
+ if (globalThis.platformatic?.prometheus) {
96
74
  this.#registerMetrics()
97
75
  }
98
76
  }
@@ -118,7 +96,7 @@ export class CacheHandler {
118
96
 
119
97
  let value
120
98
  try {
121
- value = this.#deserialize(rawValue)
99
+ value = deserialize(rawValue)
122
100
  } catch (e) {
123
101
  this.#cacheMissMetric?.inc()
124
102
  this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot deserialize cache value from Valkey')
@@ -155,7 +133,7 @@ export class CacheHandler {
155
133
 
156
134
  try {
157
135
  // Compute the parameters to save
158
- const data = this.#serialize({
136
+ const data = serialize({
159
137
  value,
160
138
  tags,
161
139
  lastModified: Date.now(),
@@ -170,7 +148,6 @@ export class CacheHandler {
170
148
  }
171
149
 
172
150
  // Enqueue all the operations to perform in Valkey
173
-
174
151
  const promises = []
175
152
  promises.push(this.#store.set(key, data, 'EX', expire))
176
153
 
@@ -210,7 +187,7 @@ export class CacheHandler {
210
187
 
211
188
  let value
212
189
  try {
213
- value = this.#deserialize(rawValue)
190
+ value = deserialize(rawValue)
214
191
  } catch (e) {
215
192
  this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot deserialize cache value from Valkey')
216
193
 
@@ -299,65 +276,10 @@ export class CacheHandler {
299
276
  await Promise.all(promises)
300
277
  }
301
278
 
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) {
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
279
  #keyFor (key, section) {
350
280
  return keyFor(this.#prefix, this.#subprefix, section, key)
351
281
  }
352
282
 
353
- #serialize (data) {
354
- return pack(data).toString('base64url')
355
- }
356
-
357
- #deserialize (data) {
358
- return unpack(Buffer.from(data, 'base64url'))
359
- }
360
-
361
283
  #registerMetrics () {
362
284
  const { client, registry } = globalThis.platformatic.prometheus
363
285
 
package/lib/capability.js CHANGED
@@ -13,11 +13,10 @@ import { ChildProcess } from 'node:child_process'
13
13
  import { once } from 'node:events'
14
14
  import { readFile, writeFile } from 'node:fs/promises'
15
15
  import { dirname, resolve as resolvePath } from 'node:path'
16
- import { pathToFileURL } from 'node:url'
17
16
  import { parse, satisfies } from 'semver'
18
17
  import { version } from './schema.js'
19
18
 
20
- const supportedVersions = ['^14.0.0', '^15.0.0']
19
+ const supportedVersions = ['^14.0.0', '^15.0.0', '^16.0.0']
21
20
 
22
21
  export class NextCapability extends BaseCapability {
23
22
  #basePath
@@ -164,7 +163,10 @@ export class NextCapability extends BaseCapability {
164
163
  async getChildManagerContext (basePath) {
165
164
  const context = await super.getChildManagerContext(basePath)
166
165
 
166
+ const { major, minor } = this.#nextVersion
167
167
  context.exitOnUnhandledErrors = false
168
+ context.wantsAbsoluteUrls = true
169
+ context.nextVersion = { major, minor }
168
170
 
169
171
  return context
170
172
  }
@@ -190,16 +192,13 @@ export class NextCapability extends BaseCapability {
190
192
 
191
193
  this.childManager = new ChildManager({
192
194
  loader: loaderUrl,
193
- context: {
194
- ...context,
195
- port: false,
196
- wantsAbsoluteUrls: true
197
- },
195
+ context: { ...context, port: false },
198
196
  scripts: this.#getChildManagerScripts()
199
197
  })
200
198
 
201
199
  const promise = once(this.childManager, 'url')
202
200
  await this.#startDevelopmentNext(serverOptions)
201
+
203
202
  const [url, clientWs] = await promise
204
203
  this.url = url
205
204
  this.clientWs = clientWs
@@ -235,7 +234,7 @@ export class NextCapability extends BaseCapability {
235
234
  }
236
235
  }
237
236
 
238
- async #startProduction (listen) {
237
+ async #startProduction () {
239
238
  const config = this.config
240
239
  const loaderUrl = new URL('./loader.js', import.meta.url)
241
240
  const command = this.config.application.commands.production
@@ -253,19 +252,7 @@ export class NextCapability extends BaseCapability {
253
252
 
254
253
  this.childManager = new ChildManager({
255
254
  loader: loaderUrl,
256
- context: {
257
- config: this.config,
258
- applicationId: this.applicationId,
259
- workerId: this.workerId,
260
- // Always use URL to avoid serialization problem in Windows
261
- root: pathToFileURL(this.root).toString(),
262
- basePath: this.#basePath,
263
- logLevel: this.logger.level,
264
- isEntrypoint: this.isEntrypoint,
265
- runtimeBasePath: this.runtimeConfig.basePath,
266
- wantsAbsoluteUrls: true,
267
- telemetryConfig: this.telemetryConfig
268
- },
255
+ context: await this.getChildManagerContext(this.#basePath),
269
256
  scripts: this.#getChildManagerScripts()
270
257
  })
271
258
 
@@ -312,7 +299,7 @@ export class NextCapability extends BaseCapability {
312
299
  #getChildManagerScripts () {
313
300
  const scripts = []
314
301
 
315
- if (this.#nextVersion.major === 15) {
302
+ if (this.#nextVersion.major >= 15) {
316
303
  scripts.push(new URL('./loader-next-15.cjs', import.meta.url))
317
304
  }
318
305
 
@@ -42,8 +42,7 @@ fsPromises.readFile = async function readAndPatchNextConfigTS (url, options) {
42
42
 
43
43
  const { code } = transformSync(contents.toString('utf-8'), { mode: 'strip-only' })
44
44
 
45
- const { transformESM, transformCJS, setLoaderData } = await import('./loader.js')
46
- setLoaderData({ basePath: globalThis.platformatic.basePath, config: globalThis.platformatic.config })
45
+ const { transformESM, transformCJS } = await import('./loader.js')
47
46
  const transformer = detectFormat(code) === 'esm' ? transformESM : transformCJS
48
47
  const transformed = transformer(code)
49
48
 
package/lib/loader.js CHANGED
@@ -11,83 +11,26 @@ import {
11
11
  variableDeclarator
12
12
  } from '@babel/types'
13
13
  import { readFile, realpath } from 'node:fs/promises'
14
- import { sep } from 'node:path'
15
14
  import { fileURLToPath, pathToFileURL } from 'node:url'
16
15
 
17
- const originalId = '__pltOriginalNextConfig'
18
-
19
- let config
20
- let candidates
21
- let basePath = ''
16
+ const originalId = '__pltEnhanceNextConfig'
17
+ // Keep in sync with https://github.com/vercel/next.js/blob/main/packages/next/src/shared/lib/constants.ts
18
+ let candidates = ['next.config.js', 'next.config.mjs']
22
19
 
23
20
  function parseSingleExpression (expr) {
24
21
  return parse(expr, { allowAwaitOutsideFunction: true }).program.body[0]
25
22
  }
26
23
 
27
- /*
28
- Generates:
29
- async function (...args) {
30
- let __pltOriginalNextConfig = $ORIGINAL;
31
-
32
- if (typeof __pltOriginalNextConfig === 'function') {
33
- __pltOriginalNextConfig = await __pltOriginalNextConfig(...args);
34
- }
35
-
36
- if(typeof __pltOriginalNextConfig.basePath === 'undefined') {
37
- __pltOriginalNextConfig.basePath = basePath
38
- }
39
-
40
- if(typeof __pltOriginalNextConfig.cacheHandler === 'undefined') {
41
- __pltOriginalNextConfig.cacheHandler = $PATH
42
- __pltOriginalNextConfig.cacheMaxMemorySize = 0
43
- }
44
-
45
- // This is to send the configuraion when Next is executed in a child process
46
- globalThis.platformatic.notifyConfig(__pltOriginalNextConfig)
47
-
48
- return __pltOriginalNextConfig;
49
- }
50
- */
51
24
  function createEvaluatorWrapperFunction (original) {
52
- const cacheHandler = config?.cache?.adapter
53
- ? fileURLToPath(new URL(`./caching/${config.cache.adapter}.js`, import.meta.url)).replaceAll(sep, '/')
54
- : undefined
55
-
56
- const trailingSlash = config?.next?.trailingSlash
57
-
58
25
  return functionDeclaration(
59
- null,
26
+ identifier(originalId),
60
27
  [restElement(identifier('args'))],
61
- blockStatement(
62
- [
63
- // This is to avoid https://github.com/vercel/next.js/issues/76981
64
- parseSingleExpression("Headers.prototype[Symbol.for('nodejs.util.inspect.custom')] = undefined"),
65
- variableDeclaration('let', [variableDeclarator(identifier(originalId), original)]),
66
- parseSingleExpression(
67
- `if (typeof ${originalId} === 'function') { ${originalId} = await ${originalId}(...args) }`
68
- ),
69
- parseSingleExpression(
70
- `if (typeof ${originalId}.basePath === 'undefined') { ${originalId}.basePath = "${basePath}" }`
71
- ),
72
- cacheHandler
73
- ? parseSingleExpression(`
74
- if (typeof ${originalId}.cacheHandler === 'undefined') {
75
- ${originalId}.cacheHandler = '${cacheHandler}'
76
- ${originalId}.cacheMaxMemorySize = 0
77
- }
78
- `)
79
- : undefined,
80
- trailingSlash
81
- ? parseSingleExpression(`
82
- if (typeof ${originalId}.trailingSlash === 'undefined') {
83
- ${originalId}.trailingSlash = true
84
- }
85
- `)
86
- : undefined,
87
- parseSingleExpression(`globalThis.platformatic.notifyConfig(${originalId})`),
88
- returnStatement(identifier(originalId))
89
- ].filter(e => e)
90
- ),
28
+ blockStatement([
29
+ parseSingleExpression("const { enhanceNextConfig } = await import('@platformatic/next')"),
30
+ variableDeclaration('const', [variableDeclarator(identifier('original'), original)]),
31
+ parseSingleExpression('const enhanced = await enhanceNextConfig(original, ...args)'),
32
+ returnStatement(identifier('enhanced'))
33
+ ]),
91
34
  false,
92
35
  true
93
36
  )
@@ -143,12 +86,6 @@ export function transformESM (source) {
143
86
  return generate.default(ast).code
144
87
  }
145
88
 
146
- export function setLoaderData (data) {
147
- candidates = data.candidates
148
- basePath = data.basePath ?? ''
149
- config = data.config
150
- }
151
-
152
89
  export async function initialize (data) {
153
90
  const realRoot = pathToFileURL(await realpath(fileURLToPath(data.root)))
154
91
 
@@ -156,12 +93,7 @@ export async function initialize (data) {
156
93
  realRoot.pathname += '/'
157
94
  }
158
95
 
159
- setLoaderData({
160
- // Keep in sync with https://github.com/vercel/next.js/blob/main/packages/next/src/shared/lib/constants.ts
161
- candidates: ['next.config.js', 'next.config.mjs'].map(c => new URL(c, realRoot).toString()),
162
- basePath: data.basePath ?? '',
163
- config: data.config
164
- })
96
+ candidates = candidates.map(c => new URL(c, realRoot).toString())
165
97
  }
166
98
 
167
99
  export async function load (url, context, nextLoad) {
package/lib/schema.js CHANGED
@@ -19,6 +19,9 @@ export const cache = {
19
19
  prefix: {
20
20
  type: 'string'
21
21
  },
22
+ cacheComponents: {
23
+ type: 'boolean'
24
+ },
22
25
  maxTTL: {
23
26
  default: 86400 * 7, // One week
24
27
  anyOf: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/next",
3
- "version": "3.16.0",
3
+ "version": "3.18.0",
4
4
  "description": "Platformatic Next.js Capability",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -23,8 +23,8 @@
23
23
  "iovalkey": "^0.3.0",
24
24
  "msgpackr": "^1.11.2",
25
25
  "semver": "^7.6.3",
26
- "@platformatic/basic": "3.16.0",
27
- "@platformatic/foundation": "3.16.0"
26
+ "@platformatic/basic": "3.18.0",
27
+ "@platformatic/foundation": "3.18.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@fastify/reply-from": "^12.0.0",
@@ -37,17 +37,21 @@
37
37
  "fastify": "^5.0.0",
38
38
  "json-schema-to-typescript": "^15.0.1",
39
39
  "neostandard": "^0.12.0",
40
- "next": "^15.0.0",
40
+ "next": "^16.0.0",
41
41
  "typescript": "^5.5.4",
42
42
  "ws": "^8.18.0",
43
- "@platformatic/gateway": "3.16.0",
44
- "@platformatic/service": "3.16.0"
43
+ "@platformatic/service": "3.18.0",
44
+ "@platformatic/gateway": "3.18.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22.19.0"
48
48
  },
49
49
  "scripts": {
50
- "test": "node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/*.test.js test/**/*.test.js",
50
+ "test": "npm run test:main && npm run test:caching && npm run test:compatibility && npm run test:integration",
51
+ "test:main": "node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/*.test.js",
52
+ "test:caching": "node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/caching/*.test.js",
53
+ "test:compatibility": "node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/compatibility/*.test.js",
54
+ "test:integration": "node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/integration/*.test.js",
51
55
  "gen-schema": "node lib/schema.js > schema.json",
52
56
  "gen-types": "json2ts > config.d.ts < schema.json",
53
57
  "build": "npm run gen-schema && npm run gen-types",
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/next/3.16.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/next/3.18.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Next.js Config",
5
5
  "type": "object",
@@ -1142,8 +1142,7 @@
1142
1142
  {
1143
1143
  "type": "string"
1144
1144
  }
1145
- ],
1146
- "default": 4294967296
1145
+ ]
1147
1146
  },
1148
1147
  "maxYoungGeneration": {
1149
1148
  "anyOf": [
@@ -1915,6 +1914,9 @@
1915
1914
  "prefix": {
1916
1915
  "type": "string"
1917
1916
  },
1917
+ "cacheComponents": {
1918
+ "type": "boolean"
1919
+ },
1918
1920
  "maxTTL": {
1919
1921
  "default": 604800,
1920
1922
  "anyOf": [