mikser-io 7.8.2 → 7.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "7.8.2",
3
+ "version": "7.10.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/src/api.js CHANGED
@@ -7,6 +7,31 @@ import { writeFile, unlink, mkdir } from 'node:fs/promises'
7
7
  import path from 'node:path'
8
8
  import './lifecycle.js' // side-effect: attaches runtime.create / update / delete
9
9
 
10
+ // Build the error thrown when a submitted entity goes through a full
11
+ // process cycle but never renders. useRenderer can't see *why* — by the
12
+ // time it gives up, the journal is cleared — but it knows what it
13
+ // submitted, so it branches on whether a layout was even requested and
14
+ // points at the log line carrying the authoritative reason (the layouts
15
+ // plugin warns when a render-requested entity matches no layout).
16
+ //
17
+ // `.status = 422` (Unprocessable Entity): the request was well-formed
18
+ // but the entity can't be rendered as submitted. The API endpoint reads
19
+ // err.status (defaulting to 500) so a no-layout render returns a client
20
+ // error, not a server fault.
21
+ function incompleteRenderError(entity) {
22
+ const id = entity?.id ?? '<unknown>'
23
+ const msg = entity?.meta?.layout
24
+ ? `Render did not complete for ${id}: it requested layout "${entity.meta.layout}" ` +
25
+ `but produced no output. Check the log for a "Layout not found" warning (the name ` +
26
+ `may not exist) or a "Render error" (the layout matched but threw).`
27
+ : `Render did not complete for ${id}: the entity has no meta.layout and matched no ` +
28
+ `layout, so nothing rendered it. Set meta.layout, add a layouts.match rule, or name ` +
29
+ `it to match a layout (auto-layout).`
30
+ const err = new Error(msg)
31
+ err.status = 422
32
+ return err
33
+ }
34
+
10
35
  /**
11
36
  * Bind to the runtime and return an on-demand renderer that pipelines
12
37
  * concurrent calls into the minimum number of `runtime.process()` cycles.
@@ -79,7 +104,7 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
79
104
  } finally {
80
105
  for (const item of remaining.values()) {
81
106
  clearTimeout(item.timer)
82
- item.reject(new Error(`Render did not complete for ${item.entity.id}`))
107
+ item.reject(incompleteRenderError(item.entity))
83
108
  }
84
109
  const idx = completedHooks.indexOf(hook)
85
110
  if (idx >= 0) completedHooks.splice(idx, 1)
package/src/engine.js CHANGED
@@ -131,7 +131,7 @@ export async function setup(options) {
131
131
  runtime.options.port = runtime.options.server === true
132
132
  ? 3001
133
133
  : Number(runtime.options.server) || 3001
134
- logger.info('Server starting on port %d', runtime.options.port)
134
+ logger.debug('Server starting on port %d', runtime.options.port)
135
135
 
136
136
  // Trust-proxy: when mikser is behind a reverse proxy
137
137
  // (nginx, Caddy, an Express app, ngrok with edge), the
@@ -211,7 +211,7 @@ export async function setup(options) {
211
211
  // workers, useLogger consumers) gains the side-channel
212
212
  // automatically — no second logger to thread through.
213
213
  wireLoggerToMcp(runtime.engine.logger, runtime.options.mcp)
214
- logger.info('MCP substrate ready (mounts at %s when server is up)', runtime.options.mcpPath)
214
+ logger.debug('MCP substrate ready (mounts at %s when server is up)', runtime.options.mcpPath)
215
215
  } catch (err) {
216
216
  logger.error('Failed to enable MCP: %s', err.message)
217
217
  }
@@ -627,7 +627,9 @@ export default ({
627
627
  } catch (err) {
628
628
  logger.error('Api[%s] render error: %s', name, err.message)
629
629
  if (!res.headersSent) {
630
- res.status(500).json({ error: err.message })
630
+ // useRenderer tags an unrenderable entity (no layout)
631
+ // with err.status = 422; everything else is a 500.
632
+ res.status(err.status ?? 500).json({ error: err.message })
631
633
  }
632
634
  }
633
635
  })
@@ -1,5 +1,7 @@
1
1
  import path from 'node:path'
2
2
  import { mkdir, writeFile, unlink, rm, readFile, symlink, } from 'fs/promises'
3
+ import { existsSync } from 'node:fs'
4
+ import { createRequire } from 'node:module'
3
5
  import { globby } from 'globby'
4
6
  import _ from 'lodash'
5
7
  import map from 'p-map'
@@ -42,6 +44,50 @@ export default ({
42
44
  return entityPresets
43
45
  }
44
46
 
47
+ // Resolve a preset name to an importable module location. Local
48
+ // files in presetsFolder win; names with no local file fall back to
49
+ // an npm package named `mikser-io-preset-<name>`, resolved from the
50
+ // project's node_modules. Mirrors how postprocess.js resolves
51
+ // post-* plugins — local override first, then the npm convention.
52
+ // Returns { uri, watchable } or null when neither exists.
53
+ //
54
+ // `watchable` distinguishes the two lifetimes: local presets are
55
+ // re-imported (cache-busted) on every load so watch-mode edits take
56
+ // effect; npm presets are versioned by their package, imported once.
57
+ function resolvePreset(name) {
58
+ const local = path.join(runtime.options.presetsFolder, `${name}.js`)
59
+ if (existsSync(local)) {
60
+ return { uri: local, watchable: true }
61
+ }
62
+ try {
63
+ const require = createRequire(path.join(runtime.options.workingFolder, 'package.json'))
64
+ const uri = require.resolve(`mikser-io-preset-${name}`)
65
+ return { uri, watchable: false }
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ // Import a preset module and build its catalog entity. One place for
72
+ // the (revision, format, options) export contract so onImport and
73
+ // onSync stay in sync. Cache-busts local presets so watch-mode edits
74
+ // reload; npm presets import once (their version is the cache key).
75
+ async function buildPreset({ name, uri, watchable }) {
76
+ const cacheBust = watchable ? `?stamp=${Date.now()}` : ''
77
+ const { revision = 1, format, options } = await import(`${uri}${cacheBust}`)
78
+ return {
79
+ id: `/presets/${name}`,
80
+ collection,
81
+ type,
82
+ uri,
83
+ name,
84
+ source: uri,
85
+ format,
86
+ checksum: revision,
87
+ options,
88
+ }
89
+ }
90
+
45
91
  async function getRevisions(entity) {
46
92
  let revisions = await globby(`${entity.destination.replaceAll('(', '\\(').replaceAll(')', '\\)')}.*.md5`, {
47
93
  cwd: path.join(runtime.options.assetsFolder, entity.preset.name),
@@ -147,26 +193,16 @@ export default ({
147
193
  const logger = useLogger()
148
194
  const { presets } = runtime.state.assets
149
195
 
196
+ // Watch only fires for files in presetsFolder, so these are
197
+ // always local presets — watchable: true.
150
198
  const name = relativePath.replace(path.extname(relativePath), '')
151
199
  const uri = path.join(runtime.options.presetsFolder, relativePath)
152
- const source = uri
153
200
 
154
201
  let synced = true
155
202
  switch (action) {
156
203
  case ACTION.CREATE:
157
204
  try {
158
- const { revision = 1, format, options } = await import(`${uri}?stamp=${Date.now()}`)
159
- const preset = {
160
- id: path.join('/presets', relativePath),
161
- collection,
162
- type,
163
- uri,
164
- name: relativePath.replace(path.extname(relativePath), ''),
165
- source,
166
- format,
167
- checksum: revision,
168
- options
169
- }
205
+ const preset = await buildPreset({ name, uri, watchable: true })
170
206
  presets[name] = preset
171
207
  await createEntity(preset)
172
208
  } catch (err) {
@@ -176,19 +212,12 @@ export default ({
176
212
  break
177
213
  case ACTION.UPDATE:
178
214
  try {
179
- const { revision = 1, format, options } = await import(`${uri}?stamp=${Date.now()}`)
180
- const preset = {
181
- id: path.join('/presets', relativePath),
182
- collection,
183
- type,
184
- uri,
185
- name: relativePath.replace(path.extname(relativePath), ''),
186
- checksum: revision,
187
- source,
188
- format,
189
- options
190
- }
191
- if (!preset[name]) {
215
+ const preset = await buildPreset({ name, uri, watchable: true })
216
+ // Was `!preset[name]` a typo for `!presets[name]`
217
+ // that made every UPDATE take the create branch
218
+ // (preset[name] is always undefined). Fixed: only
219
+ // re-emit when the revision actually changed.
220
+ if (!presets[name]) {
192
221
  presets[name] = preset
193
222
  await createEntity(preset)
194
223
  } else if (presets[name].checksum != preset.checksum) {
@@ -218,33 +247,45 @@ export default ({
218
247
  const logger = useLogger()
219
248
  const { presets } = runtime.state.assets
220
249
 
250
+ // Local presets: scan the presets folder. Loads every *.js
251
+ // there, keyed by filename. Local always wins.
221
252
  const paths = await globby('*.js', { cwd: runtime.options.presetsFolder })
222
253
  for (let relativePath of paths) {
254
+ const name = relativePath.replace(path.extname(relativePath), '')
223
255
  const uri = path.join(runtime.options.presetsFolder, relativePath)
224
- const source = uri
225
256
  try {
226
- const { revision = 1, format, options } = await import(`${uri}?stamp=${Date.now()}`)
227
- const name = relativePath.replace(path.extname(relativePath), '')
228
-
229
- const preset = {
230
- id: path.join('/presets', relativePath),
231
- collection,
232
- type,
233
- uri,
234
- name: relativePath.replace(path.extname(relativePath), ''),
235
- source,
236
- format,
237
- checksum: revision,
238
- options,
239
- options
240
- }
241
-
257
+ const preset = await buildPreset({ name, uri, watchable: true })
242
258
  await createEntity(preset)
243
259
  presets[name] = preset
244
260
  } catch (err) {
245
261
  logger.error(err, 'Preset loading error: %s', uri)
246
262
  }
247
263
  }
264
+
265
+ // npm presets: any preset name referenced in config that no
266
+ // local file already provided resolves to an npm package
267
+ // `mikser-io-preset-<name>`. Config is the source of truth for
268
+ // which presets a project uses; this fills the names a folder
269
+ // scan can't (the code lives in node_modules, not presets/).
270
+ for (const name of Object.keys(runtime.config.assets?.presets || {})) {
271
+ if (presets[name]) continue // a local file already loaded it
272
+ const resolved = resolvePreset(name)
273
+ if (!resolved) {
274
+ logger.error(
275
+ 'Preset not found: %s (no presets/%s.js, no npm package mikser-io-preset-%s)',
276
+ name, name, name,
277
+ )
278
+ continue
279
+ }
280
+ try {
281
+ const preset = await buildPreset({ name, uri: resolved.uri, watchable: resolved.watchable })
282
+ await createEntity(preset)
283
+ presets[name] = preset
284
+ logger.debug('Preset loaded from npm: mikser-io-preset-%s', name)
285
+ } catch (err) {
286
+ logger.error(err, 'Preset loading error (npm): mikser-io-preset-%s', name)
287
+ }
288
+ }
248
289
  })
249
290
 
250
291
  onProcessed(async (signal) => {
@@ -304,6 +304,19 @@ export default ({
304
304
  if (entity.meta?.layout && !entity.layout) {
305
305
  logger.warn('Layout not found for %s: %s', entity.collection, entity.id)
306
306
  }
307
+ // A render-requested entity (carries useRenderer's
308
+ // correlationId) that resolved to no layout will
309
+ // silently produce nothing — the caller just gets
310
+ // api.js's "did not complete". Surface the real reason
311
+ // here, where we authoritatively know no layout matched.
312
+ // Gated on correlationId so the thousands of normal
313
+ // layout-less content files stay quiet.
314
+ if (!entity.layout && entity.options?.correlationId) {
315
+ logger.warn(
316
+ 'Render requested for %s but no layout matched — set meta.layout, add a layouts.match rule, or name it to match a layout (auto-layout). Entities without a layout are not rendered.',
317
+ entity.id,
318
+ )
319
+ }
307
320
 
308
321
  if (entity.layout && entity.meta?.postprocessor) {
309
322
  entity.layout.postprocessor = entity.meta.postprocessor