@platformatic/next 3.71.1-alpha.0 → 3.73.0-alpha.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/eslint.config.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { globalIgnores } from 'eslint/config'
1
2
  import neostandard from 'neostandard'
2
3
 
3
- export default neostandard({
4
- ignores: ['**/.next', '**/dist', '**/tmp', 'test/fixtures/**'],
5
- })
4
+ export default [
5
+ ...neostandard(),
6
+ globalIgnores(['**/.next', '**/dist', '**/tmp', 'test/fixtures/**'])
7
+ ]
@@ -7,9 +7,11 @@ import {
7
7
  getRoot,
8
8
  getValkeyClients,
9
9
  getWorkerId,
10
+ isBuilding,
10
11
  updateGlobals
11
12
  } from '@platformatic/globals'
12
13
  import { existsSync, readFileSync } from 'node:fs'
14
+ import { readFile } from 'node:fs/promises'
13
15
  import { createRequire } from 'node:module'
14
16
  import { hostname } from 'node:os'
15
17
  import { resolve } from 'node:path'
@@ -112,12 +114,60 @@ export function createPlatformaticLogger () {
112
114
  return pino(pinoOptions)
113
115
  }
114
116
 
117
+ // The subprefix is the build ID. During a build it is null until Next.js writes the ID,
118
+ // so that nothing is stored under a subprefix the application will never read.
115
119
  export function getPlatformaticSubprefix () {
116
- const root = fileURLToPath(getRoot())
120
+ const buildIdPath = resolve(fileURLToPath(getRoot()), '.next/BUILD_ID')
117
121
 
118
- return existsSync(resolve(root, '.next/BUILD_ID'))
119
- ? readFileSync(resolve(root, '.next/BUILD_ID'), 'utf-8').trim()
120
- : 'development'
122
+ if (existsSync(buildIdPath)) {
123
+ return readFileSync(buildIdPath, 'utf-8').trim()
124
+ }
125
+
126
+ return isBuilding({ throwOnMissing: false }) ? null : 'development'
127
+ }
128
+
129
+ // Maps each "use cache" function ID to the file defining it, relative to the application.
130
+ export async function loadCacheFunctionsFiles () {
131
+ const nextDir = resolve(fileURLToPath(getRoot()), '.next')
132
+ const files = new Map()
133
+
134
+ const [manifest, serverFiles] = await Promise.all([
135
+ readJSON(resolve(nextDir, 'server/server-reference-manifest.json')),
136
+ readJSON(resolve(nextDir, 'required-server-files.json'))
137
+ ])
138
+
139
+ if (!manifest || !serverFiles) {
140
+ return files
141
+ }
142
+
143
+ // Turbopack reports files relative to the workspace root, which can contain the application
144
+ const appPrefix = serverFiles.relativeAppDir ? serverFiles.relativeAppDir + '/' : ''
145
+
146
+ for (const [id, { filename }] of Object.entries(manifest.node ?? {})) {
147
+ if (filename) {
148
+ files.set(id, filename.startsWith(appPrefix) ? filename.slice(appPrefix.length) : filename)
149
+ }
150
+ }
151
+
152
+ return files
153
+ }
154
+
155
+ // Returns null when the file does not exist, like in development.
156
+ async function readJSON (path) {
157
+ try {
158
+ return JSON.parse(await readFile(path, 'utf-8'))
159
+ } catch (e) {
160
+ if (e.code === 'ENOENT') {
161
+ return null
162
+ }
163
+ throw e
164
+ }
165
+ }
166
+
167
+ // The cache key is the React-encoded list of the function ID, its arguments and other parts.
168
+ // The ID, a 42 characters hexadecimal string, is the first one of its kind in the key.
169
+ export function getCacheFunctionId (cacheKey) {
170
+ return cacheKey.match(/"([0-9a-f]{42})"/)?.[1]
121
171
  }
122
172
 
123
173
  export function getPlatformaticMeta () {
@@ -11,7 +11,8 @@ export const CACHE_MISS_METRIC = {
11
11
 
12
12
  export const sections = {
13
13
  values: 'remote:components:values',
14
- tags: 'remote:components:tags'
14
+ tags: 'remote:components:tags',
15
+ revalidations: 'remote:components:revalidations'
15
16
  }
16
17
 
17
18
  export default new CacheHandler({
@@ -6,10 +6,12 @@ import {
6
6
  deserialize,
7
7
  ensureMsgpackr,
8
8
  ensureRedis,
9
+ getCacheFunctionId,
9
10
  getConnection,
10
11
  getPlatformaticMeta,
11
12
  getPlatformaticSubprefix,
12
13
  keyFor,
14
+ loadCacheFunctionsFiles,
13
15
  serialize
14
16
  } from './valkey-common.js'
15
17
 
@@ -22,10 +24,12 @@ export const CACHE_MISS_METRIC = {
22
24
  help: 'Next.js Components Cache (Valkey) Miss Count'
23
25
  }
24
26
  export const MAX_BATCH_SIZE = 100
27
+ export const IMPLICIT_TAG_PREFIX = '_N_T_'
25
28
 
26
29
  export const sections = {
27
30
  values: 'components:values',
28
- tags: 'components:tags'
31
+ tags: 'components:tags',
32
+ revalidations: 'components:revalidations'
29
33
  }
30
34
 
31
35
  export class CacheHandler {
@@ -35,6 +39,7 @@ export class CacheHandler {
35
39
  #prefix
36
40
  #subprefix
37
41
  #meta
42
+ #cacheFunctionsFiles
38
43
  #maxTTL
39
44
  #sections
40
45
  #cacheHitMetric
@@ -82,6 +87,10 @@ export class CacheHandler {
82
87
  async get (cacheKey, _, isRedisKey) {
83
88
  this.#logger.trace({ key: cacheKey }, 'cache get')
84
89
 
90
+ if (!isRedisKey && !this.#hasSubprefix()) {
91
+ return
92
+ }
93
+
85
94
  const key = isRedisKey ? cacheKey : this.#keyFor(cacheKey, this.#sections.values)
86
95
 
87
96
  let rawValue
@@ -143,8 +152,6 @@ export class CacheHandler {
143
152
 
144
153
  this.#logger.trace({ key: cacheKey, value, tags, revalidate }, 'cache set')
145
154
 
146
- const key = isRedisKey ? cacheKey : this.#keyFor(cacheKey, this.#sections.values)
147
-
148
155
  try {
149
156
  // Gather the value
150
157
  const chunks = []
@@ -152,10 +159,24 @@ export class CacheHandler {
152
159
  chunks.push(chunk)
153
160
  }
154
161
 
162
+ if (!isRedisKey && !this.#hasSubprefix()) {
163
+ return
164
+ }
165
+
166
+ const key = isRedisKey ? cacheKey : this.#keyFor(cacheKey, this.#sections.values)
167
+
155
168
  // Compute the parameters to save
169
+
170
+ const file = await this.#fileFor(cacheKey)
171
+
172
+ let meta = this.#meta
173
+ if (file) {
174
+ meta = { ...meta, file }
175
+ }
176
+
156
177
  const toSerialize = serialize({
157
178
  maxTTL: this.#maxTTL,
158
- meta: this.#meta,
179
+ meta,
159
180
  value: Buffer.concat(chunks),
160
181
  ...data
161
182
  })
@@ -193,12 +214,39 @@ export class CacheHandler {
193
214
  // This function is not necessary as we don't have a local state to synchronize.
194
215
  async refreshTags () {}
195
216
 
196
- getExpiration (_tags) {
197
- // Delegates the check to get method, only when appropriate.
198
- return Number.POSITIVE_INFINITY
217
+ // Next.js calls this with the implicit tags of the current request (the ones revalidatePath()
218
+ // targets) and discards any entry created before the returned timestamp.
219
+ async getExpiration (tags) {
220
+ if (!tags?.length || !this.#hasSubprefix()) {
221
+ return 0
222
+ }
223
+
224
+ try {
225
+ const keys = tags.map(tag => this.#keyFor(tag, this.#sections.revalidations))
226
+ const timestamps = await this.#store.mget(keys)
227
+
228
+ let expiration = 0
229
+ for (const timestamp of timestamps) {
230
+ if (timestamp) {
231
+ expiration = Math.max(expiration, Number(timestamp))
232
+ }
233
+ }
234
+
235
+ return expiration
236
+ } catch (e) {
237
+ this.#logger.error(
238
+ { err: ensureLoggableError(e) },
239
+ 'Cannot read cache tags expiration from Valkey'
240
+ )
241
+ return 0
242
+ }
199
243
  }
200
244
 
201
245
  async updateTags (tags) {
246
+ if (!this.#hasSubprefix()) {
247
+ return
248
+ }
249
+
202
250
  if (typeof tags === 'string') {
203
251
  tags = [tags]
204
252
  }
@@ -207,6 +255,17 @@ export class CacheHandler {
207
255
  const toDelete = new Set()
208
256
 
209
257
  for (const tag of tags) {
258
+ // Implicit tags are never attached to entries, so there is nothing to delete.
259
+ // Record when they were revalidated instead, getExpiration() will check it.
260
+ if (tag.startsWith(IMPLICIT_TAG_PREFIX)) {
261
+ if (this.#maxTTL >= 1) {
262
+ const revalidationKey = this.#keyFor(tag, this.#sections.revalidations)
263
+ await this.#store.set(revalidationKey, Date.now(), 'EX', this.#maxTTL)
264
+ }
265
+
266
+ continue
267
+ }
268
+
210
269
  const tagsKey = this.#keyFor(tag, this.#sections.tags)
211
270
 
212
271
  // For each key in the tag set, expire the key
@@ -256,6 +315,26 @@ export class CacheHandler {
256
315
  await Promise.all(promises)
257
316
  }
258
317
 
318
+ // The manifest is read once, on the first write
319
+ async #fileFor (cacheKey) {
320
+ this.#cacheFunctionsFiles ??= loadCacheFunctionsFiles().catch(e => {
321
+ this.#logger.error(
322
+ { err: ensureLoggableError(e) },
323
+ 'Cannot read the server references manifest'
324
+ )
325
+ return new Map()
326
+ })
327
+
328
+ const files = await this.#cacheFunctionsFiles
329
+ return files.get(getCacheFunctionId(cacheKey))
330
+ }
331
+
332
+ // During a build, the subprefix is only known once Next.js writes the build ID
333
+ #hasSubprefix () {
334
+ this.#subprefix ??= getPlatformaticSubprefix()
335
+ return this.#subprefix !== null
336
+ }
337
+
259
338
  #keyFor (key, section) {
260
339
  return keyFor(this.#prefix, this.#subprefix, section, key)
261
340
  }
@@ -88,6 +88,10 @@ export class CacheHandler {
88
88
  async get (cacheKey, _, isRedisKey) {
89
89
  this.#logger.trace({ key: cacheKey }, 'cache get')
90
90
 
91
+ if (!isRedisKey && !this.#hasSubprefix()) {
92
+ return
93
+ }
94
+
91
95
  const key = this.#standalone || isRedisKey ? cacheKey : this.#keyFor(cacheKey, sections.values)
92
96
 
93
97
  let rawValue
@@ -134,6 +138,10 @@ export class CacheHandler {
134
138
  }
135
139
 
136
140
  async set (cacheKey, value, ctx, isRedisKey) {
141
+ if (!isRedisKey && !this.#hasSubprefix()) {
142
+ return
143
+ }
144
+
137
145
  const tags = this.#getTags(ctx.tags, value)
138
146
  const revalidate = ctx.revalidate ?? ctx.cacheControl?.revalidate ?? value.revalidate ?? 0
139
147
 
@@ -181,6 +189,10 @@ export class CacheHandler {
181
189
  }
182
190
 
183
191
  async remove (cacheKey, isRedisKey) {
192
+ if (!isRedisKey && !this.#hasSubprefix()) {
193
+ return
194
+ }
195
+
184
196
  this.#logger.trace({ key: cacheKey }, 'cache remove')
185
197
 
186
198
  const key = this.#standalone || isRedisKey ? cacheKey : this.#keyFor(cacheKey, sections.values)
@@ -231,6 +243,10 @@ export class CacheHandler {
231
243
  }
232
244
 
233
245
  async revalidateTag (tags) {
246
+ if (!this.#hasSubprefix()) {
247
+ return
248
+ }
249
+
234
250
  this.#logger.trace({ tags }, 'cache revalidateTag')
235
251
 
236
252
  if (typeof tags === 'string') {
@@ -309,6 +325,12 @@ export class CacheHandler {
309
325
  await Promise.all(promises)
310
326
  }
311
327
 
328
+ // During a build, the subprefix is only known once Next.js writes the build ID
329
+ #hasSubprefix () {
330
+ this.#subprefix ??= getPlatformaticSubprefix()
331
+ return this.#subprefix !== null
332
+ }
333
+
312
334
  #keyFor (key, section) {
313
335
  return keyFor(this.#prefix, this.#subprefix, section, key)
314
336
  }
package/lib/capability.js CHANGED
@@ -21,6 +21,8 @@ import { parseStandaloneNextConfig, requireStandaloneStartServer, resolveStandal
21
21
 
22
22
  export const supportedVersions = ['^14.0.0', '^15.0.0', '^16.0.0']
23
23
 
24
+ const loaderOptions = { registerHooks: true }
25
+
24
26
  export function getCacheHandlerPath (name) {
25
27
  return fileURLToPath(new URL(`./caching/${name}.js`, import.meta.url)).replaceAll(sep, '/')
26
28
  }
@@ -166,7 +168,7 @@ export class NextCapability extends BaseCapability {
166
168
  command = ['node', resolvePath(this.#next, './dist/bin/next'), 'build', this.root]
167
169
  }
168
170
 
169
- await this.buildWithCommand(command, this.#basePath, { loader, scripts: this.#getChildManagerScripts() })
171
+ await this.buildWithCommand(command, this.#basePath, { loader, loaderOptions, scripts: this.#getChildManagerScripts() })
170
172
  await this.#fixRequiredServerFiles()
171
173
  }
172
174
 
@@ -208,7 +210,7 @@ export class NextCapability extends BaseCapability {
208
210
  this.#basePath = config.application?.basePath ? cleanBasePath(config.application?.basePath) : ''
209
211
 
210
212
  if (command) {
211
- return this.startWithCommand(command, loaderUrl, this.#getChildManagerScripts())
213
+ return this.startWithCommand(command, loaderUrl, this.#getChildManagerScripts(), { loaderOptions })
212
214
  }
213
215
 
214
216
  const { hostname, port } = this.serverConfig ?? {}
@@ -321,7 +323,7 @@ export class NextCapability extends BaseCapability {
321
323
  if (this.#nextVersion.major < 15 || (this.#nextVersion.major <= 15 && this.#nextVersion.minor < 1)) {
322
324
  childManagerScripts.push(new URL('./create-context-patch.js', import.meta.url))
323
325
  }
324
- return this.startWithCommand(command, loaderUrl, childManagerScripts)
326
+ return this.startWithCommand(command, loaderUrl, childManagerScripts, { loaderOptions })
325
327
  }
326
328
 
327
329
  this.childManager = this.#createChildManager(
@@ -512,6 +514,7 @@ export class NextCapability extends BaseCapability {
512
514
  #createChildManager (loader, context, scripts) {
513
515
  const childManager = new ChildManager({
514
516
  loader,
517
+ loaderOptions,
515
518
  context,
516
519
  scripts
517
520
  })
package/lib/loader.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  variableDeclaration,
11
11
  variableDeclarator
12
12
  } from '@babel/types'
13
+ import { readFileSync, realpathSync } from 'node:fs'
13
14
  import { readFile, realpath } from 'node:fs/promises'
14
15
  import { fileURLToPath, pathToFileURL } from 'node:url'
15
16
 
@@ -86,8 +87,8 @@ export function transformESM (source) {
86
87
  return generate.default(ast).code
87
88
  }
88
89
 
89
- export async function initialize (data) {
90
- const realRoot = pathToFileURL(await realpath(fileURLToPath(data.root)))
90
+ function initializeCandidates (data, realRootPath) {
91
+ const realRoot = pathToFileURL(realRootPath)
91
92
 
92
93
  if (!realRoot.pathname.endsWith('/')) {
93
94
  realRoot.pathname += '/'
@@ -100,6 +101,38 @@ export async function initialize (data) {
100
101
  candidates = candidates.map(c => new URL(c, realRoot).toString())
101
102
  }
102
103
 
104
+ export function initializeSync (data) {
105
+ initializeCandidates(data, realpathSync(fileURLToPath(data.root)))
106
+ }
107
+
108
+ export async function initialize (data) {
109
+ initializeCandidates(data, await realpath(fileURLToPath(data.root)))
110
+ }
111
+
112
+ export function loadSync (url, context, nextLoad) {
113
+ // Load the original file
114
+ const result = nextLoad(url, context)
115
+
116
+ if (!url.startsWith('file:')) {
117
+ return result
118
+ }
119
+
120
+ url = pathToFileURL(realpathSync(fileURLToPath(url))).toString()
121
+
122
+ if (!candidates.includes(url)) {
123
+ return result
124
+ }
125
+
126
+ if (result.format === 'commonjs' || result.format == null) {
127
+ result.format ??= 'commonjs'
128
+ result.source = transformCJS(result.source ?? readFileSync(new URL(result.responseURL ?? url), 'utf-8'))
129
+ } else {
130
+ result.source = transformESM(result.source)
131
+ }
132
+
133
+ return result
134
+ }
135
+
103
136
  export async function load (url, context, nextLoad) {
104
137
  // Load the original file
105
138
  const result = await nextLoad(url, context)
@@ -114,8 +147,8 @@ export async function load (url, context, nextLoad) {
114
147
  return result
115
148
  }
116
149
 
117
- if (result.format === 'commonjs') {
118
- await readFile(new URL(result.responseURL ?? url), 'utf-8')
150
+ if (result.format === 'commonjs' || result.format == null) {
151
+ result.format ??= 'commonjs'
119
152
  result.source = transformCJS(result.source ?? (await readFile(new URL(result.responseURL ?? url), 'utf-8')))
120
153
  } else {
121
154
  result.source = transformESM(result.source)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/next",
3
- "version": "3.71.1-alpha.0",
3
+ "version": "3.73.0-alpha.0",
4
4
  "description": "Platformatic Next.js Capability",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -19,33 +19,33 @@
19
19
  "@babel/parser": "^7.25.3",
20
20
  "@babel/traverse": "^7.25.3",
21
21
  "@babel/types": "^7.25.2",
22
+ "@platformatic/basic": "3.73.0-alpha.0",
23
+ "@platformatic/foundation": "3.73.0-alpha.0",
24
+ "@platformatic/globals": "3.73.0-alpha.0",
22
25
  "@platformatic/image-optimizer": "^0.2.1",
23
26
  "amaro": "^1.0.0",
24
- "iovalkey": "^0.3.0",
27
+ "iovalkey": "^0.4.0",
25
28
  "light-my-request": "^6.0.0",
26
29
  "msgpackr": "^1.11.2",
27
- "semver": "^7.6.3",
28
- "@platformatic/basic": "3.71.1-alpha.0",
29
- "@platformatic/foundation": "3.71.1-alpha.0",
30
- "@platformatic/globals": "3.71.1-alpha.0"
30
+ "semver": "^7.6.3"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@fastify/reply-from": "^12.0.0",
34
+ "@platformatic/gateway": "3.73.0-alpha.0",
35
+ "@platformatic/service": "3.73.0-alpha.0",
34
36
  "@types/node": "^22.5.0",
35
37
  "@types/react": "^19.1.11",
36
38
  "@types/react-dom": "^19.1.7",
37
- "cleaner-spec-reporter": "^0.5.0",
39
+ "cleaner-spec-reporter": "^2.0.0",
38
40
  "eslint": "9",
39
41
  "tstyche": "^7.0.0",
40
42
  "execa": "^9.5.1",
41
43
  "fastify": "^5.7.3",
42
44
  "json-schema-to-typescript": "^15.0.1",
43
- "neostandard": "^0.12.0",
45
+ "neostandard": "^0.14.0",
44
46
  "next": "^16.1.0",
45
47
  "typescript": "~6.0.3",
46
- "ws": "^8.18.0",
47
- "@platformatic/service": "3.71.1-alpha.0",
48
- "@platformatic/gateway": "3.71.1-alpha.0"
48
+ "ws": "^8.18.0"
49
49
  },
50
50
  "engines": {
51
51
  "node": ">=22.19.0"
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/next/3.71.1-alpha.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/next/3.73.0-alpha.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Next.js Config",
5
5
  "type": "object",