mikser-io 6.9.4 → 6.15.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": "6.9.4",
3
+ "version": "6.15.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": {
@@ -57,6 +57,7 @@
57
57
  "devDependencies": {
58
58
  "fluent-ffmpeg": "^2.1.3",
59
59
  "mikser-io-post-mjml": "file:../mikser-io-post-mjml",
60
+ "mikser-io-render-eta": "file:../mikser-io-render-eta",
60
61
  "mikser-io-render-liquid": "file:../mikser-io-render-liquid",
61
62
  "mikser-io-render-markdown": "file:../mikser-io-render-markdown",
62
63
  "sharp": "^0.34.5"
package/src/api.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { randomUUID } from 'node:crypto'
6
6
  import { writeFile, unlink, mkdir } from 'node:fs/promises'
7
7
  import path from 'node:path'
8
- import { updateEntity as defaultUpdateEntity } from './lifecycle.js'
8
+ import './lifecycle.js' // side-effect: attaches runtime.create / update / delete
9
9
 
10
10
  /**
11
11
  * Bind to the runtime and return an on-demand renderer that pipelines
@@ -20,14 +20,10 @@ import { updateEntity as defaultUpdateEntity } from './lifecycle.js'
20
20
  *
21
21
  * @param {object} runtime - the mikser runtime singleton
22
22
  * @param {object} [opts]
23
- * @param {Function} [opts.updateEntity] - override lifecycle.updateEntity (mostly for testing)
24
- * @param {number} [opts.defaultTimeout] - per-render timeout in ms (default 30_000)
23
+ * @param {number} [opts.defaultTimeout] - per-render timeout in ms (default 30_000)
25
24
  * @returns {{ render: (entity, { timeout? }?) => Promise<{ output, entity }> }}
26
25
  */
27
- export function useRenderer(runtime, {
28
- updateEntity = defaultUpdateEntity,
29
- defaultTimeout = 30_000,
30
- } = {}) {
26
+ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
31
27
  let pending = []
32
28
  let cycleRunning = false
33
29
 
@@ -61,7 +57,7 @@ export function useRenderer(runtime, {
61
57
 
62
58
  try {
63
59
  for (const item of batch) {
64
- await updateEntity(item.entity).catch(item.reject)
60
+ await runtime.update(item.entity).catch(item.reject)
65
61
  }
66
62
  await runtime.process()
67
63
  } catch (err) {
@@ -91,16 +87,41 @@ export function useRenderer(runtime, {
91
87
  * `runtime.process()` cycle — within that cycle, mikser's worker pool
92
88
  * renders the batch in parallel.
93
89
  *
90
+ * Two control flags mirror mikser's default-keep-everything behavior;
91
+ * both opt-out via strict `=== false`:
92
+ *
93
+ * - `catalog: true` (default) — keep the entity in the catalog after
94
+ * the render. Pass `catalog: false` to prune the catalog row;
95
+ * useful for on-demand renders where the metadata row would just
96
+ * accumulate.
97
+ * - `save: true` (default) — write the rendered output to disk at
98
+ * `<outputFolder>/<entity.destination>`. Pass `save: false` to
99
+ * skip the final disk write; the bytes still come back via
100
+ * `output.result` for you to pipe wherever you want (HTTP
101
+ * response, S3, …). For layouts with a postprocessor (e.g.
102
+ * `*.html-pdf.*`), the intermediate file is still written so the
103
+ * postprocessor can consume it; only the FINAL output is skipped.
104
+ *
105
+ * The rendered output's bytes are always returned in `output.result`
106
+ * regardless of either flag — `save` only affects whether they also
107
+ * end up on disk.
108
+ *
94
109
  * @param {object} entity - any entity-shaped object
95
110
  * @param {object} [opts]
96
- * @param {number} [opts.timeout] - override the default timeout
111
+ * @param {number} [opts.timeout] - override the default timeout
112
+ * @param {boolean} [opts.catalog=true] - keep the catalog row after render
113
+ * @param {boolean} [opts.save=true] - write the rendered output to disk
97
114
  * @returns {Promise<{output, entity}>}
98
115
  */
99
- function render(entity, { timeout = defaultTimeout } = {}) {
100
- return new Promise((resolve, reject) => {
116
+ async function render(entity, { timeout = defaultTimeout, catalog = true, save = true } = {}) {
117
+ const result = await new Promise((resolve, reject) => {
101
118
  const correlationId = randomUUID()
119
+ const stamped = { ...entity, _correlationId: correlationId }
120
+ // Only stamp _save when explicitly opting out — keeps the
121
+ // entity object clean for the common case.
122
+ if (save === false) stamped._save = false
102
123
  pending.push({
103
- entity: { ...entity, _correlationId: correlationId },
124
+ entity: stamped,
104
125
  correlationId,
105
126
  timeout,
106
127
  resolve,
@@ -109,6 +130,24 @@ export function useRenderer(runtime, {
109
130
  })
110
131
  if (!cycleRunning) setImmediate(runBatch)
111
132
  })
133
+
134
+ if (catalog === false) {
135
+ // Explicit opt-out: prune the catalog row so it doesn't
136
+ // accumulate. The rendered output file stays on disk — the
137
+ // bytes are the work product. We deliberately bypass the
138
+ // journal/DELETE path here (which would also unlink the file
139
+ // via engine.js's manifest cleanup) and splice the entity
140
+ // out of the in-memory catalog directly. Strict equality so
141
+ // ambiguous inputs (null, "false", 0) fall through to the
142
+ // default of keeping the row.
143
+ const entities = runtime.catalog?.data?.entities
144
+ if (entities) {
145
+ const idx = entities.findIndex(e => e.id === result.entity.id)
146
+ if (idx >= 0) entities.splice(idx, 1)
147
+ }
148
+ }
149
+
150
+ return result
112
151
  }
113
152
 
114
153
  return { render }
package/src/journal.js CHANGED
@@ -69,7 +69,13 @@ export async function* useJournal(name, operations, signal) {
69
69
  export async function clearJournal(aborted) {
70
70
  await journal('operations').del()
71
71
  if (!aborted) {
72
- if (runtime.options.watch !== true) {
72
+ // Tear down the sqlite connection only when this is genuinely a
73
+ // one-shot run that's about to exit. Watch mode and any plugin
74
+ // that keeps the process alive (e.g. the API plugin's HTTP server)
75
+ // sets `persistent` so subsequent cycles can still write to the
76
+ // journal — without this, the second /render request would crash
77
+ // with "Unable to acquire a connection".
78
+ if (runtime.options.watch !== true && runtime.options.persistent !== true) {
73
79
  journal.destroy()
74
80
  }
75
81
  }
package/src/lifecycle.js CHANGED
@@ -34,6 +34,15 @@ export async function updateEntity(entity) {
34
34
  }
35
35
  }
36
36
 
37
+ // Expose the three entity operations as methods on the runtime so library
38
+ // code (useRenderer, custom plugins) can call `runtime.update(entity)`
39
+ // instead of importing and threading the standalone functions. The
40
+ // imports above already pulled the runtime singleton into scope; this is
41
+ // just a single assignment per name.
42
+ runtime.create = createEntity
43
+ runtime.update = updateEntity
44
+ runtime.delete = deleteEntity
45
+
37
46
  export async function postprocessEntity(entity, options = {}, context = {}) {
38
47
  const logger = useLogger()
39
48
  const entry = { operation: OPERATION.POSTPROCESS, entity, options, context }
@@ -68,7 +68,6 @@ export default ({
68
68
  runtime,
69
69
  onLoaded,
70
70
  useLogger,
71
- updateEntity,
72
71
  findEntities,
73
72
  }) => {
74
73
  onLoaded(async () => {
@@ -95,7 +94,6 @@ export default ({
95
94
  // library entry point and the Api endpoints share the exact same
96
95
  // batching/timeouts/error semantics.
97
96
  const { render } = useRenderer(runtime, {
98
- updateEntity,
99
97
  defaultTimeout: runtime.config.api?.renderTimeout ?? 30_000,
100
98
  })
101
99
 
@@ -150,7 +148,16 @@ export default ({
150
148
 
151
149
  router.post('/render', auth, async (req, res) => {
152
150
  try {
153
- const { output, entity } = await render(req.body)
151
+ // Body shape mirrors the JS API: entity fields at top
152
+ // level, control flags grouped under `options`. Forwarded
153
+ // straight to render(entity, options). Defaults match
154
+ // mikser's lifecycle (save and keep the catalog row);
155
+ // strict opt-outs via the literal `false`:
156
+ // options.catalog: false → prune the catalog row
157
+ // options.save: false → skip the final disk write
158
+ // (bytes still in the response)
159
+ const { options = {}, ...entityShape } = req.body
160
+ const { output, entity } = await render(entityShape, options)
154
161
  await sendRenderOutput(res, output, entity)
155
162
  } catch (err) {
156
163
  logger.error('Api render error: %s', err.message)
@@ -160,6 +167,11 @@ export default ({
160
167
  }
161
168
  })
162
169
 
170
+ // The HTTP server keeps the process alive across many process()
171
+ // cycles. Tell the journal layer not to tear down the sqlite
172
+ // connection at the end of each cycle.
173
+ runtime.options.persistent = true
174
+
163
175
  const base = runtime.config.api?.base ?? '/api'
164
176
  app.use(base, router)
165
177
 
@@ -370,13 +370,32 @@ export default ({
370
370
  onComplete(async ({ entity, options, output }) => {
371
371
  const logger = useLogger()
372
372
  if (entity.layout && !options?.ignore && output.result != null) {
373
- const destinationFile = path.join(runtime.options.outputFolder, entity.destination)
374
- await mkdir(path.dirname(destinationFile), { recursive: true })
375
- try {
376
- await unlink(destinationFile)
377
- } catch { }
378
- await writeFile(destinationFile, output.result)
379
- logger.debug('Layout render finished: %s', entity.destination.replace(runtime.options.workingFolder, ''))
373
+ // `_save: false` (stamped by useRenderer when called with
374
+ // { save: false }) opts out of writing the final output to
375
+ // disk. The bytes still come back to the caller via
376
+ // output.result. Strict equality — only the literal `false`
377
+ // opts out, matching the catalog-flag pattern.
378
+ //
379
+ // The intermediate file (when a postprocessor will run next)
380
+ // must still be written so the postprocessor can consume it,
381
+ // so we only honour `_save: false` for the FINAL output —
382
+ // detected as either "no postprocessor configured" or "we're
383
+ // running on the postprocess side already" (origin set).
384
+ const isFinal = !entity.layout.postprocessor || entity.origin != null
385
+ const skipWrite = entity._save === false && isFinal
386
+
387
+ if (!skipWrite) {
388
+ const destinationFile = path.join(runtime.options.outputFolder, entity.destination)
389
+ await mkdir(path.dirname(destinationFile), { recursive: true })
390
+ try {
391
+ await unlink(destinationFile)
392
+ } catch { }
393
+ await writeFile(destinationFile, output.result)
394
+ logger.debug('Layout render finished: %s', entity.destination.replace(runtime.options.workingFolder, ''))
395
+ } else {
396
+ logger.debug('Layout render finished (save:false, bytes only): %s', entity.id)
397
+ }
398
+
380
399
  if (entity.origin && entity.origin !== entity.destination) {
381
400
  // Don't unlink the origin if it was the same path we just
382
401
  // wrote to (post plugins that produce the same extension as
package/src/tracking.js CHANGED
@@ -55,7 +55,7 @@ export function stopProgress() {
55
55
  logger.warn('%s unfinished: %d', name, total - value)
56
56
  } else {
57
57
  const time = Math.round((Date.now() - progress.stamp) / 1000)
58
- logger.info('%s: %s', name, formatTime(time, { autopaddingChar: '' }))
58
+ logger.info('%s finished: %d %s', name, total, formatTime(time, { autopaddingChar: '' }))
59
59
  }
60
60
  progress = {}
61
61
  }