mikser-io 6.21.4 → 6.23.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/README.md CHANGED
@@ -4,7 +4,23 @@
4
4
 
5
5
  # Mikser
6
6
 
7
- Mikser is a content engine for Node.js built around a strict lifecycle, a composable plugin system, and direct control over every output. Every document, asset, and template flows through the same deterministic pipeline. Plugins hook in at any phase; nothing runs outside the cycle. It scales from a single markdown blog to a multi-language, multi-format publishing platform — and stays predictable in both directions.
7
+ **Mikser is the content layer of your application.** Business logic, user accounts, transactions live in their own services; mikser handles the parts that *are* content — pages, docs, the published catalog, multi-format outputs. The SDKs are the seam between them.
8
+
9
+ Built for Node.js around a strict lifecycle, a composable plugin system, and direct control over every output. Every document, asset, and template flows through the same deterministic pipeline. Plugins hook in at any phase; nothing runs outside the cycle. It scales from a single markdown blog to a multi-language, multi-format publishing platform — and stays predictable in both directions.
10
+
11
+ ## Where it fits
12
+
13
+ Mikser is a focused component, not a backend. Think of it like a database in your stack: defined surface, content-shaped responsibilities, the app code lives separately and reaches in through a small typed interface.
14
+
15
+ | | Strapi / Payload / Sanity / Contentful | Mikser as content layer |
16
+ |---|---|---|
17
+ | **Role** | "Be the backend" — content + relationships + sometimes business logic | One component of the app, specifically the content piece |
18
+ | **Boundary** | Soft — they invite business logic into the CMS (computed fields, hooks, workflows) | Hard — files in, rendered output out; business logic isn't here |
19
+ | **Coupling** | App tied to the CMS vendor | App owns business logic independently; the content source can be swapped |
20
+ | **Storage** | Vendor's database, vendor's schema | Plain `.md` / `.yml` files on disk — diffable, portable, takeable on day one and year ten |
21
+ | **Migration risk** | High when the vendor reinvents itself (Strapi v3→v4, etc.) | Content is files, business logic is yours — neither is exposed to the other's churn |
22
+
23
+ Build mikser into the parts of your application that are content-shaped. Keep the rest where it belongs.
8
24
 
9
25
  ## Why mikser
10
26
 
@@ -14,7 +30,7 @@ Mikser is a content engine for Node.js built around a strict lifecycle, a compos
14
30
 
15
31
  **Concurrent rendering.** Renders fan out across a worker pool that keeps every CPU core hot. Multi-format outputs (HTML, PDF, MJML email, etc.) generate in parallel from the same source.
16
32
 
17
- **One lifecycle, everything composes.** Plugins hook into 20+ named lifecycle phases. A search-indexing plugin shares the same journal iteration as a CMS plugin and an email-rendering plugin — no glue code, no orchestration layer.
33
+ **One lifecycle, everything composes.** Plugins hook into 20+ named lifecycle phases. A search-indexing plugin shares the same journal iteration as an email-rendering plugin and a PDF-postprocessing plugin — no glue code, no orchestration layer. The engine doesn't know which plugins are loaded; plugins don't have to know about each other.
18
34
 
19
35
  **Run anywhere.** The same CLI handles one-shot builds, watch-mode dev loops, and a long-running HTTP server with a shared Express app. `npx mikser` ships a static site; `mikser --watch` is the dev loop; `mikser --server` exposes a live admin/API.
20
36
 
@@ -22,9 +38,27 @@ Mikser is a content engine for Node.js built around a strict lifecycle, a compos
22
38
 
23
39
  **Open source.** MIT-licensed, on GitHub, no telemetry, no auth wall, no SaaS dependency. What you see is what runs.
24
40
 
25
- ## Plugin ecosystem
41
+ ## Built for AI-assisted development
42
+
43
+ Files-as-source isn't just a portability story — it makes the project unusually friendly to AI coding agents. Most setups lose time configuring an agent's access to the data: tokens, schemas, MCP servers, sandboxed query environments. Mikser sidesteps all of it because the content is text files the agent can already read with the tools it already has.
44
+
45
+ **Zero infra friction for discovery.** An agent can `rg "type: product"` across the content tree to find every product doc in a second. No DB connection, no API token, no schema file to parse.
46
+
47
+ **The schema emerges from examples, not a definition file.** Front-matter shows what fields exist *in the docs that exist*. Markdown + YAML are overwhelmingly well-represented in AI training data, so the model "speaks" them fluently and infers structure from real documents better than from a schema definition.
26
48
 
27
- Plugins are independent npm packages — install only what a project actually uses.
49
+ **Determinism shortens the iteration loop.** Save a file → watcher fires → predictable rebuild. No DB triggers, no surprise cache invalidation, no API quotas. The agent's mental model of "what happens next" can be precise instead of probabilistic.
50
+
51
+ **The SDK's `.d.ts` is the read-side contract.** When the agent writes frontend query code, the operator subset and envelope shape are right there in types — a step-change for code generation quality versus "go read the REST API docs."
52
+
53
+ **Plugin-by-example.** Authoring a new plugin? There are 15+ existing ones in the same shape to pattern-match against. Convention is dense enough that new plugins look like the old ones without coaching.
54
+
55
+ The honest caveat: this advantage is real on **content-shaped work** — adding pages, restructuring collections, generating new layouts, building frontends. It doesn't make mikser better for non-content tasks (concurrency bugs in the worker pool, database tuning elsewhere in your stack); those are plain Node debugging like anywhere else. The visibility advantage also degrades past ~10k documents — at that scale the agent queries via the SDK instead of grepping the tree, which is still good but less "see everything at once."
56
+
57
+ ## Plugins on top of the engine
58
+
59
+ The engine is what stays stable — the lifecycle, the catalog, the file-based content model. Plugins are independent npm packages sitting on the plugin API: some are essential to the SSG workflow, some give external systems HTTP access to the catalog, some are integrations that earn their keep on real projects, and some are probes that test how far the lifecycle stretches without touching the core. Install what a project needs; drop what it doesn't.
60
+
61
+ **Core — sources, layouts, renderers, postprocessors:**
28
62
 
29
63
  | Plugin | What it does |
30
64
  |---|---|
@@ -34,12 +68,38 @@ Plugins are independent npm packages — install only what a project actually us
34
68
  | `render-resource`, `render-asset`, `render-href` | Resource / asset / link rewriting at render time |
35
69
  | `post-pdf` | HTML → PDF via headless Chromium |
36
70
  | `post-mjml` | MJML email markup → inbox-safe HTML |
37
- | `data` | JSON snapshots of entities / context / catalog over HTTP |
38
- | `api` | REST endpoints — list / get / create / update / delete / render |
71
+
72
+ **HTTP access to the catalog:**
73
+
74
+ | Plugin | What it does |
75
+ |---|---|
76
+ | `data` | JSON snapshots of entities / context / catalog, written to disk for static serving |
77
+ | `api` | REST endpoints with sift-backed queries, per-endpoint tokens, optional render |
78
+
79
+ **Integrations:**
80
+
81
+ | Plugin | What it does |
82
+ |---|---|
39
83
  | `vector` | OpenAI embeddings + semantic search (sqlite-vec or pgvector) |
40
- | `decap` | [Decap CMS](https://decapcms.org/) mounted in the same process |
41
84
  | `archive`, `mapper`, `live`, `aml` | Specialty integrations |
42
85
 
86
+ **Integration probes** — wrap a substantial external project as a plugin to confirm the lifecycle is open enough to host it without core changes. Treat these as feasibility evidence, not as a statement about where mikser is heading:
87
+
88
+ | Plugin | What it does |
89
+ |---|---|
90
+ | `decap` | Mounts [Decap CMS](https://decapcms.org/) inside the same Express server — admin UI + local proxy backend + bake-to-`out/` for static deploys (~150 lines, zero engine changes) |
91
+
92
+ ## Client SDKs
93
+
94
+ The `api` and `vector` plugins are paired with small client-side SDKs so a frontend (or another Node app) can talk to a running mikser server without rolling its own `fetch` glue. Zero dependencies, runs in browsers / Node 18+ / Deno / Bun / Workers.
95
+
96
+ | Package | For the plugin | What you get |
97
+ |---|---|---|
98
+ | [`mikser-io-sdk-api`](https://github.com/almero-digital-marketing/mikser-io-sdk-api) | `api` | `entities(name).list / query / urlFor / pages / update / delete / render` — Mongo-style filter operators backed by sift, sort, projection, pagination |
99
+ | [`mikser-io-sdk-vector`](https://github.com/almero-digital-marketing/mikser-io-sdk-vector) | `vector` | `vector(storeName).findSimilar(text, { limit })` — semantic search hits with the original mapped object attached |
100
+
101
+ Each SDK ships TypeScript declarations so client projects get autocomplete on filters, envelopes, and the `MikserError` thrown on non-2xx responses. Install only the one(s) a project needs.
102
+
43
103
  ## Quick Start
44
104
 
45
105
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "6.21.4",
3
+ "version": "6.23.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": {
@@ -46,6 +46,7 @@
46
46
  "pino": "^10.3.1",
47
47
  "pino-pretty": "^13.1.3",
48
48
  "piscina": "^5.1.4",
49
+ "sift": "^17.1.3",
49
50
  "sqlite3": "^6.0.1",
50
51
  "truncate-stream": "^1.0.2",
51
52
  "yaml": "^2.8.4"
package/src/engine.js CHANGED
@@ -47,6 +47,8 @@ export async function setup(options) {
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
49
  .option('-s --server [port]', 'start an Express server on the given port (defaults to 3001)')
50
+ .option('--cors [origin]', 'restrict server CORS to a specific origin (default *)')
51
+ .option('--no-cors', 'disable server CORS headers')
50
52
 
51
53
  Object.assign(runtime.options, options || runtime.engine.commander.parse(process.argv).opts())
52
54
  runtime.options.info = true
@@ -117,6 +119,32 @@ export async function setup(options) {
117
119
  ? 3001
118
120
  : Number(runtime.options.server) || 3001
119
121
  logger.info('Server starting on port %d', runtime.options.port)
122
+
123
+ // CORS — a server exists to be fetched, and in dev the
124
+ // frontend is almost always on a different origin (a dev
125
+ // server on another port, a separate domain). So CORS is ON
126
+ // by default with Access-Control-Allow-Origin: *. The token
127
+ // on /api (not CORS) is what gates mutations, and '*' can't
128
+ // carry credentials, so this is low-risk. Pin it down with
129
+ // --cors <origin> / config.server.cors, or disable entirely
130
+ // with --no-cors / config.server.cors:false (recommended for
131
+ // private/admin deployments). Mounted first so it covers
132
+ // static routes and every plugin router.
133
+ // Default on (?? true) so programmatic setup({ server }) —
134
+ // which bypasses commander's --no-cors default — matches the
135
+ // CLI. Explicit false (config or --no-cors) still disables.
136
+ const corsOrigin = runtime.config.server?.cors ?? runtime.options.cors ?? true
137
+ if (corsOrigin) {
138
+ const origin = corsOrigin === true ? '*' : String(corsOrigin)
139
+ runtime.options.app.use((req, res, next) => {
140
+ res.header('Access-Control-Allow-Origin', origin)
141
+ res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
142
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
143
+ if (req.method === 'OPTIONS') return res.sendStatus(204)
144
+ next()
145
+ })
146
+ logger.info('CORS enabled: %s', origin)
147
+ }
120
148
  }
121
149
  })
122
150
 
@@ -1,7 +1,123 @@
1
1
  import path from 'node:path'
2
2
  import { access } from 'node:fs/promises'
3
+ import _ from 'lodash'
4
+ import sift from 'sift'
3
5
  import { useRenderer, useCollection } from '../api.js'
4
6
 
7
+ // Mongo-style operators recognised in URL query params as `<path>.$<op>=...`.
8
+ // $in / $nin take comma-separated values; $exists takes a truthy/falsy
9
+ // string; everything else coerces the value (numbers, booleans, null) and
10
+ // hands it to sift. Anything sift accepts works on the POST body path too.
11
+ const QUERY_OPS = new Set([
12
+ 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'exists', 'regex',
13
+ ])
14
+
15
+ function coerceScalar(v) {
16
+ if (typeof v !== 'string') return v
17
+ if (v === 'true') return true
18
+ if (v === 'false') return false
19
+ if (v === 'null') return null
20
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v)
21
+ return v
22
+ }
23
+
24
+ function coerceForOp(value, op) {
25
+ if (op === 'in' || op === 'nin') {
26
+ return String(value).split(',').map(s => coerceScalar(s.trim()))
27
+ }
28
+ if (op === 'exists') return value === 'true' || value === '1'
29
+ if (op === 'regex') return String(value)
30
+ return coerceScalar(value)
31
+ }
32
+
33
+ // "name,-date" → { name: 1, date: -1 }
34
+ function parseSortString(spec) {
35
+ const sort = {}
36
+ if (!spec) return sort
37
+ for (const raw of String(spec).split(',')) {
38
+ const t = raw.trim()
39
+ if (!t) continue
40
+ if (t.startsWith('-')) sort[t.slice(1)] = -1
41
+ else sort[t.replace(/^\+/, '')] = 1
42
+ }
43
+ return sort
44
+ }
45
+
46
+ // "id,meta.title" → ['id', 'meta.title']
47
+ function parseFieldsString(spec) {
48
+ if (!spec) return null
49
+ return String(spec).split(',').map(s => s.trim()).filter(Boolean)
50
+ }
51
+
52
+ // Reserved query-string keys that aren't filter fields.
53
+ const RESERVED = new Set(['page', 'limit', 'skip', 'sort', 'fields'])
54
+
55
+ function parseQueryString(params) {
56
+ const filter = {}
57
+ let page = 1, limit, skip, sort, fields
58
+ for (const [key, raw] of Object.entries(params)) {
59
+ if (key === 'page') { page = Math.max(1, parseInt(raw) || 1); continue }
60
+ if (key === 'limit') { limit = parseInt(raw); continue }
61
+ if (key === 'skip') { skip = parseInt(raw); continue }
62
+ if (key === 'sort') { sort = parseSortString(raw); continue }
63
+ if (key === 'fields') { fields = parseFieldsString(raw); continue }
64
+
65
+ // <path>.$<op>=... groups under the same dotted-path key so
66
+ // multiple operators on one field compose:
67
+ // meta.price.$gt=10&meta.price.$lt=50 → { 'meta.price': { $gt:10, $lt:50 } }
68
+ // We use dotted keys (Mongo-style) rather than nested objects so
69
+ // sift treats them as path matchers, not deep-equality.
70
+ const m = key.match(/^(.+)\.\$([a-zA-Z]+)$/)
71
+ if (m && QUERY_OPS.has(m[2])) {
72
+ const [, fieldPath, op] = m
73
+ const ops = (filter[fieldPath] && typeof filter[fieldPath] === 'object' && !Array.isArray(filter[fieldPath]))
74
+ ? filter[fieldPath]
75
+ : {}
76
+ ops[`$${op}`] = coerceForOp(raw, op)
77
+ filter[fieldPath] = ops
78
+ } else {
79
+ filter[key] = coerceScalar(raw)
80
+ }
81
+ }
82
+ return { filter, page, limit, skip, sort, fields }
83
+ }
84
+
85
+ // Apply filter → endpoint scope → sort → skip/limit → projection.
86
+ // `findEntities()` returns the live in-memory catalog; for ≤100k docs the
87
+ // per-request scan is plenty fast. Backing this with an index becomes
88
+ // interesting past that — same endpoint contract.
89
+ async function runQuery({ filter, sort, fields, skip, limit, scope, findEntities }) {
90
+ let all = await findEntities()
91
+ if (scope) all = all.filter(scope)
92
+
93
+ if (filter && Object.keys(filter).length) {
94
+ const match = sift(filter)
95
+ all = all.filter(match)
96
+ }
97
+
98
+ const total = all.length
99
+
100
+ if (sort && Object.keys(sort).length) {
101
+ const entries = Object.entries(sort)
102
+ all.sort((a, b) => {
103
+ for (const [key, dir] of entries) {
104
+ const av = _.get(a, key)
105
+ const bv = _.get(b, key)
106
+ if (av == null && bv == null) continue
107
+ if (av == null) return 1
108
+ if (bv == null) return -1
109
+ if (av < bv) return -dir
110
+ if (av > bv) return dir
111
+ }
112
+ return 0
113
+ })
114
+ }
115
+
116
+ let items = all.slice(skip, skip + limit)
117
+ if (fields?.length) items = items.map(e => _.pick(e, fields))
118
+ return { items, total }
119
+ }
120
+
5
121
  // MIME type lookup used when streaming a postprocessor's output back over
6
122
  // HTTP. The renderer's output extension lives on entity.destination
7
123
  // (assigned by the layouts plugin), so we use it as the source of truth.
@@ -64,11 +180,36 @@ export async function sendRenderOutput(res, output, entity) {
64
180
  return res.json(result)
65
181
  }
66
182
 
183
+ // Subscription bookkeeping shared across all endpoints. Each entry holds
184
+ // the live Express response, the compiled sift filter, the endpoint
185
+ // scope, and a heartbeat timer. Per-cycle the onFinalized hook iterates
186
+ // the journal and dispatches matching changes to every subscription.
187
+ const subscriptions = new Map()
188
+ let subscriptionCounter = 0
189
+
190
+ function sseInit(res) {
191
+ res.setHeader('Content-Type', 'text/event-stream')
192
+ res.setHeader('Cache-Control', 'no-cache, no-transform')
193
+ res.setHeader('Connection', 'keep-alive')
194
+ res.setHeader('X-Accel-Buffering', 'no') // disable nginx buffering
195
+ if (typeof res.flushHeaders === 'function') res.flushHeaders()
196
+ }
197
+
198
+ function sseSend(res, eventName, payload) {
199
+ try {
200
+ res.write(`event: ${eventName}\n`)
201
+ res.write(`data: ${JSON.stringify(payload)}\n\n`)
202
+ } catch { /* connection dropped — cleanup runs via 'close' */ }
203
+ }
204
+
67
205
  export default ({
68
206
  runtime,
69
207
  onLoaded,
208
+ onFinalize,
70
209
  useLogger,
210
+ useJournal,
71
211
  findEntities,
212
+ constants: { OPERATION },
72
213
  }) => {
73
214
  onLoaded(async () => {
74
215
  const logger = useLogger()
@@ -86,99 +227,251 @@ export default ({
86
227
  )
87
228
  }
88
229
 
230
+ const endpoints = runtime.config.api?.endpoints
231
+ if (!endpoints || !Object.keys(endpoints).length) {
232
+ logger.warn('Api plugin loaded but no endpoints configured (api.endpoints) — nothing to mount')
233
+ return
234
+ }
235
+
89
236
  const { default: express } = await import('express').catch(() => {
90
237
  throw new Error('Express is required for the api plugin — run: npm install express')
91
238
  })
92
239
 
93
- const router = express.Router()
94
- router.use(express.json())
240
+ const base = runtime.config.api?.base ?? '/api'
241
+ const globalPageSize = runtime.config.api?.pageSize ?? 10
242
+ const globalRenderTimeout = runtime.config.api?.renderTimeout ?? 30_000
95
243
 
96
- const token = runtime.config.api?.token
97
- const auth = (req, res, next) => {
98
- if (!token) return next()
99
- if (req.headers.authorization === `Bearer ${token}`) return next()
100
- res.status(401).json({ error: 'Unauthorized' })
101
- }
244
+ // The plugin mirrors the vector / data plugin shape: a named map
245
+ // of endpoints, each with its own optional `token`, `query`
246
+ // scope, allowed `operations`, and overrides for pageSize /
247
+ // renderTimeout. Each endpoint mounts under <base>/<name>.
248
+ //
249
+ // api.endpoints.public { query: e => e.meta?.published, operations: ['list'] }
250
+ // api.endpoints.admin { token: '...', operations: ['list','update','delete','render'] }
251
+ for (const [name, ep] of Object.entries(endpoints)) {
252
+ const router = express.Router()
253
+ router.use(express.json())
102
254
 
103
- // Reuse the transport-agnostic primitives from src/api.js so the
104
- // library entry point and the Api endpoints share the exact same
105
- // batching/timeouts/error semantics.
106
- const { render } = useRenderer(runtime, {
107
- defaultTimeout: runtime.config.api?.renderTimeout ?? 30_000,
108
- })
255
+ // Operations default to the safer shape when no token is set
256
+ // (read-only) and full access when token-gated. `subscribe`
257
+ // holds an open SSE connection per client — opt in for
258
+ // public endpoints; included in the token-gated default
259
+ // because tokens already gate the trust. Explicit
260
+ // `operations` always wins.
261
+ const defaultOps = ep.token
262
+ ? ['list', 'update', 'delete', 'render', 'subscribe']
263
+ : ['list']
264
+ const allowedOps = new Set(ep.operations ?? defaultOps)
109
265
 
110
- router.get('/entities', async (req, res) => {
111
- try {
112
- const { page: rawPage, limit: rawLimit, ...filter } = req.query
113
- const page = Math.max(1, parseInt(rawPage) || 1)
114
- const limit = Math.min(100, Math.max(1, parseInt(rawLimit) || (runtime.config.api?.pageSize ?? 10)))
115
- const query = Object.keys(filter).length ? filter : undefined
116
-
117
- const all = await findEntities(query)
118
- const total = all.length
119
- const totalPages = Math.ceil(total / limit)
120
- const items = all.slice((page - 1) * limit, page * limit)
121
-
122
- res.json({
123
- items,
124
- page,
125
- limit,
126
- total,
127
- totalPages,
128
- hasNext: page < totalPages,
129
- hasPrev: page > 1,
130
- })
131
- } catch (err) {
132
- logger.error('Api list error: %s', err.message)
133
- res.status(500).json({ error: err.message })
134
- }
135
- })
266
+ const query = typeof ep.query === 'function' ? ep.query : null
267
+ const pageSize = ep.pageSize ?? globalPageSize
268
+ const renderTimeout = ep.renderTimeout ?? globalRenderTimeout
136
269
 
137
- router.put('/entities', auth, async (req, res) => {
138
- try {
139
- const { collection, relativePath, content = '' } = req.body
140
- await useCollection(runtime, collection).write(relativePath, content)
141
- res.status(202).json({ ok: true })
142
- } catch (err) {
143
- logger.error('Api update error: %s', err.message)
144
- res.status(/Unknown collection/.test(err.message) ? 400 : 500).json({ error: err.message })
270
+ const auth = (req, res, next) => {
271
+ if (!ep.token) return next()
272
+ if (req.headers.authorization === `Bearer ${ep.token}`) return next()
273
+ res.status(401).json({ error: 'Unauthorized' })
145
274
  }
146
- })
147
275
 
148
- router.delete('/entities', auth, async (req, res) => {
149
- try {
150
- const { collection, relativePath } = req.body
151
- await useCollection(runtime, collection).remove(relativePath)
152
- res.status(202).json({ ok: true })
153
- } catch (err) {
154
- logger.error('Api delete error: %s', err.message)
155
- res.status(/Unknown collection/.test(err.message) ? 400 : 500).json({ error: err.message })
276
+ const allow = (op) => (req, res, next) => {
277
+ if (!allowedOps.has(op)) {
278
+ return res.status(403).json({
279
+ error: `Operation '${op}' is not allowed on endpoint '${name}'`,
280
+ })
281
+ }
282
+ next()
156
283
  }
157
- })
158
284
 
159
- router.post('/render', auth, async (req, res) => {
160
- try {
161
- // Body shape mirrors the JS API: entity fields at top
162
- // level, control flags grouped under `options`. Forwarded
163
- // straight to render(entity, options). Defaults match
164
- // mikser's lifecycle (save and keep the catalog row);
165
- // strict opt-outs via the literal `false`:
166
- // options.catalog: false → prune the catalog row
167
- // options.save: false → skip the final disk write
168
- // (bytes still in the response)
169
- const { options = {}, ...entityShape } = req.body
170
- const { output, entity } = await render(entityShape, options)
171
- await sendRenderOutput(res, output, entity)
172
- } catch (err) {
173
- logger.error('Api render error: %s', err.message)
174
- if (!res.headersSent) {
285
+ // Reuse the transport-agnostic primitives from src/api.js so
286
+ // the library entry point and the HTTP endpoints share the
287
+ // exact same batching/timeouts/error semantics. One renderer
288
+ // per endpoint so per-endpoint renderTimeout overrides land.
289
+ const { render } = useRenderer(runtime, { defaultTimeout: renderTimeout })
290
+
291
+ router.get('/entities', allow('list'), auth, async (req, res) => {
292
+ try {
293
+ const parsed = parseQueryString(req.query)
294
+ const limit = Math.min(100, Math.max(1, parsed.limit ?? pageSize))
295
+ const skip = parsed.skip ?? (parsed.page - 1) * limit
296
+
297
+ const { items, total } = await runQuery({
298
+ filter: parsed.filter,
299
+ sort: parsed.sort,
300
+ fields: parsed.fields,
301
+ skip,
302
+ limit,
303
+ scope: query,
304
+ findEntities,
305
+ })
306
+
307
+ const page = Math.floor(skip / limit) + 1
308
+ const totalPages = Math.ceil(total / limit) || 1
309
+ res.json({
310
+ items, page, limit, total, totalPages,
311
+ hasNext: skip + limit < total,
312
+ hasPrev: skip > 0,
313
+ })
314
+ } catch (err) {
315
+ logger.error('Api[%s] list error: %s', name, err.message)
175
316
  res.status(500).json({ error: err.message })
176
317
  }
177
- }
178
- })
318
+ })
179
319
 
180
- const base = runtime.config.api?.base ?? '/api'
181
- app.use(base, router)
182
- logger.info('Api mounted: %s', base)
320
+ // POST /entities/query — body-based query for anything that
321
+ // doesn't fit cleanly in a URL: $and/$or, nested operators,
322
+ // regex, projections, etc. Same shape as a Mongo find.
323
+ router.post('/entities/query', allow('list'), auth, async (req, res) => {
324
+ try {
325
+ const { filter = {}, sort, fields, page: rawPage = 1, limit: rawLimit, skip: rawSkip } = req.body ?? {}
326
+ const limit = Math.min(100, Math.max(1, rawLimit ?? pageSize))
327
+ const skip = rawSkip ?? (Math.max(1, parseInt(rawPage) || 1) - 1) * limit
328
+
329
+ const { items, total } = await runQuery({
330
+ filter, sort, fields, skip, limit,
331
+ scope: query,
332
+ findEntities,
333
+ })
334
+
335
+ const page = Math.floor(skip / limit) + 1
336
+ const totalPages = Math.ceil(total / limit) || 1
337
+ res.json({
338
+ items, page, limit, total, totalPages,
339
+ hasNext: skip + limit < total,
340
+ hasPrev: skip > 0,
341
+ })
342
+ } catch (err) {
343
+ logger.error('Api[%s] query error: %s', name, err.message)
344
+ res.status(500).json({ error: err.message })
345
+ }
346
+ })
347
+
348
+ // GET /entities/subscribe — open an SSE stream. Each subsequent
349
+ // process cycle (file-watcher fired OR programmatic API write)
350
+ // emits create/update/delete events for entities matching the
351
+ // subscription's filter and the endpoint's scope. Heartbeats
352
+ // every 25s keep proxies happy. Connection close cleans up.
353
+ router.get('/entities/subscribe', allow('subscribe'), auth, (req, res) => {
354
+ let filterFn = null
355
+ try {
356
+ const parsed = parseQueryString(req.query)
357
+ if (Object.keys(parsed.filter).length) filterFn = sift(parsed.filter)
358
+ } catch (err) {
359
+ return res.status(400).json({ error: `Invalid filter: ${err.message}` })
360
+ }
361
+
362
+ sseInit(res)
363
+ const subscriptionId = `sub_${Date.now()}_${++subscriptionCounter}`
364
+ sseSend(res, 'init', { subscriptionId, endpoint: name })
365
+
366
+ // Heartbeat — silent enough to not confuse the SDK but
367
+ // frequent enough that idle proxies don't kill the
368
+ // connection.
369
+ const heartbeat = setInterval(() => sseSend(res, 'heartbeat', {}), 25_000)
370
+ if (typeof heartbeat.unref === 'function') heartbeat.unref()
371
+
372
+ subscriptions.set(subscriptionId, {
373
+ endpointName: name,
374
+ scope: query,
375
+ filter: filterFn,
376
+ res,
377
+ })
378
+
379
+ req.on('close', () => {
380
+ clearInterval(heartbeat)
381
+ subscriptions.delete(subscriptionId)
382
+ logger.debug('Api[%s] subscription closed: %s', name, subscriptionId)
383
+ })
384
+
385
+ logger.debug('Api[%s] subscription opened: %s', name, subscriptionId)
386
+ })
387
+
388
+ router.put('/entities', allow('update'), auth, async (req, res) => {
389
+ try {
390
+ const { collection, relativePath, content = '' } = req.body
391
+ await useCollection(runtime, collection).write(relativePath, content)
392
+ res.status(202).json({ ok: true })
393
+ } catch (err) {
394
+ logger.error('Api[%s] update error: %s', name, err.message)
395
+ res.status(/Unknown collection/.test(err.message) ? 400 : 500).json({ error: err.message })
396
+ }
397
+ })
398
+
399
+ router.delete('/entities', allow('delete'), auth, async (req, res) => {
400
+ try {
401
+ const { collection, relativePath } = req.body
402
+ await useCollection(runtime, collection).remove(relativePath)
403
+ res.status(202).json({ ok: true })
404
+ } catch (err) {
405
+ logger.error('Api[%s] delete error: %s', name, err.message)
406
+ res.status(/Unknown collection/.test(err.message) ? 400 : 500).json({ error: err.message })
407
+ }
408
+ })
409
+
410
+ router.post('/render', allow('render'), auth, async (req, res) => {
411
+ try {
412
+ // Body shape mirrors the JS API: entity fields at top
413
+ // level, control flags grouped under `options`. Forwarded
414
+ // straight to render(entity, options). Defaults match
415
+ // mikser's lifecycle (save and keep the catalog row);
416
+ // strict opt-outs via the literal `false`:
417
+ // options.catalog: false → prune the catalog row
418
+ // options.save: false → skip the final disk write
419
+ // (bytes still in the response)
420
+ const { options = {}, ...entityShape } = req.body
421
+ // When the endpoint declares a scope, reject anything
422
+ // outside it BEFORE pushing through the renderer.
423
+ if (query && !query(entityShape)) {
424
+ return res.status(403).json({ error: 'Entity is outside this endpoint\'s scope' })
425
+ }
426
+ const { output, entity } = await render(entityShape, options)
427
+ await sendRenderOutput(res, output, entity)
428
+ } catch (err) {
429
+ logger.error('Api[%s] render error: %s', name, err.message)
430
+ if (!res.headersSent) {
431
+ res.status(500).json({ error: err.message })
432
+ }
433
+ }
434
+ })
435
+
436
+ app.use(`${base}/${name}`, router)
437
+ logger.info('Api endpoint mounted: %s/%s (ops=[%s] %s)',
438
+ base, name, [...allowedOps].join(','),
439
+ ep.token ? '[token]' : '[public]')
440
+ }
441
+ })
442
+
443
+ // Dispatcher: once per lifecycle cycle, walk the journal and push
444
+ // matching CREATE/UPDATE/DELETE events to every active subscription
445
+ // (regardless of which endpoint opened it). Each subscription has
446
+ // its own scope + filter; we apply both before sending. Empty when
447
+ // nothing's subscribed, so it costs ~nothing in normal builds.
448
+ //
449
+ // Hook onFinalize, NOT onFinalized — journal.js registers its own
450
+ // clearJournal callback on onFinalized at module load, which runs
451
+ // before plugin onFinalized hooks. By Finalize we still have the
452
+ // cycle's journal entries; by Finalized they're already gone.
453
+ onFinalize(async (signal) => {
454
+ if (!subscriptions.size) return
455
+ const logger = useLogger()
456
+ const evMap = {
457
+ [OPERATION.CREATE]: 'create',
458
+ [OPERATION.UPDATE]: 'update',
459
+ [OPERATION.DELETE]: 'delete',
460
+ }
461
+ for await (const { operation, entity } of useJournal(
462
+ 'Api subscriptions',
463
+ [OPERATION.CREATE, OPERATION.UPDATE, OPERATION.DELETE],
464
+ signal,
465
+ )) {
466
+ for (const [subId, sub] of subscriptions) {
467
+ if (sub.scope && !sub.scope(entity)) continue
468
+ if (sub.filter && !sub.filter(entity)) continue
469
+ const payload = operation === OPERATION.DELETE
470
+ ? { id: entity.id }
471
+ : { id: entity.id, entity }
472
+ sseSend(sub.res, evMap[operation], payload)
473
+ logger.trace('Api subscription %s %s: %s', subId, evMap[operation], entity.id)
474
+ }
475
+ }
183
476
  })
184
477
  }