@platformatic/runtime 3.4.1 → 3.5.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/README.md +1 -1
- package/config.d.ts +224 -77
- package/eslint.config.js +3 -5
- package/index.d.ts +73 -24
- package/index.js +173 -29
- package/lib/config.js +279 -197
- package/lib/errors.js +126 -34
- package/lib/generator.js +640 -0
- package/lib/logger.js +43 -41
- package/lib/management-api.js +109 -118
- package/lib/prom-server.js +202 -16
- package/lib/runtime.js +1963 -585
- package/lib/scheduler.js +119 -0
- package/lib/schema.js +22 -234
- package/lib/shared-http-cache.js +43 -0
- package/lib/upgrade.js +6 -8
- package/lib/utils.js +6 -61
- package/lib/version.js +7 -0
- package/lib/versions/v1.36.0.js +2 -4
- package/lib/versions/v1.5.0.js +2 -4
- package/lib/versions/v2.0.0.js +3 -5
- package/lib/versions/v3.0.0.js +16 -0
- package/lib/worker/controller.js +302 -0
- package/lib/worker/http-cache.js +171 -0
- package/lib/worker/interceptors.js +190 -10
- package/lib/worker/itc.js +146 -59
- package/lib/worker/main.js +220 -81
- package/lib/worker/messaging.js +182 -0
- package/lib/worker/round-robin-map.js +62 -0
- package/lib/worker/shared-context.js +22 -0
- package/lib/worker/symbols.js +14 -5
- package/package.json +47 -38
- package/schema.json +1383 -55
- package/help/compile.txt +0 -8
- package/help/help.txt +0 -5
- package/help/start.txt +0 -21
- package/index.test-d.ts +0 -41
- package/lib/build-server.js +0 -69
- package/lib/compile.js +0 -98
- package/lib/dependencies.js +0 -59
- package/lib/generator/README.md +0 -32
- package/lib/generator/errors.js +0 -10
- package/lib/generator/runtime-generator.d.ts +0 -37
- package/lib/generator/runtime-generator.js +0 -498
- package/lib/start.js +0 -190
- package/lib/worker/app.js +0 -278
- package/lib/worker/default-stackable.js +0 -33
- package/lib/worker/metrics.js +0 -122
- package/runtime.mjs +0 -54
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ensureLoggableError,
|
|
3
|
+
FileWatcher,
|
|
4
|
+
kHandledError,
|
|
5
|
+
listRecognizedConfigurationFiles,
|
|
6
|
+
loadConfiguration,
|
|
7
|
+
loadConfigurationModule
|
|
8
|
+
} from '@platformatic/foundation'
|
|
9
|
+
import debounce from 'debounce'
|
|
10
|
+
import { EventEmitter } from 'node:events'
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
import { resolve } from 'node:path'
|
|
13
|
+
import { getActiveResourcesInfo } from 'node:process'
|
|
14
|
+
import { workerData } from 'node:worker_threads'
|
|
15
|
+
import { getGlobalDispatcher, setGlobalDispatcher } from 'undici'
|
|
16
|
+
import { ApplicationAlreadyStartedError, RuntimeNotStartedError } from '../errors.js'
|
|
17
|
+
import { getApplicationUrl } from '../utils.js'
|
|
18
|
+
|
|
19
|
+
function fetchApplicationUrl (application, key) {
|
|
20
|
+
if (!key.endsWith('_URL') || !application.id) {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return getApplicationUrl(application.id)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class Controller extends EventEmitter {
|
|
28
|
+
#starting
|
|
29
|
+
#started
|
|
30
|
+
#listening
|
|
31
|
+
#watch
|
|
32
|
+
#fileWatcher
|
|
33
|
+
#debouncedRestart
|
|
34
|
+
#context
|
|
35
|
+
#lastELU
|
|
36
|
+
|
|
37
|
+
constructor (
|
|
38
|
+
appConfig,
|
|
39
|
+
workerId,
|
|
40
|
+
telemetryConfig,
|
|
41
|
+
loggerConfig,
|
|
42
|
+
serverConfig,
|
|
43
|
+
metricsConfig,
|
|
44
|
+
hasManagementApi,
|
|
45
|
+
watch
|
|
46
|
+
) {
|
|
47
|
+
super()
|
|
48
|
+
this.appConfig = appConfig
|
|
49
|
+
this.applicationId = this.appConfig.id
|
|
50
|
+
this.workerId = workerId
|
|
51
|
+
this.#watch = watch
|
|
52
|
+
this.#starting = false
|
|
53
|
+
this.#started = false
|
|
54
|
+
this.#listening = false
|
|
55
|
+
this.capability = null
|
|
56
|
+
this.#fileWatcher = null
|
|
57
|
+
this.#lastELU = performance.eventLoopUtilization()
|
|
58
|
+
|
|
59
|
+
this.#context = {
|
|
60
|
+
controller: this,
|
|
61
|
+
applicationId: this.applicationId,
|
|
62
|
+
workerId: this.workerId,
|
|
63
|
+
directory: this.appConfig.path,
|
|
64
|
+
dependencies: this.appConfig.dependencies,
|
|
65
|
+
isEntrypoint: this.appConfig.entrypoint,
|
|
66
|
+
isProduction: this.appConfig.isProduction,
|
|
67
|
+
telemetryConfig,
|
|
68
|
+
metricsConfig,
|
|
69
|
+
loggerConfig,
|
|
70
|
+
serverConfig,
|
|
71
|
+
worker: workerData?.worker,
|
|
72
|
+
hasManagementApi: !!hasManagementApi,
|
|
73
|
+
fetchApplicationUrl: fetchApplicationUrl.bind(null, appConfig)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getStatus () {
|
|
78
|
+
if (this.#starting) return 'starting'
|
|
79
|
+
if (this.#started) return 'started'
|
|
80
|
+
return 'stopped'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async updateContext (context) {
|
|
84
|
+
this.#context = { ...this.#context, ...context }
|
|
85
|
+
if (this.capability) {
|
|
86
|
+
await this.capability.updateContext(context)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Note: capability's init() is executed within start
|
|
91
|
+
async init () {
|
|
92
|
+
try {
|
|
93
|
+
const appConfig = this.appConfig
|
|
94
|
+
|
|
95
|
+
if (appConfig.isProduction && !process.env.NODE_ENV) {
|
|
96
|
+
process.env.NODE_ENV = 'production'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Before returning the base application, check if there is any file we recognize
|
|
100
|
+
// and the user just forgot to specify in the configuration.
|
|
101
|
+
if (!appConfig.config) {
|
|
102
|
+
const candidate = listRecognizedConfigurationFiles().find(f => existsSync(resolve(appConfig.path, f)))
|
|
103
|
+
|
|
104
|
+
if (candidate) {
|
|
105
|
+
appConfig.config = resolve(appConfig.path, candidate)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (appConfig.config) {
|
|
110
|
+
// Parse the configuration file the first time to obtain the schema
|
|
111
|
+
const unvalidatedConfig = await loadConfiguration(appConfig.config, null, {
|
|
112
|
+
onMissingEnv: this.#context.fetchApplicationUrl
|
|
113
|
+
})
|
|
114
|
+
const pkg = await loadConfigurationModule(appConfig.path, unvalidatedConfig)
|
|
115
|
+
this.capability = await pkg.create(appConfig.path, appConfig.config, this.#context)
|
|
116
|
+
// We could not find a configuration file, we use the bundle @platformatic/basic with the runtime to load it
|
|
117
|
+
} else {
|
|
118
|
+
const pkg = await loadConfigurationModule(resolve(import.meta.dirname, '../..'), {}, '@platformatic/basic')
|
|
119
|
+
this.capability = await pkg.create(appConfig.path, {}, this.#context)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.#updateDispatcher()
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err.validationErrors) {
|
|
125
|
+
globalThis.platformatic.logger.error(
|
|
126
|
+
{ err: ensureLoggableError(err) },
|
|
127
|
+
'The application threw a validation error.'
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
throw err
|
|
131
|
+
} else {
|
|
132
|
+
this.#logAndThrow(err)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async start () {
|
|
138
|
+
if (this.#starting || this.#started) {
|
|
139
|
+
throw new ApplicationAlreadyStartedError()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.#starting = true
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await this.capability.init?.()
|
|
146
|
+
this.emit('init')
|
|
147
|
+
} catch (err) {
|
|
148
|
+
this.#logAndThrow(err)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.emit('starting')
|
|
152
|
+
|
|
153
|
+
if (this.capability.status === 'stopped') {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (this.#watch) {
|
|
158
|
+
const watchConfig = await this.capability.getWatchConfig()
|
|
159
|
+
|
|
160
|
+
if (watchConfig.enabled !== false) {
|
|
161
|
+
/* c8 ignore next 4 */
|
|
162
|
+
this.#debouncedRestart = debounce(() => {
|
|
163
|
+
this.capability.log({ message: 'files changed', level: 'debug' })
|
|
164
|
+
this.emit('changed')
|
|
165
|
+
}, 100) // debounce restart for 100ms
|
|
166
|
+
|
|
167
|
+
this.#startFileWatching(watchConfig)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const listen = !!this.appConfig.useHttp
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await this.capability.start({ listen })
|
|
175
|
+
this.#listening = listen
|
|
176
|
+
/* c8 ignore next 5 */
|
|
177
|
+
} catch (err) {
|
|
178
|
+
this.capability.log({ message: err.message, level: 'debug' })
|
|
179
|
+
this.#starting = false
|
|
180
|
+
throw err
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.#started = true
|
|
184
|
+
this.#starting = false
|
|
185
|
+
this.emit('started')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async stop (force = false, dependents = []) {
|
|
189
|
+
if (!force && (!this.#started || this.#starting)) {
|
|
190
|
+
throw new RuntimeNotStartedError()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
this.emit('stopping')
|
|
194
|
+
await this.#stopFileWatching()
|
|
195
|
+
await this.capability.waitForDependentsStop(dependents)
|
|
196
|
+
await this.capability.stop()
|
|
197
|
+
|
|
198
|
+
this.#started = false
|
|
199
|
+
this.#starting = false
|
|
200
|
+
this.#listening = false
|
|
201
|
+
this.emit('stopped')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async listen () {
|
|
205
|
+
// This server is not an entrypoint or already listened in start. Behave as no-op.
|
|
206
|
+
if (!this.appConfig.entrypoint || this.appConfig.useHttp || this.#listening) {
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await this.capability.start({ listen: true })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async getMetrics ({ format }) {
|
|
214
|
+
const dispatcher = getGlobalDispatcher()
|
|
215
|
+
if (globalThis.platformatic?.onHttpStatsFree && dispatcher?.stats) {
|
|
216
|
+
for (const url in dispatcher.stats) {
|
|
217
|
+
const { free, connected, pending, queued, running, size } = dispatcher.stats[url]
|
|
218
|
+
globalThis.platformatic.onHttpStatsFree(url, free || 0)
|
|
219
|
+
globalThis.platformatic.onHttpStatsConnected(url, connected || 0)
|
|
220
|
+
globalThis.platformatic.onHttpStatsPending(url, pending || 0)
|
|
221
|
+
globalThis.platformatic.onHttpStatsQueued(url, queued || 0)
|
|
222
|
+
globalThis.platformatic.onHttpStatsRunning(url, running || 0)
|
|
223
|
+
globalThis.platformatic.onHttpStatsSize(url, size || 0)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
globalThis.platformatic.onActiveResourcesEventLoop(getActiveResourcesInfo().length)
|
|
227
|
+
return this.capability.getMetrics({ format })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async getHealth () {
|
|
231
|
+
const currentELU = performance.eventLoopUtilization()
|
|
232
|
+
const elu = performance.eventLoopUtilization(currentELU, this.#lastELU).utilization
|
|
233
|
+
this.#lastELU = currentELU
|
|
234
|
+
|
|
235
|
+
const { heapUsed, heapTotal } = process.memoryUsage()
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
elu,
|
|
239
|
+
heapUsed,
|
|
240
|
+
heapTotal
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#startFileWatching (watch) {
|
|
245
|
+
if (this.#fileWatcher) {
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const fileWatcher = new FileWatcher({
|
|
250
|
+
path: watch.path,
|
|
251
|
+
/* c8 ignore next 2 */
|
|
252
|
+
allowToWatch: watch?.allow,
|
|
253
|
+
watchIgnore: watch?.ignore || []
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
fileWatcher.on('update', this.#debouncedRestart)
|
|
257
|
+
|
|
258
|
+
fileWatcher.startWatching()
|
|
259
|
+
this.capability.log({ message: 'start watching files', level: 'debug' })
|
|
260
|
+
this.#fileWatcher = fileWatcher
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async #stopFileWatching () {
|
|
264
|
+
const watcher = this.#fileWatcher
|
|
265
|
+
|
|
266
|
+
if (watcher) {
|
|
267
|
+
this.capability.log({ message: 'stop watching files', level: 'debug' })
|
|
268
|
+
await watcher.stopWatching()
|
|
269
|
+
this.#fileWatcher = null
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#logAndThrow (err) {
|
|
274
|
+
globalThis.platformatic.logger.error(
|
|
275
|
+
{ err: ensureLoggableError(err) },
|
|
276
|
+
err[kHandledError] ? err.message : 'The application threw an error.'
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
throw err
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#updateDispatcher () {
|
|
283
|
+
const telemetryConfig = this.#context.telemetryConfig
|
|
284
|
+
const telemetryId = telemetryConfig?.applicationName
|
|
285
|
+
|
|
286
|
+
const interceptor = dispatch => {
|
|
287
|
+
return function InterceptedDispatch (opts, handler) {
|
|
288
|
+
if (telemetryId) {
|
|
289
|
+
opts.headers = {
|
|
290
|
+
...opts.headers,
|
|
291
|
+
'x-plt-telemetry-id': telemetryId
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return dispatch(opts, handler)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const dispatcher = getGlobalDispatcher().compose(interceptor)
|
|
299
|
+
|
|
300
|
+
setGlobalDispatcher(dispatcher)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { Readable, Writable } from 'node:stream'
|
|
3
|
+
import { interceptors } from 'undici'
|
|
4
|
+
import { kITC } from './symbols.js'
|
|
5
|
+
|
|
6
|
+
const kCacheIdHeader = Symbol('cacheIdHeader')
|
|
7
|
+
const CACHE_ID_HEADER = 'x-plt-http-cache-id'
|
|
8
|
+
|
|
9
|
+
const noop = () => {}
|
|
10
|
+
|
|
11
|
+
export class RemoteCacheStore {
|
|
12
|
+
#onRequest
|
|
13
|
+
#onCacheHit
|
|
14
|
+
#onCacheMiss
|
|
15
|
+
#logger
|
|
16
|
+
|
|
17
|
+
constructor (opts = {}) {
|
|
18
|
+
this.#onRequest = opts.onRequest ?? noop
|
|
19
|
+
this.#onCacheHit = opts.onCacheHit ?? noop
|
|
20
|
+
this.#onCacheMiss = opts.onCacheMiss ?? noop
|
|
21
|
+
this.#logger = opts.logger
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async get (request) {
|
|
25
|
+
try {
|
|
26
|
+
this.#onRequest(request)
|
|
27
|
+
} catch (err) {
|
|
28
|
+
this.#logger.error(err, 'Error in onRequest http cache hook')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const itc = globalThis[kITC]
|
|
32
|
+
if (!itc) return
|
|
33
|
+
|
|
34
|
+
const sanitizedRequest = this.#sanitizeRequest(request)
|
|
35
|
+
|
|
36
|
+
const cachedValue = await itc.send('getHttpCacheValue', {
|
|
37
|
+
request: sanitizedRequest
|
|
38
|
+
})
|
|
39
|
+
if (!cachedValue) {
|
|
40
|
+
try {
|
|
41
|
+
this.#onCacheMiss(request)
|
|
42
|
+
} catch (err) {
|
|
43
|
+
this.#logger.error(err, 'Error in onCacheMiss http cache hook')
|
|
44
|
+
}
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const readable = new Readable({ read () {} })
|
|
49
|
+
readable.push(cachedValue.payload)
|
|
50
|
+
readable.push(null)
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
this.#onCacheHit(request, cachedValue.response)
|
|
54
|
+
} catch (err) {
|
|
55
|
+
this.#logger.error(err, 'Error in onCacheHit http cache hook')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
...cachedValue.response,
|
|
60
|
+
body: readable
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
createWriteStream (key, value) {
|
|
65
|
+
const cacheEntryId = value.headers?.[kCacheIdHeader]
|
|
66
|
+
if (cacheEntryId) {
|
|
67
|
+
key = { ...key, id: cacheEntryId }
|
|
68
|
+
value.headers = { ...value.headers, [CACHE_ID_HEADER]: cacheEntryId }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const itc = globalThis[kITC]
|
|
72
|
+
if (!itc) throw new Error('Cannot write to cache without an ITC instance')
|
|
73
|
+
|
|
74
|
+
const acc = []
|
|
75
|
+
|
|
76
|
+
key = this.#sanitizeRequest(key)
|
|
77
|
+
|
|
78
|
+
return new Writable({
|
|
79
|
+
write (chunk, encoding, callback) {
|
|
80
|
+
acc.push(chunk)
|
|
81
|
+
callback()
|
|
82
|
+
},
|
|
83
|
+
final (callback) {
|
|
84
|
+
let payload
|
|
85
|
+
if (acc.length > 0 && typeof acc[0] === 'string') {
|
|
86
|
+
payload = acc.join('')
|
|
87
|
+
} else {
|
|
88
|
+
payload = Buffer.concat(acc)
|
|
89
|
+
}
|
|
90
|
+
itc
|
|
91
|
+
.send('setHttpCacheValue', { request: key, response: value, payload })
|
|
92
|
+
.then(() => callback())
|
|
93
|
+
.catch(err => callback(err))
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
delete (request) {
|
|
99
|
+
const itc = globalThis[kITC]
|
|
100
|
+
if (!itc) throw new Error('Cannot delete from cache without an ITC instance')
|
|
101
|
+
|
|
102
|
+
request = this.#sanitizeRequest(request)
|
|
103
|
+
itc.send('deleteHttpCacheValue', { request })
|
|
104
|
+
// TODO: return a Promise
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#sanitizeRequest (request) {
|
|
108
|
+
return {
|
|
109
|
+
id: request.id,
|
|
110
|
+
origin: request.origin,
|
|
111
|
+
method: request.method,
|
|
112
|
+
path: request.path,
|
|
113
|
+
headers: request.headers
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function httpCacheInterceptor (interceptorOpts) {
|
|
119
|
+
const originalInterceptor = interceptors.cache(interceptorOpts)
|
|
120
|
+
|
|
121
|
+
// AsyncLocalStorage that contains a client http request span
|
|
122
|
+
// Exists only when the nodejs capability telemetry is enabled
|
|
123
|
+
const clientSpansAls = globalThis.platformatic.clientSpansAls
|
|
124
|
+
|
|
125
|
+
return originalDispatch => {
|
|
126
|
+
const dispatch = (opts, handler) => {
|
|
127
|
+
const originOnResponseStart = handler.onResponseStart.bind(handler)
|
|
128
|
+
handler.onResponseStart = (ac, statusCode, headers, statusMessage) => {
|
|
129
|
+
// Setting a potentially cache entry id when cache miss happens
|
|
130
|
+
headers[kCacheIdHeader] = randomUUID()
|
|
131
|
+
return originOnResponseStart(ac, statusCode, headers, statusMessage)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return originalDispatch(opts, handler)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const dispatcher = originalInterceptor(dispatch)
|
|
138
|
+
|
|
139
|
+
return (opts, handler) => {
|
|
140
|
+
const originOnResponseStart = handler.onResponseStart.bind(handler)
|
|
141
|
+
handler.onResponseStart = (ac, statusCode, headers, statusMessage) => {
|
|
142
|
+
const cacheEntryId = headers[kCacheIdHeader] ?? headers[CACHE_ID_HEADER]
|
|
143
|
+
const isCacheHit = headers.age !== undefined
|
|
144
|
+
|
|
145
|
+
if (cacheEntryId) {
|
|
146
|
+
// Setting a cache id header on cache hit
|
|
147
|
+
headers[CACHE_ID_HEADER] = cacheEntryId
|
|
148
|
+
delete headers[kCacheIdHeader]
|
|
149
|
+
|
|
150
|
+
if (clientSpansAls) {
|
|
151
|
+
try {
|
|
152
|
+
const { span } = clientSpansAls.getStore()
|
|
153
|
+
if (span) {
|
|
154
|
+
span.setAttribute('http.cache.id', cacheEntryId)
|
|
155
|
+
span.setAttribute('http.cache.hit', isCacheHit.toString())
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
interceptorOpts.logger.error(err, 'Error setting cache id on span')
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return originOnResponseStart(ac, statusCode, headers, statusMessage)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!clientSpansAls) {
|
|
166
|
+
return dispatcher(opts, handler)
|
|
167
|
+
}
|
|
168
|
+
return clientSpansAls.run({ span: null }, () => dispatcher(opts, handler))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -1,17 +1,197 @@
|
|
|
1
|
-
|
|
1
|
+
import { createTelemetryThreadInterceptorHooks } from '@platformatic/telemetry'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { parentPort, workerData } from 'node:worker_threads'
|
|
6
|
+
import { Agent, Client, Pool, setGlobalDispatcher } from 'undici'
|
|
7
|
+
import { wire } from 'undici-thread-interceptor'
|
|
8
|
+
import { RemoteCacheStore, httpCacheInterceptor } from './http-cache.js'
|
|
9
|
+
import { kInterceptors } from './symbols.js'
|
|
2
10
|
|
|
3
|
-
|
|
11
|
+
export async function setDispatcher (runtimeConfig) {
|
|
12
|
+
const threadDispatcher = createThreadInterceptor(runtimeConfig)
|
|
13
|
+
const threadInterceptor = threadDispatcher.interceptor
|
|
14
|
+
|
|
15
|
+
let cacheInterceptor = null
|
|
16
|
+
if (runtimeConfig.httpCache) {
|
|
17
|
+
cacheInterceptor = createHttpCacheInterceptor(runtimeConfig)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let userInterceptors = []
|
|
21
|
+
if (Array.isArray(runtimeConfig.undici?.interceptors)) {
|
|
22
|
+
const _require = createRequire(join(workerData.dirname, 'package.json'))
|
|
23
|
+
userInterceptors = await loadInterceptors(_require, runtimeConfig.undici.interceptors)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const dispatcherOpts = await getDispatcherOpts(runtimeConfig.undici)
|
|
27
|
+
|
|
28
|
+
setGlobalDispatcher(
|
|
29
|
+
new Agent(dispatcherOpts).compose([threadInterceptor, ...userInterceptors, cacheInterceptor].filter(Boolean))
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return { threadDispatcher }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function updateUndiciInterceptors (undiciConfig) {
|
|
36
|
+
const updatableInterceptors = globalThis[kInterceptors]
|
|
37
|
+
if (!updatableInterceptors) return
|
|
38
|
+
|
|
39
|
+
if (Array.isArray(undiciConfig?.interceptors)) {
|
|
40
|
+
for (const interceptorConfig of undiciConfig.interceptors) {
|
|
41
|
+
const { module, options } = interceptorConfig
|
|
42
|
+
|
|
43
|
+
const interceptorCtx = updatableInterceptors[module]
|
|
44
|
+
if (!interceptorCtx) continue
|
|
45
|
+
|
|
46
|
+
const { createInterceptor, updateInterceptor } = interceptorCtx
|
|
47
|
+
updateInterceptor(createInterceptor(options))
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
for (const key of ['Agent', 'Pool', 'Client']) {
|
|
51
|
+
const interceptorConfigs = undiciConfig.interceptors[key]
|
|
52
|
+
if (!interceptorConfigs) continue
|
|
53
|
+
|
|
54
|
+
for (const interceptorConfig of interceptorConfigs) {
|
|
55
|
+
const { module, options } = interceptorConfig
|
|
56
|
+
|
|
57
|
+
const interceptorCtx = updatableInterceptors[key][module]
|
|
58
|
+
if (!interceptorCtx) continue
|
|
59
|
+
|
|
60
|
+
const { createInterceptor, updateInterceptor } = interceptorCtx
|
|
61
|
+
updateInterceptor(createInterceptor(options))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createUpdatableInterceptor (originInterceptor) {
|
|
68
|
+
let originalDispatcher = null
|
|
69
|
+
let originalDispatch = null
|
|
70
|
+
|
|
71
|
+
function updatableInterceptor (dispatch) {
|
|
72
|
+
originalDispatch = dispatch
|
|
73
|
+
originalDispatcher = originInterceptor(dispatch)
|
|
74
|
+
|
|
75
|
+
return function dispatcher (opts, handler) {
|
|
76
|
+
return originalDispatcher(opts, handler)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function updateInterceptor (newInterceptor) {
|
|
81
|
+
originalDispatcher = newInterceptor(originalDispatch)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { updatableInterceptor, updateInterceptor }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function loadInterceptors (_require, interceptorsConfigs, key) {
|
|
88
|
+
return Promise.all(
|
|
89
|
+
interceptorsConfigs.map(async interceptorConfig => {
|
|
90
|
+
return loadInterceptor(_require, interceptorConfig, key)
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function loadInterceptor (_require, interceptorConfig, key) {
|
|
96
|
+
let updatableInterceptors = globalThis[kInterceptors]
|
|
97
|
+
if (!updatableInterceptors) {
|
|
98
|
+
updatableInterceptors = {}
|
|
99
|
+
globalThis[kInterceptors] = updatableInterceptors
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { module, options } = interceptorConfig
|
|
4
103
|
|
|
5
|
-
async function loadInterceptor (_require, module, options) {
|
|
6
104
|
const url = pathToFileURL(_require.resolve(module))
|
|
7
|
-
const
|
|
8
|
-
|
|
105
|
+
const createInterceptor = (await import(url)).default
|
|
106
|
+
const interceptor = createInterceptor(options)
|
|
107
|
+
|
|
108
|
+
const { updatableInterceptor, updateInterceptor } = createUpdatableInterceptor(interceptor)
|
|
109
|
+
|
|
110
|
+
const interceptorCtx = { createInterceptor, updateInterceptor }
|
|
111
|
+
|
|
112
|
+
if (key !== undefined) {
|
|
113
|
+
if (!updatableInterceptors[key]) {
|
|
114
|
+
updatableInterceptors[key] = {}
|
|
115
|
+
}
|
|
116
|
+
updatableInterceptors[key][module] = interceptorCtx
|
|
117
|
+
} else {
|
|
118
|
+
updatableInterceptors[module] = interceptorCtx
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return updatableInterceptor
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function getDispatcherOpts (undiciConfig) {
|
|
125
|
+
const dispatcherOpts = { ...undiciConfig }
|
|
126
|
+
|
|
127
|
+
const interceptorsConfigs = undiciConfig?.interceptors
|
|
128
|
+
if (!interceptorsConfigs || Array.isArray(interceptorsConfigs)) {
|
|
129
|
+
return dispatcherOpts
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const _require = createRequire(join(workerData.dirname, 'package.json'))
|
|
133
|
+
|
|
134
|
+
const clientInterceptors = []
|
|
135
|
+
const poolInterceptors = []
|
|
136
|
+
|
|
137
|
+
for (const key of ['Agent', 'Pool', 'Client']) {
|
|
138
|
+
const interceptorConfig = undiciConfig.interceptors[key]
|
|
139
|
+
if (!interceptorConfig) continue
|
|
140
|
+
|
|
141
|
+
const interceptors = await loadInterceptors(_require, interceptorConfig, key)
|
|
142
|
+
if (key === 'Agent') {
|
|
143
|
+
clientInterceptors.push(...interceptors)
|
|
144
|
+
poolInterceptors.push(...interceptors)
|
|
145
|
+
}
|
|
146
|
+
if (key === 'Pool') {
|
|
147
|
+
poolInterceptors.push(...interceptors)
|
|
148
|
+
}
|
|
149
|
+
if (key === 'Client') {
|
|
150
|
+
clientInterceptors.push(...interceptors)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
dispatcherOpts.factory = (origin, opts) => {
|
|
155
|
+
return opts && opts.connections === 1
|
|
156
|
+
? new Client(origin, opts).compose(clientInterceptors)
|
|
157
|
+
: new Pool(origin, opts).compose(poolInterceptors)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return dispatcherOpts
|
|
9
161
|
}
|
|
10
162
|
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
163
|
+
function createThreadInterceptor (runtimeConfig) {
|
|
164
|
+
const telemetry = runtimeConfig.telemetry
|
|
165
|
+
|
|
166
|
+
const telemetryHooks = telemetry ? createTelemetryThreadInterceptorHooks() : {}
|
|
167
|
+
|
|
168
|
+
const threadDispatcher = wire({
|
|
169
|
+
// Specifying the domain is critical to avoid flooding the DNS
|
|
170
|
+
// with requests for a domain that's never going to exist.
|
|
171
|
+
domain: '.plt.local',
|
|
172
|
+
port: parentPort,
|
|
173
|
+
timeout: runtimeConfig.applicationTimeout,
|
|
174
|
+
...telemetryHooks
|
|
175
|
+
})
|
|
176
|
+
return threadDispatcher
|
|
15
177
|
}
|
|
16
178
|
|
|
17
|
-
|
|
179
|
+
function createHttpCacheInterceptor (runtimeConfig) {
|
|
180
|
+
const cacheInterceptor = httpCacheInterceptor({
|
|
181
|
+
store: new RemoteCacheStore({
|
|
182
|
+
onRequest: opts => {
|
|
183
|
+
globalThis.platformatic?.onHttpCacheRequest?.(opts)
|
|
184
|
+
},
|
|
185
|
+
onCacheHit: opts => {
|
|
186
|
+
globalThis.platformatic?.onHttpCacheHit?.(opts)
|
|
187
|
+
},
|
|
188
|
+
onCacheMiss: opts => {
|
|
189
|
+
globalThis.platformatic?.onHttpCacheMiss?.(opts)
|
|
190
|
+
},
|
|
191
|
+
logger: globalThis.platformatic.logger
|
|
192
|
+
}),
|
|
193
|
+
methods: runtimeConfig.httpCache.methods ?? ['GET', 'HEAD'],
|
|
194
|
+
logger: globalThis.platformatic.logger
|
|
195
|
+
})
|
|
196
|
+
return cacheInterceptor
|
|
197
|
+
}
|