mikser-io 6.15.0 → 6.21.2

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.15.0",
3
+ "version": "6.21.2",
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": {
@@ -60,6 +60,7 @@
60
60
  "mikser-io-render-eta": "file:../mikser-io-render-eta",
61
61
  "mikser-io-render-liquid": "file:../mikser-io-render-liquid",
62
62
  "mikser-io-render-markdown": "file:../mikser-io-render-markdown",
63
+ "mikser-io-vector": "file:../mikser-io-vector",
63
64
  "sharp": "^0.34.5"
64
65
  },
65
66
  "directories": {
package/src/engine.js CHANGED
@@ -6,7 +6,7 @@ import { existsSync } from 'fs'
6
6
  import _ from 'lodash'
7
7
  import Piscina from 'piscina'
8
8
  import runtime from './runtime.js'
9
- import { onInitialize, onInitialized, onRender, onCancel, onCancelled, onFinalized, onLoaded, onAfterRender, onBeforePostprocess, onPostprocess, postprocessEntities } from './lifecycle.js'
9
+ import { onInitialize, onInitialized, onLoad, onRender, onCancel, onCancelled, onFinalized, onLoaded, onAfterRender, onBeforePostprocess, onPostprocess, postprocessEntities } from './lifecycle.js'
10
10
  import { useJournal, updateEntry } from './journal.js'
11
11
  import { globby } from 'globby'
12
12
  import { OPERATION, TASKS } from './constants.js'
@@ -46,6 +46,7 @@ export async function setup(options) {
46
46
  .option('-d --debug', 'display debug statements')
47
47
  .option('-t --trace', 'display trace statements')
48
48
  .option('-e --runtime-folder <folder>', 'set mikser runtime folder relative to working folder', 'runtime')
49
+ .option('-s --server [port]', 'start an Express server on the given port (defaults to 3001)')
49
50
 
50
51
  Object.assign(runtime.options, options || runtime.engine.commander.parse(process.argv).opts())
51
52
  runtime.options.info = true
@@ -84,6 +85,73 @@ export async function setup(options) {
84
85
  }
85
86
  }
86
87
  await mkdir(runtime.options.runtimeFolder, { recursive: true })
88
+
89
+ // Server bring-up: two paths, controlled by which of these are set.
90
+ //
91
+ // runtime.options.app — pre-supplied Express app (e.g. mikser
92
+ // embedded inside an existing service).
93
+ // The caller owns the listen lifecycle
94
+ // and the static-route policy. Engine
95
+ // stays out of routing/listening so it
96
+ // doesn't clobber their setup; plugins
97
+ // still mount their own routers on it.
98
+ //
99
+ // --server [port] — engine creates the Express app, mounts
100
+ // (runtime.options.server) static for the output folder, and
101
+ // listens on the port. The actual
102
+ // listen() is deferred via the onLoad
103
+ // hook below so it runs LAST in the
104
+ // onLoaded phase — after every plugin
105
+ // has had a chance to register routes.
106
+ //
107
+ // If both are present, the externally-supplied app wins; --server
108
+ // becomes a no-op (the caller is in charge).
109
+ if (runtime.options.app) {
110
+ logger.info('Using externally-supplied Express app on runtime.options.app')
111
+ } else if (runtime.options.server) {
112
+ const { default: express } = await import('express').catch(() => {
113
+ throw new Error('Express is required for --server. Run: npm install express')
114
+ })
115
+ runtime.options.app = express()
116
+ runtime.options.port = runtime.options.server === true
117
+ ? 3001
118
+ : Number(runtime.options.server) || 3001
119
+ logger.info('Server starting on port %d', runtime.options.port)
120
+ }
121
+ })
122
+
123
+ // Registered here (inside setup) so it runs AFTER plugins.js's onLoad
124
+ // (which is registered at module-import time). That ordering matters
125
+ // because plugins.js loads user plugins during its onLoad — each plugin
126
+ // factory may register onLoaded handlers that mount routes on
127
+ // runtime.options.app. We want our listen() to run LAST in the
128
+ // onLoaded phase, which is why we register it from inside another
129
+ // onLoad — by then plugins have already appended their handlers.
130
+ onLoad(() => {
131
+ // Only auto-mount static + auto-listen when the engine owns the
132
+ // app (created via --server). When an external app was supplied,
133
+ // `port` is unset and we stay out of the way — the caller manages
134
+ // both routing decisions and the listen lifecycle themselves.
135
+ if (!runtime.options.app || runtime.options.port == null) return
136
+ onLoaded(async () => {
137
+ const logger = useLogger()
138
+ const { default: express } = await import('express')
139
+
140
+ // Serve the output folder as the catch-all static route.
141
+ // Mounted LAST in the middleware chain so plugin routes
142
+ // (e.g. /api/*) match first; anything that didn't match a
143
+ // plugin's router falls through to the static handler and
144
+ // gets served from <outputFolder>.
145
+ runtime.options.app.use(express.static(runtime.options.outputFolder))
146
+ logger.info('Serving %s as /', runtime.options.outputFolder.replace(runtime.options.workingFolder + '/', ''))
147
+
148
+ await new Promise(resolve => {
149
+ runtime.options.app.listen(runtime.options.port, () => {
150
+ logger.info('Server listening on port %d', runtime.options.port)
151
+ resolve()
152
+ })
153
+ })
154
+ })
87
155
  })
88
156
 
89
157
  onLoaded(async () => {
package/src/journal.js CHANGED
@@ -70,12 +70,12 @@ export async function clearJournal(aborted) {
70
70
  await journal('operations').del()
71
71
  if (!aborted) {
72
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
+ // one-shot run that's about to exit. The two ways mikser stays
74
+ // alive across cycles are watch mode (chokidar handles keep the
75
+ // event loop ref'd) and an HTTP server (`runtime.options.app`,
76
+ // set either by --server or by a caller passing setup({ app }));
77
+ // both need the journal to survive subsequent cycles.
78
+ if (runtime.options.watch !== true && !runtime.options.app) {
79
79
  journal.destroy()
80
80
  }
81
81
  }
@@ -73,13 +73,23 @@ export default ({
73
73
  onLoaded(async () => {
74
74
  const logger = useLogger()
75
75
 
76
+ // The api plugin no longer creates its own Express app. It mounts
77
+ // onto an existing app provided either by --server (engine
78
+ // creates one) or by a caller that programmatically passed
79
+ // setup({ app: ... }). Without an app there's nowhere to mount
80
+ // routes, so fail fast with a message that points at the fix.
81
+ const app = runtime.options.app
82
+ if (!app) {
83
+ throw new Error(
84
+ 'API plugin requires runtime.options.app — run mikser with --server, ' +
85
+ 'or pass { app: yourExpressInstance } to setup() before loading the api plugin'
86
+ )
87
+ }
88
+
76
89
  const { default: express } = await import('express').catch(() => {
77
- throw new Error('express is required for the api plugin — run: npm install express')
90
+ throw new Error('Express is required for the api plugin — run: npm install express')
78
91
  })
79
92
 
80
- const ownApp = !runtime.options.app
81
- const app = runtime.options.app ?? express()
82
-
83
93
  const router = express.Router()
84
94
  router.use(express.json())
85
95
 
@@ -167,22 +177,8 @@ export default ({
167
177
  }
168
178
  })
169
179
 
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
-
175
180
  const base = runtime.config.api?.base ?? '/api'
176
181
  app.use(base, router)
177
-
178
- if (ownApp) {
179
- const port = runtime.config.api?.port ?? 3001
180
- const url = runtime.config.api?.url ?? 'http://localhost'
181
- app.listen(port, () => {
182
- logger.info('Api listening: %s:%d%s', url, port, base)
183
- })
184
- } else {
185
- logger.info('Api mounted: %s', base)
186
- }
182
+ logger.info('Api mounted: %s', base)
187
183
  })
188
184
  }
@@ -36,9 +36,18 @@ export default ({
36
36
  }
37
37
  }
38
38
  for (let entitiesName in entitiesConfig) {
39
+ // Per-config namespacing token. When set, this entity-writer's
40
+ // files land under <dataFolder>/<token>/ instead of straight
41
+ // in <dataFolder>. Each named entity config can declare its
42
+ // own token, so they can target different namespaces (useful
43
+ // for multi-tenant exports into shared storage).
44
+ const token = entitiesConfig[entitiesName].token
45
+ const targetFolder = token
46
+ ? path.join(runtime.options.dataFolder, token)
47
+ : runtime.options.dataFolder
39
48
  const {
40
49
  query,
41
- map,
50
+ map: mapEntity = entity => entity,
42
51
  pick,
43
52
  save: saveEntity = async entity => {
44
53
  if (!entity.name) {
@@ -46,12 +55,12 @@ export default ({
46
55
  return
47
56
  }
48
57
  const dump = JSON.stringify(normalize(entity))
49
- const entityFile = path.join(runtime.options.dataFolder, `${entity.name}.${entitiesName}.json`)
58
+ const entityFile = path.join(targetFolder, `${entity.name}.${entitiesName}.json`)
50
59
  await mkdir(path.dirname(entityFile), { recursive: true })
51
60
  await writeFile(entityFile, dump, 'utf8')
52
61
  },
53
62
  delete: deleteEntity = async entity => {
54
- const entityFile = path.join(runtime.options.dataFolder, `${entity.name}.json`)
63
+ const entityFile = path.join(targetFolder, `${entity.name}.json`)
55
64
  await unlink(entityFile)
56
65
  }
57
66
  } = entitiesConfig[entitiesName]
@@ -62,12 +71,12 @@ export default ({
62
71
  case OPERATION.CREATE:
63
72
  case OPERATION.UPDATE:
64
73
  logger.debug('Data export entity %s %s: %s', entity.collection, operation, entity.id)
65
- await saveEntity(map ? await map(entity) : {
74
+ await saveEntity(({
66
75
  refId: ('/' + entity.name.replaceAll('\\', '/')).replace(/\/index$/g, '/'),
67
76
  name: entity.name,
68
77
  date: new Date(entity.time),
69
- data: _.pick(entity, pick || ['collection', 'format', 'type', 'destination', 'stamp', 'meta', 'id',])
70
- })
78
+ data: _.pick(await mapEntity(entity), pick || ['collection', 'format', 'type', 'destination', 'stamp', 'meta', 'id',])
79
+ }))
71
80
  break
72
81
  case OPERATION.DELETE:
73
82
  await deleteEntity(entity)
@@ -90,14 +99,19 @@ export default ({
90
99
  }
91
100
  }
92
101
  for (let contextName in contextConfig) {
102
+ // Per-config namespacing token — see the entities loop above.
103
+ const token = contextConfig[contextName].token
104
+ const targetFolder = token
105
+ ? path.join(runtime.options.dataFolder, token)
106
+ : runtime.options.dataFolder
93
107
  const {
94
108
  query,
95
- map,
109
+ map: mapEntityContext = (entity, context) => context,
96
110
  pick,
97
111
  save: saveConext = async (entity, context) => {
98
112
  if (context?.data) {
99
113
  const entityName = entity.name
100
- const contextFile = path.join(runtime.options.dataFolder, `${entityName}.${contextName}.json`)
114
+ const contextFile = path.join(targetFolder, `${entityName}.${contextName}.json`)
101
115
  await mkdir(path.dirname(contextFile), { recursive: true })
102
116
  await writeFile(contextFile, JSON.stringify(context), 'utf8')
103
117
  }
@@ -107,7 +121,7 @@ export default ({
107
121
  for await (let { entity, context } of useJournal('Data context', [OPERATION.RENDER])) {
108
122
  if (query(entity)) {
109
123
  logger.debug('Data export context: %s', entity.name)
110
- await saveConext(entity, map ? await map(entity, context) : _.pick(context, pick || ['data']))
124
+ await saveConext(entity, _.pick(await mapEntityContext(entity, context), pick || ['data']))
111
125
  }
112
126
  }
113
127
  }
@@ -116,23 +130,29 @@ export default ({
116
130
  onFinalize(async () => {
117
131
  const logger = useLogger()
118
132
  for (let catalogName in runtime.config.data?.catalog || {}) {
133
+ // Per-config namespacing token — see the entities loop above.
134
+ const token = runtime.config.data?.catalog[catalogName].token
135
+ const targetFolder = token
136
+ ? path.join(runtime.options.dataFolder, token)
137
+ : runtime.options.dataFolder
119
138
  const {
120
139
  query: queryEntities = entity => entity.type == 'document',
121
- map,
140
+ map: mapEntity = entity => entity,
122
141
  pick,
123
142
  save: saveEntities = async entities => {
124
- const entitiesFile = path.join(runtime.options.dataFolder, `${catalogName}.json`)
143
+ const entitiesFile = path.join(targetFolder, `${catalogName}.json`)
144
+ await mkdir(path.dirname(entitiesFile), { recursive: true })
125
145
  logger.debug('Data export catalog %s %s: %s', catalogName, entities.length, entitiesFile)
126
146
  await writeFile(entitiesFile, JSON.stringify(entities), 'utf8')
127
147
  }
128
148
  } = runtime.config.data?.catalog[catalogName]
129
149
  const entities = await findEntities(queryEntities)
130
- await saveEntities(await pMap(entities, async entity => map ? await map(entity) : {
150
+ await saveEntities(await pMap(entities, async entity => ({
131
151
  refId: ('/' + entity.name.replaceAll('\\', '/')).replace(/\/index$/g, '/'),
132
152
  name: entity.name,
133
153
  date: new Date(entity.time),
134
- data: _.pick(entity, pick || ['collection', 'format', 'type', 'destination', 'stamp', 'meta', 'id',])
135
- }))
154
+ data: _.pick(await mapEntity(entity), pick || ['collection', 'format', 'type', 'destination', 'stamp', 'meta', 'id',])
155
+ })))
136
156
  }
137
157
  })
138
158
  }
@@ -14,7 +14,7 @@ export async function setup({ config, logger }) {
14
14
  }
15
15
 
16
16
  const { default: puppeteer } = await import('puppeteer').catch(() => {
17
- throw new Error('puppeteer is required for the pdf postprocessor — run: npm install puppeteer')
17
+ throw new Error('Puppeteer is required for the pdf postprocessor — run: npm install puppeteer')
18
18
  })
19
19
  browser = await puppeteer.launch({
20
20
  headless: true,
@@ -24,6 +24,7 @@ export default ({
24
24
  trackProgress,
25
25
  updateProgress,
26
26
  updateEntry,
27
+ matchEntity,
27
28
  constants: { OPERATION },
28
29
  }) => {
29
30
  const collection = 'resources'
@@ -65,8 +66,7 @@ export default ({
65
66
  _.eachDeep(entity.meta, resource => {
66
67
  if (typeof resource == 'string') {
67
68
  for (let library in resourceLib) {
68
- const match = new RegExp(library)
69
- if (resource.match(match)) {
69
+ if (matchEntity(resource, library)) {
70
70
  resourceMap[entity.id].push({ library, resource, entity })
71
71
  }
72
72
  }
package/src/runtime.js CHANGED
@@ -77,7 +77,7 @@ const runtime = {
77
77
  else if (this.abortController) {
78
78
  await this.cancel()
79
79
  }
80
- this.mutex.use(async () => {
80
+ await this.mutex.use(async () => {
81
81
  try {
82
82
  this.abortController = new AbortController()
83
83
  const { signal } = this.abortController
package/src/utils.js CHANGED
@@ -49,13 +49,13 @@ export function matchEntity(entity, match) {
49
49
  if (typeof match == 'function') return match(entity)
50
50
  else if (typeof match == 'string') {
51
51
  if (match.substring(0, 2) == '@/') {
52
- return minimatch(entity.name, match.substring(2))
52
+ return minimatch(typeof entity == 'string' ? entity : entity.name, match.substring(2))
53
53
  } else {
54
- return minimatch(entity.id, match)
54
+ return minimatch(typeof entity == 'string' ? entity : entity.id, match)
55
55
  }
56
56
  }
57
57
  else if (typeof match == 'object') return _.isMatch(entity, match)
58
- throw new Error('Ivalid match type')
58
+ throw new Error('Invalid match type')
59
59
  }
60
60
 
61
61
  export function changeExtension(file, format) {