@adobe/llm-apps-runtime 0.2.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/src/loader.js ADDED
@@ -0,0 +1,496 @@
1
+ /*
2
+ Copyright 2022 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+
7
+ Unless required by applicable law or agreed to in writing, software distributed under
8
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ OF ANY KIND, either express or implied. See the License for the specific language
10
+ governing permissions and limitations under the License.
11
+ */
12
+
13
+ /**
14
+ * Action Loader for @adobe/llm-apps-runtime.
15
+ *
16
+ * Two loading paths:
17
+ *
18
+ * loadActionsFromContexts(server, { moduleContext, htmlContext, actionsConfig })
19
+ * Used at runtime when webpack has already resolved the action modules and widget
20
+ * HTML files into context objects. Called from the consumer app's entry.js.
21
+ *
22
+ * loadActionsFromFs(server, actionsDir, actionsConfig)
23
+ * Used in Jest tests and any environment without webpack (plain Node).
24
+ * Reads modules and HTML files directly from the filesystem.
25
+ *
26
+ * Registration is driven by actionsConfig (the parsed actions.json) in both paths.
27
+ * When actionsConfig is empty, both paths fall back to filesystem discovery and print
28
+ * a banner reminding the developer to download actions.json from the llm-apps UI.
29
+ *
30
+ * Widget resolution priority (same in both paths):
31
+ * 1. widget.html file in the action directory
32
+ * 2. EDS config in actionsConfig (auto-generates aem-embed template)
33
+ * 3. Tool-only (no widget)
34
+ *
35
+ * Handler export shape (only shape accepted):
36
+ * module.exports = async (args) => ({ content, structuredContent? })
37
+ * ESM default-function exports are also accepted.
38
+ */
39
+
40
+ const fs = require('fs')
41
+ const path = require('path')
42
+ const { z } = require('zod')
43
+ const { safeSize } = require('./analytics.js')
44
+
45
+ const RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app'
46
+
47
+ const MISSING_CONFIG_BANNER = [
48
+ '',
49
+ ' ┌─────────────────────────────────────────────────────────────────────┐',
50
+ ' │ No actions.json found. │',
51
+ ' │ Handlers will be registered with empty metadata. │',
52
+ ' │ Download actions.json from the llm-apps UI to mirror production. │',
53
+ ' └─────────────────────────────────────────────────────────────────────┘',
54
+ ''
55
+ ].join('\n')
56
+
57
+ function normalizeAction (mod) {
58
+ if (typeof mod === 'function') return { handler: mod }
59
+ if (mod && typeof mod.default === 'function') return { handler: mod.default }
60
+ return null
61
+ }
62
+
63
+ function validateAction (mod, source) {
64
+ if (normalizeAction(mod)) return true
65
+ console.warn(`Skipping ${source}: expected module.exports to be an async function`)
66
+ return false
67
+ }
68
+
69
+ function createDefaultHandler () {
70
+ return async () => ({
71
+ content: [{ type: 'text', text: '' }],
72
+ structuredContent: {}
73
+ })
74
+ }
75
+
76
+ function jsonSchemaPropertyToZod (prop, isRequired) {
77
+ let zodType
78
+ if (prop.enum) {
79
+ zodType = z.enum(prop.enum)
80
+ } else {
81
+ switch (prop.type) {
82
+ case 'string':
83
+ zodType = z.string()
84
+ break
85
+ case 'number':
86
+ zodType = z.number()
87
+ break
88
+ case 'integer':
89
+ zodType = z.number().int()
90
+ break
91
+ case 'boolean':
92
+ zodType = z.boolean()
93
+ break
94
+ case 'array':
95
+ zodType = z.array(prop.items ? jsonSchemaPropertyToZod(prop.items, true) : z.any())
96
+ break
97
+ default:
98
+ zodType = z.any()
99
+ }
100
+ }
101
+ if (prop.description) zodType = zodType.describe(prop.description)
102
+ if (!isRequired) zodType = zodType.optional()
103
+ return zodType
104
+ }
105
+
106
+ function jsonSchemaToZodShape (jsonSchema) {
107
+ if (!jsonSchema || jsonSchema.type !== 'object' || !jsonSchema.properties) {
108
+ return undefined
109
+ }
110
+ const required = new Set(jsonSchema.required || [])
111
+ const shape = {}
112
+ for (const [key, prop] of Object.entries(jsonSchema.properties)) {
113
+ shape[key] = jsonSchemaPropertyToZod(prop, required.has(key))
114
+ }
115
+ return shape
116
+ }
117
+
118
+ function buildResourceMeta (resourceMeta) {
119
+ if (!resourceMeta) return undefined
120
+
121
+ const ui = {}
122
+ const src = resourceMeta.ui || resourceMeta
123
+ if (src.csp) ui.csp = src.csp
124
+ if (src.permissions) ui.permissions = src.permissions
125
+ if (src.domain !== undefined) ui.domain = src.domain
126
+ if (src.prefersBorder !== undefined) ui.prefersBorder = src.prefersBorder
127
+
128
+ return Object.keys(ui).length > 0 ? { ui } : undefined
129
+ }
130
+
131
+ function generateEdsWidgetHtml (config) {
132
+ if (config?.widget_type !== 'EDS') return null
133
+
134
+ const edsWidget = config.eds_widget
135
+ if (!edsWidget?.script_url || !edsWidget?.widget_embed_url) {
136
+ console.warn(` ⚠ EDS widget for "${config.name}": missing script_url or widget_embed_url in eds_widget config`)
137
+ return null
138
+ }
139
+
140
+ return `<script src="${edsWidget.script_url}" type="module"></script>\n<div>\n <aem-embed url="${edsWidget.widget_embed_url}"></aem-embed>\n</div>\n`
141
+ }
142
+
143
+ /**
144
+ * Wrap a tool handler with analytics instrumentation.
145
+ *
146
+ * Captures per-call timing/size into a per-invocation buffer that the
147
+ * server flushes as one signed batch at end-of-request. Inputs and outputs
148
+ * are NEVER serialized — only byte sizes via safeSize().
149
+ *
150
+ * If analyticsConfig is absent (no buffer) the original handler is returned
151
+ * unchanged so unconfigured deploys cost zero overhead.
152
+ */
153
+ function wrapHandlerWithAnalytics (name, handler, analyticsConfig) {
154
+ if (!analyticsConfig || !analyticsConfig.buffer) return handler
155
+
156
+ return async function instrumented (...args) {
157
+ const startedAt = Date.now()
158
+ const occurredAt = new Date(startedAt).toISOString()
159
+ const inputSize = safeSize(args[0])
160
+
161
+ try {
162
+ const result = await handler(...args)
163
+ analyticsConfig.buffer.push({
164
+ occurred_at: occurredAt,
165
+ tool_name: name,
166
+ duration_ms: Date.now() - startedAt,
167
+ status: 'ok',
168
+ input_size_bytes: inputSize,
169
+ output_size_bytes: safeSize(result),
170
+ mcp_method: 'tools/call'
171
+ })
172
+ return result
173
+ } catch (err) {
174
+ const errorClass = (err && err.constructor && err.constructor.name) || 'Error'
175
+ analyticsConfig.buffer.push({
176
+ occurred_at: occurredAt,
177
+ tool_name: name,
178
+ duration_ms: Date.now() - startedAt,
179
+ status: 'error',
180
+ input_size_bytes: inputSize,
181
+ output_size_bytes: 0,
182
+ mcp_method: 'tools/call',
183
+ error_class: errorClass
184
+ })
185
+ throw err
186
+ }
187
+ }
188
+ }
189
+
190
+ function registerAction (server, name, action, widgetHtml, config, analyticsConfig) {
191
+ const toolMeta = {}
192
+
193
+ const inputSchema = config?.inputSchema ? jsonSchemaToZodShape(config.inputSchema) : undefined
194
+
195
+ if (widgetHtml) {
196
+ const resourceUri = `ui://${name}/widget.html`
197
+ const resourceMeta = buildResourceMeta(config?.resource_meta)
198
+
199
+ server.registerResource(
200
+ `${name}-widget`,
201
+ resourceUri,
202
+ { mimeType: RESOURCE_MIME_TYPE },
203
+ async () => ({
204
+ contents: [{
205
+ uri: resourceUri,
206
+ mimeType: RESOURCE_MIME_TYPE,
207
+ text: widgetHtml,
208
+ ...(resourceMeta ? { _meta: resourceMeta } : {})
209
+ }]
210
+ })
211
+ )
212
+
213
+ toolMeta.ui = { resourceUri, ...(config?.tool_meta?.ui || {}) }
214
+ toolMeta['ui/resourceUri'] = resourceUri
215
+ toolMeta['openai/outputTemplate'] = resourceUri
216
+ toolMeta['openai/resultCanProduceWidget'] = true
217
+ }
218
+
219
+ if (config?.tool_meta) {
220
+ for (const [key, value] of Object.entries(config.tool_meta)) {
221
+ if (key !== 'ui') {
222
+ toolMeta[key] = value
223
+ }
224
+ }
225
+ }
226
+
227
+ // Per-action gate: only wrap when the action explicitly opts in AND the
228
+ // runtime has analytics configured. Either gate failing => bare handler.
229
+ const wrappedHandler = (config?.analyticsEnabled === true && analyticsConfig)
230
+ ? wrapHandlerWithAnalytics(name, action.handler, analyticsConfig)
231
+ : action.handler
232
+
233
+ server.registerTool(name, {
234
+ title: config?.title || name,
235
+ description: config?.description || name,
236
+ inputSchema,
237
+ annotations: config?.annotations || undefined,
238
+ _meta: Object.keys(toolMeta).length > 0 ? toolMeta : undefined
239
+ }, wrappedHandler)
240
+ }
241
+
242
+ /**
243
+ * Load actions config from actions.json, keyed by action name.
244
+ * Looks for actions.json at actionsDir/../actions.json (the app root).
245
+ */
246
+ function loadActionsConfig (actionsDir) {
247
+ const configPath = path.resolve(actionsDir, '..', 'actions.json')
248
+ try {
249
+ if (fs.existsSync(configPath)) {
250
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
251
+ const map = {}
252
+ for (const act of raw.actions || []) {
253
+ if (act.name) map[act.name] = act
254
+ }
255
+ console.log(`Loaded actions.json with ${Object.keys(map).length} action(s)`)
256
+ return map
257
+ }
258
+ } catch (e) {
259
+ console.warn('Failed to load actions.json:', e.message)
260
+ }
261
+ return {}
262
+ }
263
+
264
+ /**
265
+ * Resolve actions from webpack-resolved context maps into a plain array.
266
+ *
267
+ * Returns [{ name, action, widgetHtml, config }] — no McpServer param.
268
+ * Logs startup info once. Intended to be called at createMain() time so
269
+ * logs fire on cold start only, not on every request.
270
+ *
271
+ * actionsConfig — raw actions.json: { actions: [{ name, ... }, ...] }
272
+ * Pass require('./actions.json') directly from entry.js.
273
+ */
274
+ function resolveActionsFromContexts ({ moduleContext, htmlContext, actionsConfig }) {
275
+ const moduleMap = {}
276
+ for (const key of moduleContext.keys()) {
277
+ moduleMap[key.split('/')[1]] = moduleContext(key)
278
+ }
279
+
280
+ const widgetMap = {}
281
+ for (const key of htmlContext.keys()) {
282
+ widgetMap[key.split('/')[1]] = htmlContext(key)
283
+ }
284
+
285
+ const resolved = []
286
+ const configActions = (actionsConfig && actionsConfig.actions || []).filter(a => a && a.name)
287
+
288
+ if (configActions.length > 0) {
289
+ console.log(`Registering ${configActions.length} action(s) from actions.json`)
290
+
291
+ for (const config of configActions) {
292
+ const name = config.name
293
+ try {
294
+ const mod = moduleMap[name] || moduleMap[name.replace(/_/g, '-')]
295
+ const action = mod && validateAction(mod, name)
296
+ ? normalizeAction(mod)
297
+ : { handler: createDefaultHandler() }
298
+ const widgetHtml = widgetMap[name] || widgetMap[name.replace(/_/g, '-')] || generateEdsWidgetHtml(config)
299
+
300
+ const hasHandler = !!mod
301
+ if (widgetMap[name] || widgetMap[name.replace(/_/g, '-')]) {
302
+ console.log(` ✓ ${name} (${hasHandler ? 'handler' : 'default'} + widget)`)
303
+ } else if (widgetHtml) {
304
+ console.log(` ✓ ${name} (${hasHandler ? 'handler' : 'default'} + EDS widget)`)
305
+ } else {
306
+ console.log(` ✓ ${name} (${hasHandler ? 'handler' : 'default'}, tool only)`)
307
+ }
308
+
309
+ resolved.push({ name, action, widgetHtml, config })
310
+ } catch (error) {
311
+ console.error(`Error resolving action "${name}":`, error.message)
312
+ }
313
+ }
314
+ } else {
315
+ console.warn(MISSING_CONFIG_BANNER)
316
+
317
+ const modules = moduleContext.keys()
318
+ console.log(`Discovering ${modules.length} action(s) from filesystem`)
319
+
320
+ for (const key of modules) {
321
+ try {
322
+ const mod = moduleContext(key)
323
+ const dirName = key.split('/')[1]
324
+
325
+ if (!validateAction(mod, key)) continue
326
+
327
+ const action = normalizeAction(mod)
328
+ const widgetHtml = widgetMap[dirName] || null
329
+
330
+ if (widgetHtml) {
331
+ console.log(` ✓ Loaded action: ${dirName} (tool + widget)`)
332
+ } else {
333
+ console.log(` ✓ Loaded action: ${dirName} (tool only)`)
334
+ }
335
+
336
+ resolved.push({ name: dirName, action, widgetHtml, config: undefined })
337
+ } catch (error) {
338
+ console.error(`Error loading action from ${key}:`, error.message)
339
+ }
340
+ }
341
+ }
342
+
343
+ return resolved
344
+ }
345
+
346
+ /**
347
+ * Resolve actions from the filesystem into a plain array.
348
+ *
349
+ * Returns [{ name, action, widgetHtml, config }] — no McpServer param.
350
+ * Logs startup info once. Intended to be called at createMain() time.
351
+ */
352
+ function resolveActionsFromFs (actionsDir, actionsConfig) {
353
+ const resolved = []
354
+ const configNames = Object.keys(actionsConfig)
355
+
356
+ if (configNames.length > 0) {
357
+ console.log(`Registering ${configNames.length} action(s) from actions.json`)
358
+
359
+ for (const name of configNames) {
360
+ try {
361
+ const config = actionsConfig[name]
362
+ const dirName = fs.existsSync(path.join(actionsDir, name)) ? name : name.replace(/_/g, '-')
363
+ const indexPath = path.join(actionsDir, dirName, 'index.js')
364
+ const hasHandler = fs.existsSync(indexPath)
365
+
366
+ const action = hasHandler
367
+ ? (mod => validateAction(mod, name) ? normalizeAction(mod) : { handler: createDefaultHandler() })(require(indexPath))
368
+ : { handler: createDefaultHandler() }
369
+
370
+ const widgetPath = path.join(actionsDir, dirName, 'widget.html')
371
+ const hasWidgetFile = fs.existsSync(widgetPath)
372
+ const widgetHtml = hasWidgetFile
373
+ ? fs.readFileSync(widgetPath, 'utf-8')
374
+ : generateEdsWidgetHtml(config)
375
+
376
+ if (hasWidgetFile) {
377
+ console.log(` ✓ ${name} (${hasHandler ? 'handler' : 'default'} + widget)`)
378
+ } else if (widgetHtml) {
379
+ console.log(` ✓ ${name} (${hasHandler ? 'handler' : 'default'} + EDS widget)`)
380
+ } else {
381
+ console.log(` ✓ ${name} (${hasHandler ? 'handler' : 'default'}, tool only)`)
382
+ }
383
+
384
+ resolved.push({ name, action, widgetHtml, config })
385
+ } catch (error) {
386
+ console.error(`Error resolving action "${name}":`, error.message)
387
+ }
388
+ }
389
+ } else {
390
+ console.warn(MISSING_CONFIG_BANNER)
391
+
392
+ if (!fs.existsSync(actionsDir)) {
393
+ console.warn(`Actions directory not found: ${actionsDir}`)
394
+ return resolved
395
+ }
396
+
397
+ const dirs = fs.readdirSync(actionsDir, { withFileTypes: true })
398
+ .filter(d => d.isDirectory())
399
+ .map(d => d.name)
400
+
401
+ console.log(`Discovering ${dirs.length} action(s) from ${actionsDir}`)
402
+
403
+ for (const dirName of dirs) {
404
+ try {
405
+ const indexPath = path.join(actionsDir, dirName, 'index.js')
406
+ if (!fs.existsSync(indexPath)) {
407
+ console.warn(`Skipping ${dirName}: no index.js found`)
408
+ continue
409
+ }
410
+
411
+ const mod = require(indexPath)
412
+ if (!validateAction(mod, dirName)) continue
413
+
414
+ const action = normalizeAction(mod)
415
+ const widgetPath = path.join(actionsDir, dirName, 'widget.html')
416
+ const hasWidgetFile = fs.existsSync(widgetPath)
417
+ const widgetHtml = hasWidgetFile ? fs.readFileSync(widgetPath, 'utf-8') : null
418
+
419
+ if (hasWidgetFile) {
420
+ console.log(` ✓ Loaded action: ${dirName} (tool + widget)`)
421
+ } else {
422
+ console.log(` ✓ Loaded action: ${dirName} (tool only)`)
423
+ }
424
+
425
+ resolved.push({ name: dirName, action, widgetHtml, config: undefined })
426
+ } catch (error) {
427
+ console.error(`Error loading action from ${dirName}:`, error.message)
428
+ }
429
+ }
430
+ }
431
+
432
+ return resolved
433
+ }
434
+
435
+ /**
436
+ * Resolve actions from either webpack contexts or the filesystem.
437
+ *
438
+ * Picks the right path based on options:
439
+ * - options.moduleContext present → resolveActionsFromContexts
440
+ * - otherwise → resolveActionsFromFs
441
+ *
442
+ * Returns [{ name, action, widgetHtml, config }].
443
+ * Call once at createMain() time; pass the result to createMcpServer().
444
+ */
445
+ function resolveActions (options) {
446
+ if (options.moduleContext !== undefined) {
447
+ return resolveActionsFromContexts({
448
+ moduleContext: options.moduleContext,
449
+ htmlContext: options.htmlContext || { keys: () => [] },
450
+ actionsConfig: options.actionsConfig || {}
451
+ })
452
+ }
453
+ const actionsDir = options.actionsDir || path.join(process.cwd(), 'actions')
454
+ const cfg = loadActionsConfig(actionsDir)
455
+ return resolveActionsFromFs(actionsDir, cfg)
456
+ }
457
+
458
+ /**
459
+ * Register actions from webpack-resolved context maps onto an McpServer.
460
+ *
461
+ * Thin wrapper around resolveActionsFromContexts + registerAction.
462
+ * Used directly in tests and anywhere a server instance is already available.
463
+ * In production (createMain), prefer resolveActions + registerAction loop
464
+ * so resolution happens once at startup rather than per-request.
465
+ */
466
+ function loadActionsFromContexts (server, { moduleContext, htmlContext, actionsConfig }, analyticsConfig) {
467
+ const resolved = resolveActionsFromContexts({ moduleContext, htmlContext, actionsConfig })
468
+ for (const { name, action, widgetHtml, config } of resolved) {
469
+ registerAction(server, name, action, widgetHtml, config, analyticsConfig)
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Filesystem-based action loading.
475
+ *
476
+ * Thin wrapper around resolveActionsFromFs + registerAction.
477
+ * Used in Jest tests and plain Node environments without webpack.
478
+ */
479
+ function loadActionsFromFs (server, actionsDir, actionsConfig, analyticsConfig) {
480
+ const resolved = resolveActionsFromFs(actionsDir, actionsConfig)
481
+ for (const { name, action, widgetHtml, config } of resolved) {
482
+ registerAction(server, name, action, widgetHtml, config, analyticsConfig)
483
+ }
484
+ }
485
+
486
+ module.exports = {
487
+ resolveActions,
488
+ loadActionsFromContexts,
489
+ loadActionsFromFs,
490
+ loadActionsConfig,
491
+ createDefaultHandler,
492
+ generateEdsWidgetHtml,
493
+ registerAction,
494
+ wrapHandlerWithAnalytics,
495
+ RESOURCE_MIME_TYPE
496
+ }
package/src/local.js ADDED
@@ -0,0 +1,91 @@
1
+ /*
2
+ Copyright 2022 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+
7
+ Unless required by applicable law or agreed to in writing, software distributed under
8
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ OF ANY KIND, either express or implied. See the License for the specific language
10
+ governing permissions and limitations under the License.
11
+ */
12
+
13
+ /**
14
+ * Local development HTTP server for MCP Apps.
15
+ *
16
+ * Wraps a main() function (from createMain) in a plain Node.js HTTP server so
17
+ * developers can run without Adobe I/O Runtime credentials.
18
+ *
19
+ * Usage in consumer app server/local.js:
20
+ *
21
+ * const { createLocalServer } = require('@adobe/llm-apps-runtime')
22
+ * const { main } = require('../dist/index.js') // webpack bundle
23
+ * createLocalServer(main, process.env.PORT || 9080)
24
+ */
25
+
26
+ const http = require('http')
27
+
28
+ /**
29
+ * Start a local HTTP server that routes plain HTTP requests to an MCP main() function.
30
+ *
31
+ * @param {Function} main The main(params) function returned by createMain
32
+ * @param {number} port Port to listen on (default 9080)
33
+ * @returns {http.Server}
34
+ */
35
+ function createLocalServer (main, port) {
36
+ // Use ?? (not ||) so an explicit 0 (ephemeral port, useful for tests) is honored.
37
+ const listenPort = port ?? 9080
38
+
39
+ const server = http.createServer(async (req, res) => {
40
+ const chunks = []
41
+ req.on('data', chunk => chunks.push(chunk))
42
+ req.on('end', async () => {
43
+ const rawBody = Buffer.concat(chunks).toString('utf8')
44
+
45
+ const params = {
46
+ __ow_method: req.method.toLowerCase(),
47
+ __ow_path: req.url,
48
+ __ow_body: rawBody || undefined,
49
+ __ow_headers: req.headers,
50
+ LOG_LEVEL: process.env.LOG_LEVEL || 'info'
51
+ }
52
+
53
+ try {
54
+ const result = await main(params)
55
+
56
+ res.statusCode = result.statusCode || 200
57
+ for (const [key, value] of Object.entries(result.headers || {})) {
58
+ res.setHeader(key, value)
59
+ }
60
+ res.end(result.body || '')
61
+ } catch (err) {
62
+ console.error('Unhandled error:', err)
63
+ res.statusCode = 500
64
+ res.setHeader('Content-Type', 'application/json')
65
+ res.end(JSON.stringify({ error: err.message }))
66
+ }
67
+ })
68
+ })
69
+
70
+ server.listen(listenPort, () => {
71
+ // When listenPort is 0, the OS picks the real port — read it back.
72
+ const actualPort = server.address().port
73
+ console.log('')
74
+ console.log(' LLM Apps local server running')
75
+ console.log(` Endpoint: http://localhost:${actualPort}`)
76
+ console.log('')
77
+ console.log(' To test:')
78
+ console.log(` curl -sX POST http://localhost:${actualPort} \\`)
79
+ console.log(' -H \'content-type: application/json\' \\')
80
+ console.log(' -H \'accept: application/json;q=1.0, text/event-stream;q=0.5\' \\')
81
+ console.log(' -d \'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}\' | python3 -m json.tool')
82
+ console.log('')
83
+ console.log(' MCP Inspector: npx @modelcontextprotocol/inspector')
84
+ console.log(` Inspector URL: http://localhost:${actualPort}`)
85
+ console.log('')
86
+ })
87
+
88
+ return server
89
+ }
90
+
91
+ module.exports = { createLocalServer }