@platformatic/runtime 2.6.1 → 2.7.1-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
@@ -5,12 +5,13 @@
5
5
  * and run json-schema-to-typescript to regenerate this file.
6
6
  */
7
7
 
8
- export type HttpsSchemasPlatformaticDevPlatformaticRuntime261Json = {
8
+ export type HttpsSchemasPlatformaticDevPlatformaticRuntime271Alpha1Json = {
9
9
  [k: string]: unknown;
10
10
  } & {
11
11
  $schema?: string;
12
12
  preload?: string;
13
13
  entrypoint?: string;
14
+ basePath?: string;
14
15
  autoload?: {
15
16
  path: string;
16
17
  exclude?: string[];
@@ -109,6 +110,17 @@ export type HttpsSchemasPlatformaticDevPlatformaticRuntime261Json = {
109
110
  };
110
111
  [k: string]: unknown;
111
112
  };
113
+ httpCache?:
114
+ | boolean
115
+ | {
116
+ store?: string;
117
+ /**
118
+ * @minItems 1
119
+ */
120
+ methods?: [string, ...string[]];
121
+ cacheTagsHeader?: string;
122
+ [k: string]: unknown;
123
+ };
112
124
  watch?: boolean | string;
113
125
  managementApi?:
114
126
  | boolean
@@ -193,6 +193,15 @@ async function managementApiPlugin (app, opts) {
193
193
  const logFileStream = await runtime.getLogFileStream(logId, runtimePID)
194
194
  return logFileStream
195
195
  })
196
+
197
+ app.get('/http-cache/requests', async () => {
198
+ return runtime.getCachedHttpRequests()
199
+ })
200
+
201
+ app.post('/http-cache/invalidate', async (req) => {
202
+ const { origin, routes, tags } = req.body
203
+ await runtime.invalidateHttpCache({ origin, routes, tags })
204
+ })
196
205
  }
197
206
 
198
207
  async function startManagementApi (runtime, configManager) {
package/lib/runtime.js CHANGED
@@ -16,6 +16,7 @@ const errors = require('./errors')
16
16
  const { createLogger } = require('./logger')
17
17
  const { startManagementApi } = require('./management-api')
18
18
  const { startPrometheusServer } = require('./prom-server')
19
+ const { createSharedStore } = require('./shared-http-cache')
19
20
  const { getRuntimeTmpDir } = require('./utils')
20
21
  const { sendViaITC, waitEventFromITC } = require('./worker/itc')
21
22
  const { kId, kITC, kConfig } = require('./worker/symbols')
@@ -53,6 +54,7 @@ class Runtime extends EventEmitter {
53
54
  #bootstrapAttempts
54
55
  #inspectors
55
56
  #inspectorServer
57
+ #sharedHttpCache
56
58
 
57
59
  constructor (configManager, runtimeLogsDir, env) {
58
60
  super()
@@ -72,6 +74,7 @@ class Runtime extends EventEmitter {
72
74
  this.#restartPromises = new Map()
73
75
  this.#bootstrapAttempts = new Map()
74
76
  this.#inspectors = []
77
+ this.#sharedHttpCache = null
75
78
  }
76
79
 
77
80
  async init () {
@@ -116,6 +119,11 @@ class Runtime extends EventEmitter {
116
119
  throw e
117
120
  }
118
121
 
122
+ this.#sharedHttpCache = createSharedStore(
123
+ this.#configManager.dirname,
124
+ config.httpCache
125
+ )
126
+
119
127
  this.#updateStatus('init')
120
128
  }
121
129
 
@@ -755,6 +763,24 @@ class Runtime extends EventEmitter {
755
763
  return createReadStream(filePath)
756
764
  }
757
765
 
766
+ async getCachedHttpRequests () {
767
+ return this.#sharedHttpCache.getRoutes()
768
+ }
769
+
770
+ async invalidateHttpCache (options = {}) {
771
+ const { origin, routes, tags } = options
772
+
773
+ if (!this.#sharedHttpCache) return
774
+
775
+ if (routes && routes.length > 0) {
776
+ await this.#sharedHttpCache.deleteRoutes(routes)
777
+ }
778
+
779
+ if (tags && tags.length > 0) {
780
+ await this.#sharedHttpCache.deleteByCacheTags(origin, tags)
781
+ }
782
+ }
783
+
758
784
  #updateStatus (status) {
759
785
  this.#status = status
760
786
  this.emit(status)
@@ -865,7 +891,18 @@ class Runtime extends EventEmitter {
865
891
  port: service,
866
892
  handlers: {
867
893
  getServiceMeta: this.getServiceMeta.bind(this),
868
- getServices: this.getServices.bind(this)
894
+ getServices: this.getServices.bind(this),
895
+ isHttpCacheFull: () => this.#sharedHttpCache.isFull(),
896
+ getHttpCacheValue: opts => this.#sharedHttpCache.getValue(opts.request),
897
+ setHttpCacheValue: opts => this.#sharedHttpCache.setValue(
898
+ opts.request,
899
+ opts.response,
900
+ opts.payload
901
+ ),
902
+ deleteHttpCacheValue: opts => this.#sharedHttpCache.deleteByOrigin(
903
+ opts.origin
904
+ ),
905
+ invalidateHttpCache: opts => this.invalidateHttpCache(opts),
869
906
  }
870
907
  })
871
908
  service[kITC].listen()
package/lib/schema.js CHANGED
@@ -49,6 +49,9 @@ const platformaticRuntimeSchema = {
49
49
  entrypoint: {
50
50
  type: 'string'
51
51
  },
52
+ basePath: {
53
+ type: 'string'
54
+ },
52
55
  autoload: {
53
56
  type: 'object',
54
57
  additionalProperties: false,
@@ -142,6 +145,32 @@ const platformaticRuntimeSchema = {
142
145
  }
143
146
  }
144
147
  },
148
+ httpCache: {
149
+ oneOf: [
150
+ {
151
+ type: 'boolean'
152
+ },
153
+ {
154
+ type: 'object',
155
+ properties: {
156
+ store: {
157
+ type: 'string'
158
+ },
159
+ methods: {
160
+ type: 'array',
161
+ items: {
162
+ type: 'string'
163
+ },
164
+ default: ['GET', 'HEAD'],
165
+ minItems: 1
166
+ },
167
+ cacheTagsHeader: {
168
+ type: 'string'
169
+ }
170
+ }
171
+ }
172
+ ]
173
+ },
145
174
  watch: {
146
175
  anyOf: [
147
176
  {
@@ -0,0 +1,45 @@
1
+ 'use strict'
2
+
3
+ const { join } = require('node:path')
4
+ const { createRequire } = require('node:module')
5
+ const MemoryCacheStore = require('@platformatic/undici-cache-memory')
6
+
7
+ function createSharedStore (projectDir, httpCacheConfig = {}) {
8
+ const runtimeRequire = createRequire(join(projectDir, 'file'))
9
+
10
+ const { store, ...storeConfig } = httpCacheConfig
11
+ const CacheStore = store ? runtimeRequire(store) : MemoryCacheStore
12
+
13
+ class SharedCacheStore extends CacheStore {
14
+ async getValue (req) {
15
+ const readStream = await this.createReadStream(req)
16
+ if (!readStream) return null
17
+
18
+ let payload = ''
19
+ for await (const chunk of readStream) {
20
+ payload += chunk
21
+ }
22
+
23
+ const response = this.#sanitizeResponse(readStream.value)
24
+ return { response, payload }
25
+ }
26
+
27
+ setValue (req, opts, data) {
28
+ const writeStream = this.createWriteStream(req, opts)
29
+ writeStream.write(data)
30
+ writeStream.end()
31
+ return null
32
+ }
33
+
34
+ #sanitizeResponse (response) {
35
+ return {
36
+ ...response,
37
+ rawHeaders: response.rawHeaders.map(header => header.toString())
38
+ }
39
+ }
40
+ }
41
+
42
+ return new SharedCacheStore(storeConfig)
43
+ }
44
+
45
+ module.exports = { createSharedStore }
@@ -0,0 +1,83 @@
1
+ 'use strict'
2
+
3
+ const { Readable, Writable } = require('node:stream')
4
+ const { kITC } = require('./symbols')
5
+
6
+ class RemoteCacheStore {
7
+ get isFull () {
8
+ // TODO: make an itc call to the shared cache when interceptor supports
9
+ // an async isFull method
10
+ return false
11
+ }
12
+
13
+ async createReadStream (request) {
14
+ const itc = globalThis[kITC]
15
+ if (!itc) return
16
+
17
+ const cachedValue = await itc.send('getHttpCacheValue', {
18
+ request: this.#sanitizeRequest(request)
19
+ })
20
+ if (!cachedValue) return
21
+
22
+ const readable = new Readable({
23
+ read () {}
24
+ })
25
+
26
+ Object.defineProperty(readable, 'value', {
27
+ get () { return cachedValue.response }
28
+ })
29
+
30
+ readable.push(cachedValue.payload)
31
+ readable.push(null)
32
+
33
+ return readable
34
+ }
35
+
36
+ createWriteStream (request, response) {
37
+ const itc = globalThis[kITC]
38
+ if (!itc) throw new Error('Cannot write to cache without an ITC instance')
39
+
40
+ let payload = ''
41
+
42
+ request = this.#sanitizeRequest(request)
43
+ response = this.#sanitizeResponse(response)
44
+
45
+ return new Writable({
46
+ write (chunk, encoding, callback) {
47
+ payload += chunk
48
+ callback()
49
+ },
50
+ final (callback) {
51
+ itc.send('setHttpCacheValue', { request, response, payload })
52
+ .then(() => callback())
53
+ .catch((err) => callback(err))
54
+ }
55
+ })
56
+ }
57
+
58
+ deleteByOrigin (origin) {
59
+ const itc = globalThis[kITC]
60
+ if (!itc) throw new Error('Cannot delete from cache without an ITC instance')
61
+
62
+ itc.send('deleteHttpCacheValue', { origin })
63
+ // TODO: return a Promise
64
+ }
65
+
66
+ #sanitizeRequest (request) {
67
+ return {
68
+ origin: request.origin,
69
+ method: request.method,
70
+ path: request.path,
71
+ headers: request.headers
72
+ }
73
+ }
74
+
75
+ #sanitizeResponse (response) {
76
+ return {
77
+ ...response,
78
+ rawHeaders: response.rawHeaders.map(header => header.toString())
79
+ }
80
+ }
81
+ }
82
+
83
+ module.exports = RemoteCacheStore
@@ -5,11 +5,15 @@ const { join } = require('node:path')
5
5
  const { parentPort, workerData, threadId } = require('node:worker_threads')
6
6
  const { pathToFileURL } = require('node:url')
7
7
  const inspector = require('node:inspector')
8
+ const diagnosticChannel = require('node:diagnostics_channel')
9
+ const { ServerResponse } = require('node:http')
8
10
 
9
11
  const pino = require('pino')
10
- const { fetch, setGlobalDispatcher, Agent } = require('undici')
12
+ const { fetch, setGlobalDispatcher, getGlobalDispatcher, Agent } = require('undici')
11
13
  const { wire } = require('undici-thread-interceptor')
14
+ const undici = require('undici')
12
15
 
16
+ const RemoteCacheStore = require('./http-cache')
13
17
  const { PlatformaticApp } = require('./app')
14
18
  const { setupITC } = require('./itc')
15
19
  const loadInterceptors = require('./interceptors')
@@ -78,10 +82,34 @@ async function main () {
78
82
  }
79
83
  }
80
84
 
81
- const globalDispatcher = new Agent({
82
- ...config.undici,
83
- interceptors
84
- }).compose(composedInterceptors)
85
+ const dispatcherOpts = { ...config.undici }
86
+
87
+ if (Object.keys(interceptors).length > 0) {
88
+ const clientInterceptors = []
89
+ const poolInterceptors = []
90
+
91
+ if (interceptors.Agent) {
92
+ clientInterceptors.push(...interceptors.Agent)
93
+ poolInterceptors.push(...interceptors.Agent)
94
+ }
95
+
96
+ if (interceptors.Pool) {
97
+ poolInterceptors.push(...interceptors.Pool)
98
+ }
99
+
100
+ if (interceptors.Client) {
101
+ clientInterceptors.push(...interceptors.Client)
102
+ }
103
+
104
+ dispatcherOpts.factory = (origin, opts) => {
105
+ return opts && opts.connections === 1
106
+ ? new undici.Client(origin, opts).compose(clientInterceptors)
107
+ : new undici.Pool(origin, opts).compose(poolInterceptors)
108
+ }
109
+ }
110
+
111
+ const globalDispatcher = new Agent(dispatcherOpts)
112
+ .compose(composedInterceptors)
85
113
 
86
114
  setGlobalDispatcher(globalDispatcher)
87
115
 
@@ -90,6 +118,15 @@ async function main () {
90
118
  // TODO: make this configurable
91
119
  const threadDispatcher = wire({ port: parentPort, useNetwork: service.useHttp, timeout: 5 * 60 * 1000 })
92
120
 
121
+ if (config.httpCache) {
122
+ setGlobalDispatcher(
123
+ getGlobalDispatcher().compose(undici.interceptors.cache({
124
+ store: new RemoteCacheStore(),
125
+ methods: config.httpCache.methods ?? ['GET', 'HEAD']
126
+ }))
127
+ )
128
+ }
129
+
93
130
  // If the service is an entrypoint and runtime server config is defined, use it.
94
131
  let serverConfig = null
95
132
  if (config.server && service.entrypoint) {
@@ -141,6 +178,13 @@ async function main () {
141
178
 
142
179
  await app.init()
143
180
 
181
+ if (service.entrypoint && config.basePath) {
182
+ const meta = await app.stackable.getMeta()
183
+ if (!meta.wantsAbsoluteUrls) {
184
+ stripBasePath(config.basePath)
185
+ }
186
+ }
187
+
144
188
  // Setup interaction with parent port
145
189
  const itc = setupITC(app, service, threadDispatcher)
146
190
 
@@ -152,5 +196,55 @@ async function main () {
152
196
  globalThis[kITC] = itc
153
197
  }
154
198
 
199
+ function stripBasePath (basePath) {
200
+ const kBasePath = Symbol('kBasePath')
201
+
202
+ diagnosticChannel.subscribe('http.server.request.start', ({ request, response }) => {
203
+ if (request.url.startsWith(basePath)) {
204
+ request.url = request.url.slice(basePath.length)
205
+
206
+ if (request.url.charAt(0) !== '/') {
207
+ request.url = '/' + request.url
208
+ }
209
+
210
+ response[kBasePath] = basePath
211
+ }
212
+ })
213
+
214
+ const originWriteHead = ServerResponse.prototype.writeHead
215
+ const originSetHeader = ServerResponse.prototype.setHeader
216
+
217
+ ServerResponse.prototype.writeHead = function (statusCode, statusMessage, headers) {
218
+ if (this[kBasePath] !== undefined) {
219
+ if (headers === undefined && typeof statusMessage === 'object') {
220
+ headers = statusMessage
221
+ statusMessage = undefined
222
+ }
223
+
224
+ if (headers) {
225
+ for (const key in headers) {
226
+ if (
227
+ key.toLowerCase() === 'location' &&
228
+ !headers[key].startsWith(basePath)
229
+ ) {
230
+ headers[key] = basePath + headers[key]
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ return originWriteHead.call(this, statusCode, statusMessage, headers)
237
+ }
238
+
239
+ ServerResponse.prototype.setHeader = function (name, value) {
240
+ if (this[kBasePath]) {
241
+ if (name.toLowerCase() === 'location' && !value.startsWith(basePath)) {
242
+ value = basePath + value
243
+ }
244
+ }
245
+ originSetHeader.call(this, name, value)
246
+ }
247
+ }
248
+
155
249
  // No need to catch this because there is the unhadledRejection handler on top.
156
250
  main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "2.6.1",
3
+ "version": "2.7.1-alpha.1",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -35,17 +35,19 @@
35
35
  "typescript": "^5.5.4",
36
36
  "undici-oidc-interceptor": "^0.5.0",
37
37
  "why-is-node-running": "^2.2.2",
38
- "@platformatic/composer": "2.6.1",
39
- "@platformatic/db": "2.6.1",
40
- "@platformatic/service": "2.6.1",
41
- "@platformatic/sql-mapper": "2.6.1",
42
- "@platformatic/sql-graphql": "2.6.1"
38
+ "@platformatic/composer": "2.7.1-alpha.1",
39
+ "@platformatic/service": "2.7.1-alpha.1",
40
+ "@platformatic/db": "2.7.1-alpha.1",
41
+ "@platformatic/node": "2.7.1-alpha.1",
42
+ "@platformatic/sql-graphql": "2.7.1-alpha.1",
43
+ "@platformatic/sql-mapper": "2.7.1-alpha.1"
43
44
  },
44
45
  "dependencies": {
45
46
  "@fastify/error": "^4.0.0",
46
47
  "@fastify/websocket": "^11.0.0",
47
48
  "@hapi/topo": "^6.0.2",
48
49
  "@platformatic/http-metrics": "^0.2.1",
50
+ "@platformatic/undici-cache-memory": "^0.3.0",
49
51
  "@watchable/unpromise": "^1.0.2",
50
52
  "boring-name-generator": "^1.0.3",
51
53
  "change-case-all": "^2.1.0",
@@ -67,16 +69,16 @@
67
69
  "semgrator": "^0.3.0",
68
70
  "tail-file-stream": "^0.2.0",
69
71
  "thread-cpu-usage": "^0.2.0",
70
- "undici": "^6.9.0",
72
+ "undici": "7.0.0-alpha.3",
71
73
  "undici-thread-interceptor": "^0.7.0",
72
74
  "ws": "^8.16.0",
73
- "@platformatic/basic": "2.6.1",
74
- "@platformatic/config": "2.6.1",
75
- "@platformatic/generators": "2.6.1",
76
- "@platformatic/itc": "2.6.1",
77
- "@platformatic/ts-compiler": "2.6.1",
78
- "@platformatic/telemetry": "2.6.1",
79
- "@platformatic/utils": "2.6.1"
75
+ "@platformatic/config": "2.7.1-alpha.1",
76
+ "@platformatic/basic": "2.7.1-alpha.1",
77
+ "@platformatic/generators": "2.7.1-alpha.1",
78
+ "@platformatic/telemetry": "2.7.1-alpha.1",
79
+ "@platformatic/ts-compiler": "2.7.1-alpha.1",
80
+ "@platformatic/itc": "2.7.1-alpha.1",
81
+ "@platformatic/utils": "2.7.1-alpha.1"
80
82
  },
81
83
  "scripts": {
82
84
  "test": "npm run lint && borp --concurrency=1 --timeout=180000 && tsd",
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/2.6.1.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/2.7.1-alpha.1.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "type": "object",
5
5
  "properties": {
@@ -13,6 +13,9 @@
13
13
  "entrypoint": {
14
14
  "type": "string"
15
15
  },
16
+ "basePath": {
17
+ "type": "string"
18
+ },
16
19
  "autoload": {
17
20
  "type": "object",
18
21
  "additionalProperties": false,
@@ -379,6 +382,35 @@
379
382
  }
380
383
  }
381
384
  },
385
+ "httpCache": {
386
+ "oneOf": [
387
+ {
388
+ "type": "boolean"
389
+ },
390
+ {
391
+ "type": "object",
392
+ "properties": {
393
+ "store": {
394
+ "type": "string"
395
+ },
396
+ "methods": {
397
+ "type": "array",
398
+ "items": {
399
+ "type": "string"
400
+ },
401
+ "default": [
402
+ "GET",
403
+ "HEAD"
404
+ ],
405
+ "minItems": 1
406
+ },
407
+ "cacheTagsHeader": {
408
+ "type": "string"
409
+ }
410
+ }
411
+ }
412
+ ]
413
+ },
382
414
  "watch": {
383
415
  "anyOf": [
384
416
  {