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 +67 -7
- package/package.json +2 -1
- package/src/engine.js +28 -0
- package/src/plugins/api.js +372 -79
package/README.md
CHANGED
|
@@ -4,7 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
# Mikser
|
|
6
6
|
|
|
7
|
-
Mikser is
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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.
|
|
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
|
|
package/src/plugins/api.js
CHANGED
|
@@ -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
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
}
|