mikser-io-layouts 11.1.0 → 11.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/index.js CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  import { createInspect } from './lib/inspect.js'
30
30
  import { createOnProcessed } from './lib/matching.js'
31
31
  import { createOnBeforeRender } from './lib/assembly.js'
32
- import { installSidecarModuleHook } from './lib/sidecar-modules.js'
32
+ import { installSidecarModuleHook, loadSidecarModule } from './lib/sidecar-modules.js'
33
33
  import { registerMcpTools } from './lib/mcp.js'
34
34
 
35
35
  export function layouts(userOptions = {}) {
@@ -106,6 +106,19 @@ export function layouts(userOptions = {}) {
106
106
 
107
107
  provideService('layouts', {
108
108
  inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection }),
109
+ // A layout's sidecar, loaded the way the render loads it —
110
+ // same path rule, same digest stamp. Offered as a service because
111
+ // the alternative is another package importing this one's lib/,
112
+ // and a second copy of the stamping is a second chance for an
113
+ // edited sidecar to keep answering from cache.
114
+ //
115
+ // Consumers: mikser-io-mcp-app, which invokes a sidecar's `call`,
116
+ // `read` and `list` exports for an app's actions and data. The
117
+ // render's own use of the same loader reads `load` and `plugins`.
118
+ sidecar: (layout) => loadSidecarModule(layout, {
119
+ layoutsFolder: layoutsFolderNow(),
120
+ logger: useLogger(),
121
+ }),
109
122
  }, { plugin: 'mikser-io-layouts' })
110
123
 
111
124
  // Tool registration. Registers against core's tool registry, which
package/lib/assembly.js CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  reportError,
31
31
  } from 'mikser-io'
32
32
  import { compileDestinationTemplate, sanitizeDestination, primaryDestination } from './destination.js'
33
- import { setSidecarStamp, sidecarHookInstalled } from './sidecar-modules.js'
33
+ import { loadSidecarModule } from './sidecar-modules.js'
34
34
 
35
35
  // What the sidecar actually returned, for a message a reader can act on.
36
36
  // `[object Object]` and `` are the two least useful things to print here.
@@ -270,19 +270,20 @@ export function createOnBeforeRender({
270
270
  const sidecarTrack = createTrack({ partial: false, meta: true, consumed: true })
271
271
  if (existsSync(sidecarPath)) {
272
272
  try {
273
- // Stamp = the shared sidecar digest, so the URL
274
- // changes exactly when some .js under layoutsFolder
275
- // does. With the resolve hook installed that stamp
276
- // also reaches the sidecar's own imports; without it
277
- // only the entry reloads, so fall back to a
278
- // per-render stamp to keep at least that much.
279
- const stamp = entity.layout.inputs?.shared || ''
280
- setSidecarStamp(stamp)
281
- const entryStamp = sidecarHookInstalled() ? stamp : Date.now();
273
+ // Path resolution and stamping live in
274
+ // sidecar-modules.js, shared with the apps surface
275
+ // that invokes a sidecar's action handlers — one
276
+ // implementation, so neither can drift from the
277
+ // digest rule that keeps an edited sidecar from
278
+ // answering out of cache.
279
+ const sidecarModule = await loadSidecarModule(entity.layout, {
280
+ layoutsFolder: runtime.options.layoutsFolder,
281
+ logger,
282
+ });
282
283
  // Semicolon above is load-bearing: without it the
283
284
  // parenthesised destructuring assignment below reads
284
285
  // as a CALL of the preceding expression.
285
- ({ load, plugins = [] } = await import(`${sidecarPath}?stamp=${entryStamp}`))
286
+ ({ load, plugins = [] } = sidecarModule ?? {})
286
287
  } catch (err) {
287
288
  logger.error('Layout sidecar %s failed to load: %s', sidecarPath.replace(runtime.options.workingFolder + '/', ''), err.message)
288
289
  throw err
@@ -31,6 +31,8 @@
31
31
  // once rather than left to be discovered.
32
32
 
33
33
  import module from 'node:module'
34
+ import path from 'node:path'
35
+ import { existsSync } from 'node:fs'
34
36
  import { pathToFileURL } from 'node:url'
35
37
 
36
38
  // Feature-detected rather than imported by name: `import { registerHooks }`
@@ -80,3 +82,32 @@ export function setSidecarStamp(next) {
80
82
  export function sidecarHookInstalled() {
81
83
  return installed
82
84
  }
85
+
86
+ // Load a layout's sidecar module, or null when it has none.
87
+ //
88
+ // One implementation, two callers: the render (which then tracks what the
89
+ // sidecar reads) and the apps surface (which invokes its action handlers).
90
+ // The stamping is the whole reason this is shared — a second copy would drift
91
+ // from the digest rule above and reintroduce the failure that rule exists for:
92
+ // an edited sidecar that keeps answering from cache.
93
+ export async function loadSidecarModule(layout, { layoutsFolder, logger } = {}) {
94
+ if (!layout?.name || !layoutsFolder) return null
95
+ const sidecarPath = path.join(layoutsFolder, `${layout.name}.js`)
96
+ // Existence first, so a real ERR_MODULE_NOT_FOUND from INSIDE the sidecar
97
+ // — it imports a package that is not installed — is not swallowed as
98
+ // "this layout has no sidecar".
99
+ if (!existsSync(sidecarPath)) return null
100
+
101
+ const shared = layout.inputs?.shared || ''
102
+ setSidecarStamp(shared)
103
+ // With the resolve hook installed the digest reaches the sidecar's own
104
+ // imports too; without it only the entry reloads, so fall back to a
105
+ // per-call stamp to keep at least that much.
106
+ const entryStamp = sidecarHookInstalled() ? shared : Date.now()
107
+ try {
108
+ return await import(`${pathToFileURL(sidecarPath).href}?stamp=${entryStamp}`)
109
+ } catch (err) {
110
+ logger?.error('Layout sidecar %s failed to load: %s', sidecarPath, err.message)
111
+ throw err
112
+ }
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "11.1.0",
3
+ "version": "11.2.0",
4
4
  "description": "SSG-flavor task-production policy for mikser-io. Multi-match layout assignment, per-layout destinations, postprocess chain attachment, layout inspection.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -23,7 +23,7 @@ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
23
23
  import { tmpdir } from 'node:os'
24
24
  import path from 'node:path'
25
25
 
26
- import { installSidecarModuleHook, setSidecarStamp, sidecarHookInstalled } from '../lib/sidecar-modules.js'
26
+ import { installSidecarModuleHook, setSidecarStamp, sidecarHookInstalled, loadSidecarModule } from '../lib/sidecar-modules.js'
27
27
 
28
28
  describe('sidecar module identity', () => {
29
29
  let root, layoutsFolder
@@ -98,3 +98,51 @@ describe('sidecar module identity', () => {
98
98
  "export const icon = 'filter.svg'\n")
99
99
  })
100
100
  })
101
+
102
+ // loadSidecarModule — the shared loader. The render path and the apps surface
103
+ // both go through it; a second copy of the path rule or the stamping is a
104
+ // second chance for an edited sidecar to keep answering from cache, which is
105
+ // the failure the whole file above exists for.
106
+ describe('loadSidecarModule', () => {
107
+ let root, layoutsFolder
108
+
109
+ before(async () => {
110
+ root = await mkdtemp(path.join(tmpdir(), 'sidecar-loader-'))
111
+ layoutsFolder = path.join(root, 'layouts')
112
+ await mkdir(layoutsFolder, { recursive: true })
113
+ await writeFile(path.join(layoutsFolder, 'order.js'),
114
+ 'export const load = () => ({ page: 1 })\n'
115
+ + 'export const call = async ({ action }) => ({ handled: action })\n'
116
+ + 'export const read = async ({ uri }) => ({ uri })\n'
117
+ + 'export const list = async () => [{ uri: "app://order/rows" }]\n')
118
+ await writeFile(path.join(layoutsFolder, 'plain.js'), 'export const load = () => ({})\n')
119
+ })
120
+
121
+ after(async () => { await rm(root, { recursive: true, force: true }) })
122
+
123
+ it('returns the module with every named export a sidecar declares', async () => {
124
+ const sidecar = await loadSidecarModule({ name: 'order', inputs: { shared: 'd1' } }, { layoutsFolder })
125
+ assert.equal(typeof sidecar.load, 'function', 'the render reads load')
126
+ for (const name of ['call', 'read', 'list']) {
127
+ assert.equal(typeof sidecar[name], 'function', `the apps surface reads ${name}`)
128
+ }
129
+ assert.deepEqual(await sidecar.call({ action: 'approve' }), { handled: 'approve' })
130
+ })
131
+
132
+ it('returns null for a layout with no sidecar, rather than throwing', async () => {
133
+ assert.equal(await loadSidecarModule({ name: 'absent', inputs: {} }, { layoutsFolder }), null)
134
+ })
135
+
136
+ it('returns null when it is asked without a layout or a folder', async () => {
137
+ assert.equal(await loadSidecarModule(null, { layoutsFolder }), null)
138
+ assert.equal(await loadSidecarModule({ name: 'order' }, {}), null)
139
+ })
140
+
141
+ it('leaves a sidecar without the apps exports alone', async () => {
142
+ // Every existing sidecar is this shape. Reading `call` off one must be
143
+ // undefined, not an error — the apps surface decides what to do.
144
+ const sidecar = await loadSidecarModule({ name: 'plain', inputs: { shared: 'd1' } }, { layoutsFolder })
145
+ assert.equal(typeof sidecar.load, 'function')
146
+ assert.equal(sidecar.call, undefined)
147
+ })
148
+ })