@platformatic/next 2.19.0 → 2.20.0-alpha.1

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
@@ -93,4 +93,10 @@ export interface PlatformaticNextJsStackable {
93
93
  production?: string;
94
94
  };
95
95
  };
96
+ cache?: {
97
+ adapter: "redis" | "valkey";
98
+ url: string;
99
+ prefix?: string;
100
+ maxTTL?: number | string;
101
+ };
96
102
  }
package/index.js CHANGED
@@ -21,6 +21,8 @@ import { packageJson, schema } from './lib/schema.js'
21
21
 
22
22
  const supportedVersions = ['^14.0.0', '^15.0.0']
23
23
 
24
+ export * as cachingValkey from './lib/caching/valkey.js'
25
+
24
26
  export class NextStackable extends BaseStackable {
25
27
  #basePath
26
28
  #next
@@ -61,8 +63,10 @@ export class NextStackable extends BaseStackable {
61
63
  return this.stopCommand()
62
64
  }
63
65
 
66
+ globalThis.platformatic.events.emit('plt:next:close')
67
+
64
68
  if (this.isProduction) {
65
- return new Promise((resolve, reject) => {
69
+ await new Promise((resolve, reject) => {
66
70
  this.#server.close(error => {
67
71
  /* c8 ignore next 3 */
68
72
  if (error) {
@@ -72,6 +76,8 @@ export class NextStackable extends BaseStackable {
72
76
  resolve()
73
77
  })
74
78
  })
79
+
80
+ await this.childManager.close()
75
81
  } else {
76
82
  const exitPromise = once(this.#child, 'exit')
77
83
  await this.childManager.close()
@@ -102,7 +108,8 @@ export class NextStackable extends BaseStackable {
102
108
  /* c8 ignore next 5 */
103
109
  async getWatchConfig () {
104
110
  return {
105
- enabled: false
111
+ enabled: false,
112
+ path: this.root
106
113
  }
107
114
  }
108
115
 
@@ -137,6 +144,7 @@ export class NextStackable extends BaseStackable {
137
144
  this.childManager = new ChildManager({
138
145
  loader: loaderUrl,
139
146
  context: {
147
+ config: this.configManager.current,
140
148
  serviceId: this.serviceId,
141
149
  workerId: this.workerId,
142
150
  // Always use URL to avoid serialization problem in Windows
@@ -200,6 +208,7 @@ export class NextStackable extends BaseStackable {
200
208
  this.childManager = new ChildManager({
201
209
  loader: loaderUrl,
202
210
  context: {
211
+ config: this.configManager.current,
203
212
  serviceId: this.serviceId,
204
213
  workerId: this.workerId,
205
214
  // Always use URL to avoid serialization problem in Windows
@@ -220,6 +229,7 @@ export class NextStackable extends BaseStackable {
220
229
 
221
230
  async #startProductionNext () {
222
231
  try {
232
+ globalThis.platformatic.config = this.configManager.current
223
233
  await this.childManager.inject()
224
234
  const { nextStart } = await importFile(pathResolve(this.#next, './dist/cli/next-start.js'))
225
235
 
@@ -278,6 +288,10 @@ function transformConfig () {
278
288
  this.current.watch = { enabled: this.current.watch || false }
279
289
  }
280
290
 
291
+ if (this.current.cache?.adapter === 'redis') {
292
+ this.current.cache.adapter = 'valkey'
293
+ }
294
+
281
295
  basicTransformConfig.call(this)
282
296
  }
283
297
 
@@ -0,0 +1,229 @@
1
+ import { ensureLoggableError } from '@platformatic/utils'
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
+ export const MAX_BATCH_SIZE = 100
11
+
12
+ export const sections = {
13
+ values: 'values',
14
+ tags: 'tags'
15
+ }
16
+
17
+ const clients = new Map()
18
+
19
+ export function keyFor (prefix, subprefix, section, key) {
20
+ return [prefix, 'cache:next', subprefix, section, key ? Buffer.from(key).toString('base64url') : undefined]
21
+ .filter(c => c)
22
+ .join(':')
23
+ }
24
+
25
+ export function getConnection (url) {
26
+ let client = clients.get(url)
27
+
28
+ if (!client) {
29
+ client = new Redis(url, { enableAutoPipelining: true })
30
+ clients.set(url, client)
31
+
32
+ globalThis.platformatic.events.on('plt:next:close', () => {
33
+ client.disconnect(false)
34
+ })
35
+ }
36
+
37
+ return client
38
+ }
39
+
40
+ export class CacheHandler {
41
+ #config
42
+ #logger
43
+ #store
44
+ #subprefix
45
+ #maxTTL
46
+
47
+ constructor () {
48
+ this.#logger = this.#createLogger()
49
+ this.#config = globalThis.platformatic.config.cache
50
+ this.#store = getConnection(this.#config.url)
51
+ this.#maxTTL = this.#config.maxTTL
52
+ this.#subprefix = this.#getSubprefix()
53
+ }
54
+
55
+ async get (cacheKey) {
56
+ this.#logger.trace({ key: cacheKey }, 'get')
57
+
58
+ const key = this.#keyFor(cacheKey, sections.values)
59
+
60
+ let rawValue
61
+ try {
62
+ rawValue = await this.#store.get(key)
63
+
64
+ if (!rawValue) {
65
+ return
66
+ }
67
+ } catch (e) {
68
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot read cache value from Valkey')
69
+ throw new Error('Cannot read cache value from Valkey', { cause: e })
70
+ }
71
+
72
+ let value
73
+ try {
74
+ value = this.#deserialize(rawValue)
75
+ } catch (e) {
76
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot deserialize cache value from Valkey')
77
+
78
+ // Avoid useless reads the next time
79
+ // Note that since the value was unserializable, we don't know its tags and thus
80
+ // we cannot remove it from the tags sets. TTL will take care of them.
81
+ await this.#store.del(key)
82
+
83
+ throw new Error('Cannot deserialize cache value from Valkey', { cause: e })
84
+ }
85
+
86
+ if (this.#maxTTL < value.revalidate) {
87
+ try {
88
+ await this.#refreshKey(key, value)
89
+ } catch (e) {
90
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot refresh cache key expiration in Valkey')
91
+
92
+ // We don't throw here since we want to use the cached value anyway
93
+ }
94
+ }
95
+
96
+ return value
97
+ }
98
+
99
+ async set (cacheKey, value, { tags, revalidate }) {
100
+ this.#logger.trace({ key: cacheKey, value, tags, revalidate }, 'set')
101
+
102
+ try {
103
+ // Compute the parameters to save
104
+ const key = this.#keyFor(cacheKey, sections.values)
105
+ const data = this.#serialize({ value, tags, lastModified: Date.now(), revalidate, maxTTL: this.#maxTTL })
106
+ const expire = Math.min(revalidate, this.#maxTTL)
107
+
108
+ if (expire < 1) {
109
+ return
110
+ }
111
+
112
+ // Enqueue all the operations to perform in Valkey
113
+
114
+ const promises = []
115
+ promises.push(this.#store.set(key, data, 'EX', expire))
116
+
117
+ // As Next.js limits tags to 64, we don't need to manage batches here
118
+ if (Array.isArray(tags)) {
119
+ for (const tag of tags) {
120
+ const tagsKey = this.#keyFor(tag, sections.tags)
121
+ promises.push(this.#store.sadd(tagsKey, key))
122
+ promises.push(this.#store.expire(tagsKey, expire))
123
+ }
124
+ }
125
+
126
+ // Execute all the operations
127
+ await Promise.all(promises)
128
+ } catch (e) {
129
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot write cache value in Valkey')
130
+ throw new Error('Cannot write cache value in Valkey', { cause: e })
131
+ }
132
+ }
133
+
134
+ async revalidateTag (tags) {
135
+ this.#logger.trace({ tags }, 'revalidateTag')
136
+
137
+ if (typeof tags === 'string') {
138
+ tags = [tags]
139
+ }
140
+
141
+ try {
142
+ let promises = []
143
+
144
+ for (const tag of tags) {
145
+ const tagsKey = this.#keyFor(tag, sections.tags)
146
+
147
+ // For each key in the tag set, expire the key
148
+ for await (const keys of this.#store.sscanStream(tagsKey)) {
149
+ for (const key of keys) {
150
+ promises.push(this.#store.del(key))
151
+
152
+ // Batch full, execute it
153
+ if (promises.length >= MAX_BATCH_SIZE) {
154
+ await Promise.all(promises)
155
+ promises = []
156
+ }
157
+ }
158
+ }
159
+
160
+ // Delete the set, this will also take care of executing pending operation for a non full batch
161
+ promises.push(this.#store.del(tagsKey))
162
+ await Promise.all(promises)
163
+ promises = []
164
+ }
165
+ } catch (e) {
166
+ this.#logger.error({ err: ensureLoggableError(e) }, 'Cannot expire cache tags in Valkey')
167
+ throw new Error('Cannot expire cache tags in Valkey', { cause: e })
168
+ }
169
+ }
170
+
171
+ async #refreshKey (key, value) {
172
+ const life = Math.round((Date.now() - value.lastModified) / 1000)
173
+ const expire = Math.min(value.revalidate - life, this.#maxTTL)
174
+
175
+ if (expire < 1) {
176
+ return
177
+ }
178
+
179
+ const promises = []
180
+ promises.push(this.#store.expire(key, expire, 'gt'))
181
+
182
+ if (Array.isArray(value.tags)) {
183
+ for (const tag of value.tags) {
184
+ const tagsKey = this.#keyFor(tag, sections.tags)
185
+ promises.push(this.#store.expire(tagsKey, expire, 'gt'))
186
+ }
187
+ }
188
+
189
+ await Promise.all(promises)
190
+ }
191
+
192
+ #createLogger () {
193
+ const pinoOptions = {
194
+ level: globalThis.platformatic?.logLevel ?? 'info'
195
+ }
196
+
197
+ if (this.serviceId) {
198
+ pinoOptions.name = `cache:${this.serviceId}`
199
+ }
200
+
201
+ if (typeof globalThis.platformatic.workerId !== 'undefined') {
202
+ pinoOptions.base = { pid: process.pid, hostname: hostname(), worker: this.workerId }
203
+ }
204
+
205
+ return pino(pinoOptions)
206
+ }
207
+
208
+ #getSubprefix () {
209
+ const root = fileURLToPath(globalThis.platformatic.root)
210
+
211
+ return existsSync(resolve(root, '.next/BUILD_ID'))
212
+ ? (this.#subprefix = readFileSync(resolve(root, '.next/BUILD_ID'), 'utf-8').trim())
213
+ : 'development'
214
+ }
215
+
216
+ #keyFor (key, section) {
217
+ return keyFor(this.#config.prefix, this.#subprefix, section, key)
218
+ }
219
+
220
+ #serialize (data) {
221
+ return pack(data).toString('base64url')
222
+ }
223
+
224
+ #deserialize (data) {
225
+ return unpack(Buffer.from(data, 'base64url'))
226
+ }
227
+ }
228
+
229
+ export default CacheHandler
package/lib/loader.js CHANGED
@@ -11,10 +11,12 @@ import {
11
11
  variableDeclarator
12
12
  } from '@babel/types'
13
13
  import { readFile, realpath } from 'node:fs/promises'
14
+ import { sep } from 'node:path'
14
15
  import { fileURLToPath, pathToFileURL } from 'node:url'
15
16
 
16
17
  const originalId = '__pltOriginalNextConfig'
17
18
 
19
+ let config
18
20
  let candidates
19
21
  let basePath = ''
20
22
 
@@ -35,6 +37,11 @@ function parseSingleExpression (expr) {
35
37
  __pltOriginalNextConfig.basePath = basePath
36
38
  }
37
39
 
40
+ if(typeof __pltOriginalNextConfig.cacheHandler === 'undefined') {
41
+ __pltOriginalNextConfig.cacheHandler = $PATH
42
+ __pltOriginalNextConfig.cacheMaxMemorySize = 0
43
+ }
44
+
38
45
  // This is to send the configuraion when Next is executed in a child process (development)
39
46
  globalThis[Symbol.for('plt.children.itc')]?.notify('config', __pltOriginalNextConfig)
40
47
 
@@ -45,21 +52,35 @@ function parseSingleExpression (expr) {
45
52
  }
46
53
  */
47
54
  function createEvaluatorWrapperFunction (original) {
55
+ const cacheHandler = config?.cache?.adapter
56
+ ? fileURLToPath(new URL(`./caching/${config.cache.adapter}.js`, import.meta.url)).replaceAll(sep, '/')
57
+ : undefined
58
+
48
59
  return functionDeclaration(
49
60
  null,
50
61
  [restElement(identifier('args'))],
51
- blockStatement([
52
- variableDeclaration('let', [variableDeclarator(identifier(originalId), original)]),
53
- parseSingleExpression(
54
- `if (typeof ${originalId} === 'function') { ${originalId} = await ${originalId}(...args) }`
55
- ),
56
- parseSingleExpression(
57
- `if (typeof ${originalId}.basePath === 'undefined') { ${originalId}.basePath = "${basePath}" }`
58
- ),
59
- parseSingleExpression(`globalThis[Symbol.for('plt.children.itc')]?.notify('config', ${originalId})`),
60
- parseSingleExpression(`process.emit('plt:next:config', ${originalId})`),
61
- returnStatement(identifier(originalId))
62
- ]),
62
+ blockStatement(
63
+ [
64
+ variableDeclaration('let', [variableDeclarator(identifier(originalId), original)]),
65
+ parseSingleExpression(
66
+ `if (typeof ${originalId} === 'function') { ${originalId} = await ${originalId}(...args) }`
67
+ ),
68
+ parseSingleExpression(
69
+ `if (typeof ${originalId}.basePath === 'undefined') { ${originalId}.basePath = "${basePath}" }`
70
+ ),
71
+ cacheHandler
72
+ ? parseSingleExpression(`
73
+ if (typeof ${originalId}.cacheHandler === 'undefined') {
74
+ ${originalId}.cacheHandler = '${cacheHandler}'
75
+ ${originalId}.cacheMaxMemorySize = 0
76
+ }
77
+ `)
78
+ : undefined,
79
+ parseSingleExpression(`globalThis[Symbol.for('plt.children.itc')]?.notify('config', ${originalId})`),
80
+ parseSingleExpression(`process.emit('plt:next:config', ${originalId})`),
81
+ returnStatement(identifier(originalId))
82
+ ].filter(e => e)
83
+ ),
63
84
  false,
64
85
  true
65
86
  )
@@ -125,6 +146,7 @@ export async function initialize (data) {
125
146
  // Keep in sync with https://github.com/vercel/next.js/blob/main/packages/next/src/shared/lib/constants.ts
126
147
  candidates = ['next.config.js', 'next.config.mjs'].map(c => new URL(c, realRoot).toString())
127
148
  basePath = data.basePath ?? ''
149
+ config = data.config
128
150
  }
129
151
 
130
152
  export async function load (url, context, nextLoad) {
package/lib/schema.js CHANGED
@@ -4,6 +4,36 @@ import { readFileSync } from 'node:fs'
4
4
 
5
5
  export const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'))
6
6
 
7
+ export const cache = {
8
+ type: 'object',
9
+ properties: {
10
+ adapter: {
11
+ type: 'string',
12
+ enum: ['redis', 'valkey']
13
+ },
14
+ url: {
15
+ type: 'string'
16
+ },
17
+ prefix: {
18
+ type: 'string'
19
+ },
20
+ maxTTL: {
21
+ default: 86400 * 7, // One week
22
+ anyOf: [
23
+ {
24
+ type: 'number',
25
+ minimum: 0
26
+ },
27
+ {
28
+ type: 'string'
29
+ }
30
+ ]
31
+ }
32
+ },
33
+ required: ['adapter', 'url'],
34
+ additionalProperties: false
35
+ }
36
+
7
37
  export const schema = {
8
38
  $id: `https://schemas.platformatic.dev/@platformatic/next/${packageJson.version}.json`,
9
39
  $schema: 'http://json-schema.org/draft-07/schema#',
@@ -16,7 +46,8 @@ export const schema = {
16
46
  logger: utilsSchemaComponents.logger,
17
47
  server: utilsSchemaComponents.server,
18
48
  watch: schemaComponents.watch,
19
- application: schemaComponents.application
49
+ application: schemaComponents.application,
50
+ cache
20
51
  },
21
52
  additionalProperties: false
22
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/next",
3
- "version": "2.19.0",
3
+ "version": "2.20.0-alpha.1",
4
4
  "description": "Platformatic Next.js Stackable",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -20,10 +20,12 @@
20
20
  "@babel/traverse": "^7.25.3",
21
21
  "@babel/types": "^7.25.2",
22
22
  "amaro": "^0.2.0",
23
+ "iovalkey": "^0.2.1",
24
+ "msgpackr": "^1.11.2",
23
25
  "semver": "^7.6.3",
24
- "@platformatic/basic": "2.19.0",
25
- "@platformatic/config": "2.19.0",
26
- "@platformatic/utils": "2.19.0"
26
+ "@platformatic/basic": "2.20.0-alpha.1",
27
+ "@platformatic/config": "2.20.0-alpha.1",
28
+ "@platformatic/utils": "2.20.0-alpha.1"
27
29
  },
28
30
  "devDependencies": {
29
31
  "@fastify/reply-from": "^11.0.0",
@@ -39,8 +41,8 @@
39
41
  "react-dom": "^18.3.1",
40
42
  "typescript": "^5.5.4",
41
43
  "ws": "^8.18.0",
42
- "@platformatic/composer": "2.19.0",
43
- "@platformatic/service": "2.19.0"
44
+ "@platformatic/composer": "2.20.0-alpha.1",
45
+ "@platformatic/service": "2.20.0-alpha.1"
44
46
  },
45
47
  "scripts": {
46
48
  "test": "npm run lint && borp --concurrency=1 --no-timeout",
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/next/2.19.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/next/2.20.0-alpha.1.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Next.js Stackable",
5
5
  "type": "object",
@@ -296,6 +296,41 @@
296
296
  },
297
297
  "additionalProperties": false,
298
298
  "default": {}
299
+ },
300
+ "cache": {
301
+ "type": "object",
302
+ "properties": {
303
+ "adapter": {
304
+ "type": "string",
305
+ "enum": [
306
+ "redis",
307
+ "valkey"
308
+ ]
309
+ },
310
+ "url": {
311
+ "type": "string"
312
+ },
313
+ "prefix": {
314
+ "type": "string"
315
+ },
316
+ "maxTTL": {
317
+ "default": 604800,
318
+ "anyOf": [
319
+ {
320
+ "type": "number",
321
+ "minimum": 0
322
+ },
323
+ {
324
+ "type": "string"
325
+ }
326
+ ]
327
+ }
328
+ },
329
+ "required": [
330
+ "adapter",
331
+ "url"
332
+ ],
333
+ "additionalProperties": false
299
334
  }
300
335
  },
301
336
  "additionalProperties": false