mikser-io 6.23.0 → 6.27.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 +55 -9
- package/app.js +1 -0
- package/package.json +1 -1
- package/src/plugins/api.js +245 -12
package/README.md
CHANGED
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
# Mikser
|
|
6
6
|
|
|
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.
|
|
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. [Vue](https://github.com/almero-digital-marketing/mikser-io-sdk-vue), [React](https://github.com/almero-digital-marketing/mikser-io-sdk-react), and [Svelte](https://github.com/almero-digital-marketing/mikser-io-sdk-svelte) SDKs are the seam between them — same surface across all three (`useDocument`, `useDocuments`, multilingual `useHref`, live SSE updates), each in its framework's idiomatic shape.
|
|
8
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.
|
|
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 with image / video / AI pipelines, live SSE-driven editors, semantic search, and typed frontend contracts — and stays predictable in both directions.
|
|
10
|
+
|
|
11
|
+
It's MIT-licensed, runs on Node 18+, has zero hosted dependencies, and the entire content tree it manages is a folder of `.md` and `.yml` files you can copy, diff, and version-control. **The portability promise is the architecture, not a feature.**
|
|
12
|
+
|
|
13
|
+
> **New to mikser?** Read the [Architecture Overview](./documentation/overview.md) — one document, end-to-end walkthrough of how a file becomes a deployed page across all twenty lifecycle phases. It's the doc most projects need first.
|
|
10
14
|
|
|
11
15
|
## Where it fits
|
|
12
16
|
|
|
@@ -30,10 +34,14 @@ Build mikser into the parts of your application that are content-shaped. Keep th
|
|
|
30
34
|
|
|
31
35
|
**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.
|
|
32
36
|
|
|
37
|
+
**Image, video, and AI pipelines authored as plugins — not configured.** The `assets` plugin runs user-written preset modules over binary inputs. A preset is a plain Node module: ~10 lines around `sharp` resize an image, ~10 lines around `fluent-ffmpeg` transcode a video, ~30 lines around the Replicate API run an AI upscaler. The pipeline isn't a fixed menu of operations — it's whatever Node can call. Most SSGs cap your asset processing at "resize and convert format." Mikser caps it at "what can Node do." Compose with the `resources` plugin and your DAM, CDN, or company content server becomes an upstream input — uploaded files end up transcoded, watermarked, AI-enhanced, and deployed without manual handoff. See [the assets / resources docs](./documentation/plugins.md#assets) for end-to-end examples.
|
|
38
|
+
|
|
33
39
|
**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.
|
|
34
40
|
|
|
35
41
|
**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.
|
|
36
42
|
|
|
43
|
+
**Survives backend outages.** The `api` plugin's [per-query disk cache](./documentation/caching.md) writes every cacheable list response to `out/` keyed by the request URL. A stock-nginx reverse proxy can fail over to the cached file when mikser is unreachable — same URL, transparent to the client, no Lua or extra modules required. Production frontends keep rendering routes during deploys, brief outages, and the upstream-blip-of-the-week. Opt in per endpoint with `cache: true`; see [Caching and reverse-proxy failover](./documentation/caching.md) for the working config.
|
|
44
|
+
|
|
37
45
|
**Library mode.** Mikser is also a library. `useRenderer`, `useCollection`, `findSimilar`, and direct lifecycle hooks let you embed the engine inside an existing Node app instead of running it as a CLI.
|
|
38
46
|
|
|
39
47
|
**Open source.** MIT-licensed, on GitHub, no telemetry, no auth wall, no SaaS dependency. What you see is what runs.
|
|
@@ -52,6 +60,8 @@ Files-as-source isn't just a portability story — it makes the project unusuall
|
|
|
52
60
|
|
|
53
61
|
**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
62
|
|
|
63
|
+
**One-shot bootstrap via Claude Code.** The [`mikser-io-claude-plugin`](https://github.com/almero-digital-marketing/mikser-io-claude-plugin) wraps the setup story above into a single skill. Register the repo as a Claude Code marketplace and install the plugin (`/plugin marketplace add almero-digital-marketing/mikser-io-claude-plugin` then `/plugin install mikser-io-claude-plugin@mikser-io`), then in any Vue 3, React, or SvelteKit project — or in a blank directory — say "add mikser to this app." It detects the framework (or scaffolds a fresh starter via `create-vite` / `sv create`), wires the matching framework SDK, composes with your existing router rather than replacing it, and optionally lays down a `mikser-content/` sibling folder with Zod schemas and starter documents so the backend works on first run.
|
|
64
|
+
|
|
55
65
|
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
66
|
|
|
57
67
|
## Plugins on top of the engine
|
|
@@ -74,13 +84,14 @@ The engine is what stays stable — the lifecycle, the catalog, the file-based c
|
|
|
74
84
|
| Plugin | What it does |
|
|
75
85
|
|---|---|
|
|
76
86
|
| `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 |
|
|
87
|
+
| `api` | REST endpoints with sift-backed queries, per-endpoint tokens, optional render, opt-in [per-query disk cache](./documentation/caching.md) for reverse-proxy failover |
|
|
78
88
|
|
|
79
89
|
**Integrations:**
|
|
80
90
|
|
|
81
91
|
| Plugin | What it does |
|
|
82
92
|
|---|---|
|
|
83
|
-
| `vector` | OpenAI embeddings + semantic search (sqlite-vec or pgvector) |
|
|
93
|
+
| [`mikser-io-vector`](https://github.com/almero-digital-marketing/mikser-io-vector) | OpenAI embeddings + semantic search (sqlite-vec or pgvector) |
|
|
94
|
+
| [`mikser-io-plugin-schemas`](https://github.com/almero-digital-marketing/mikser-io-plugin-schemas) | Zod-backed entity validation + auto-generated TypeScript declarations for the SDK |
|
|
84
95
|
| `archive`, `mapper`, `live`, `aml` | Specialty integrations |
|
|
85
96
|
|
|
86
97
|
**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:
|
|
@@ -91,14 +102,24 @@ The engine is what stays stable — the lifecycle, the catalog, the file-based c
|
|
|
91
102
|
|
|
92
103
|
## Client SDKs
|
|
93
104
|
|
|
94
|
-
The `api` and `
|
|
105
|
+
The `api`, `vector`, and `schemas` plugins are paired with client-side SDKs so a frontend (or another Node app) can talk to a running mikser server without rolling its own `fetch` glue or type contracts. Zero dependencies, runs in browsers / Node 18+ / Deno / Bun / Workers.
|
|
106
|
+
|
|
107
|
+
**Transport-level:**
|
|
95
108
|
|
|
96
109
|
| Package | For the plugin | What you get |
|
|
97
110
|
|---|---|---|
|
|
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 |
|
|
111
|
+
| [`mikser-io-sdk-api`](https://github.com/almero-digital-marketing/mikser-io-sdk-api) | `api` | `entities(name).list / query / urlFor / pages / update / delete / render / live` — Mongo-style filter operators backed by sift, sort, projection, pagination, SSE-driven live subscriptions |
|
|
99
112
|
| [`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
113
|
|
|
101
|
-
|
|
114
|
+
**Framework integrations** — all three wrap `mikser-io-sdk-api` in framework-idiomatic shapes. Same surface: `useDocument` / `useDocuments` live data, multilingual `useHref` / `useAlternates`, asset resolution via `useAsset`, generic on entity type so `mikser-io-plugin-schemas`-emitted types compose:
|
|
115
|
+
|
|
116
|
+
| Package | Framework | Notes |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| [`mikser-io-sdk-vue`](https://github.com/almero-digital-marketing/mikser-io-sdk-vue) | Vue 3 | Composables, vue-router integration (`useMikserRoutes` to augment an existing router, `generateMikserRoutes` for SSG prerender), provide/inject for the client. |
|
|
119
|
+
| [`mikser-io-sdk-react`](https://github.com/almero-digital-marketing/mikser-io-sdk-react) | React 18+ / 19+ | Hooks, `<MikserProvider>` Context, React Router v6+ integration via `useMikserRoutes` → `useRoutes()`. |
|
|
120
|
+
| [`mikser-io-sdk-svelte`](https://github.com/almero-digital-marketing/mikser-io-sdk-svelte) | Svelte 5 (runes) | `$state` / `$effect` reactives, SvelteKit-friendly `generateMikserRoutes` for `entries()` prerender, `useMikserPages` for live nav. |
|
|
121
|
+
|
|
122
|
+
Each SDK ships TypeScript declarations so client projects get autocomplete on filters, envelopes, and the `MikserError` thrown on non-2xx responses. Pair any of the framework SDKs with the `entities.d.ts` emitted by `mikser-io-plugin-schemas` for typed entity meta per layout. Install only the one(s) a project needs.
|
|
102
123
|
|
|
103
124
|
## Quick Start
|
|
104
125
|
|
|
@@ -130,19 +151,44 @@ npx mikser --server # build + serve at :3001
|
|
|
130
151
|
- **Runtime Singleton** — A plain module-level object holds all global state and coordinates the lifecycle. The ES module cache guarantees every importer gets the same instance.
|
|
131
152
|
- **Watch Mode** — In watch mode, file changes trigger incremental re-processing without restarting.
|
|
132
153
|
|
|
154
|
+
## What you can build with it
|
|
155
|
+
|
|
156
|
+
The shape mikser fits cleanly:
|
|
157
|
+
|
|
158
|
+
- **Marketing sites with editorial teams** — content authors work in files (via their editor, a Git client, or `mikser-io-decap`), engineers ship features without negotiating with a CMS schema, the site stays portable.
|
|
159
|
+
- **Multilingual publishing platforms** — the `useHref()` / `useAlternates()` pattern in `sdk-vue` decouples logical references from per-locale URLs. One source tree, many language deployments.
|
|
160
|
+
- **Content-heavy product catalogues** — `documents` + `mikser-io-plugin-schemas` + `data` plugin + a Vue frontend = typed product listings with live updates, semantic search via `vector`, and static-CDN-friendly JSON snapshots all at once.
|
|
161
|
+
- **AI-augmented media pipelines** — `assets` plugin presets call out to Replicate / OpenAI / local models to upscale images, transcribe audio, transcode video. The pipeline is JS code, so anything Node can do is in scope.
|
|
162
|
+
- **Mixed-output publishing** — the same source document renders to HTML, PDF (via `post-pdf`), MJML email (via `post-mjml`), and JSON snapshots. One catalog, many output formats, all concurrent.
|
|
163
|
+
- **Headless backends for static frontends** — pair the `api` plugin with `sdk-api` for SSE-driven live frontends; pair the `data` plugin output with any static host for pre-rendered consumption.
|
|
164
|
+
|
|
165
|
+
The shape mikser **doesn't** fit cleanly: anything with non-technical content authors who can't or won't work with files, anything with non-content business logic at the core, anything needing multi-tenant / per-user auth. Those aren't bugs — they're outside the design envelope. See [`decisions/0001-content-layer-not-the-app.md`](./documentation/decisions/0001-content-layer-not-the-app.md) for the explicit scope decision.
|
|
166
|
+
|
|
167
|
+
## Engineering discipline
|
|
168
|
+
|
|
169
|
+
A few things this project takes seriously:
|
|
170
|
+
|
|
171
|
+
- **ADRs for load-bearing decisions.** The [`decisions/`](./documentation/decisions/) folder names which choices are structural — files-as-source, journal+catalog split, plugin-as-factory, compose-via-protocols — and explains what protects them. Read those before proposing a feature that pushes against one.
|
|
172
|
+
- **Engine stability, plugin churn.** The 15+ plugins in the ecosystem add capability without core changes. The integration probes (e.g. `decap`, mounting a third-party CMS in ~150 lines) are deliberate evidence that the extension model holds.
|
|
173
|
+
- **Deterministic builds.** The journal is the synchronization primitive. There's no event-passing layer, no IoC container, no plugin orchestrator. The engine knows how to fire phases in order; everything else falls out of that.
|
|
174
|
+
- **The mental model is one document.** The [Architecture Overview](./documentation/overview.md) is one read for the full top-to-bottom picture. The reference docs exist for lookup; the overview exists for comprehension.
|
|
175
|
+
|
|
133
176
|
## Documentation Index
|
|
134
177
|
|
|
135
178
|
| Document | Audience | Description |
|
|
136
179
|
| ----------------------------------------------------- | ------------------ | -------------------------------------------------- |
|
|
180
|
+
| [Architecture Overview](./documentation/overview.md) | Everyone | **Start here.** End-to-end walkthrough of how a file becomes a deployed page across all lifecycle phases. |
|
|
137
181
|
| [Getting Started](./documentation/getting-started.md) | Users | Installation, first project, basic usage |
|
|
138
182
|
| [Configuration](./documentation/configuration.md) | Users | All CLI options and config file reference |
|
|
139
183
|
| [Lifecycle](./documentation/lifecycle.md) | Users & Developers | Complete lifecycle phases and hook system |
|
|
140
|
-
| [Plugins](./documentation/plugins.md) | Users & Developers | Built-in plugins, writing custom plugins
|
|
184
|
+
| [Plugins](./documentation/plugins.md) | Users & Developers | Built-in plugins, writing custom plugins, the assets / resources / AI pipeline |
|
|
141
185
|
| [Entities](./documentation/entities.md) | Users & Developers | Entity model, operations, journal, catalog |
|
|
142
186
|
| [Rendering](./documentation/rendering.md) | Users & Developers | Render pipeline, render plugins, render modes |
|
|
143
187
|
| [Watch Mode](./documentation/watch-mode.md) | Users | File watching, scheduled tasks, incremental builds |
|
|
144
|
-
| [
|
|
188
|
+
| [Caching](./documentation/caching.md) | Users (production) | The `cache: true` disk cache + working nginx config for reverse-proxy failover |
|
|
189
|
+
| [Architecture](./documentation/architecture.md) | Developers | Module-level reference — what's in each file |
|
|
145
190
|
| [API Reference](./documentation/api-reference.md) | Developers | Complete public API reference |
|
|
191
|
+
| [Decisions (ADRs)](./documentation/decisions/) | Developers | Load-bearing architectural choices and what protects them |
|
|
146
192
|
|
|
147
193
|
## License
|
|
148
194
|
|
package/app.js
CHANGED
package/package.json
CHANGED
package/src/plugins/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import { access } from 'node:fs/promises'
|
|
2
|
+
import { access, writeFile, mkdir, rm } from 'node:fs/promises'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
3
4
|
import _ from 'lodash'
|
|
4
5
|
import sift from 'sift'
|
|
5
6
|
import { useRenderer, useCollection } from '../api.js'
|
|
@@ -118,6 +119,88 @@ async function runQuery({ filter, sort, fields, skip, limit, scope, findEntities
|
|
|
118
119
|
return { items, total }
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
// Derive the cache filename from the raw query string the client sent.
|
|
123
|
+
// no query string (`/entities`) → 'index'
|
|
124
|
+
// query string ('a=1&b=2') → 'a=1&b=2'
|
|
125
|
+
//
|
|
126
|
+
// We deliberately keep the raw, undecoded form because:
|
|
127
|
+
// - that's what nginx's `$args` variable contains
|
|
128
|
+
// - stock nginx can pass it straight into `try_files` without
|
|
129
|
+
// hashing modules or Lua
|
|
130
|
+
// - the URL is its own cache key — no extra contract for clients
|
|
131
|
+
// or proxies to agree on beyond "use the request URL"
|
|
132
|
+
//
|
|
133
|
+
// Trade-off: structurally-equal queries with different parameter
|
|
134
|
+
// orders produce different cache files (`?a=1&b=2` vs `?b=2&a=1`).
|
|
135
|
+
// Soft inefficiency, not a correctness bug — the SDK uses a stable
|
|
136
|
+
// order and most consumers are SDK-driven. Filesystem rules apply:
|
|
137
|
+
// most chars (`=`, `&`, `[`, `]`, `%`) are safe; 255-byte filename
|
|
138
|
+
// limits cap query length on typical fs. Document these in the
|
|
139
|
+
// caching README.
|
|
140
|
+
function cacheNameForQueryString(rawQueryString) {
|
|
141
|
+
if (!rawQueryString) return 'index'
|
|
142
|
+
return rawQueryString
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Wide-list defaults — tuned for "an SPA accidentally pulled the whole
|
|
146
|
+
// catalog because it forgot a `fields` projection." Operators can grep
|
|
147
|
+
// the warning out of logs; SDK users see the same shape client-side via
|
|
148
|
+
// the SDK's dev-mode warning.
|
|
149
|
+
const WIDE_RESPONSE_ITEMS = 100
|
|
150
|
+
const WIDE_RESPONSE_BYTES = 256 * 1024
|
|
151
|
+
|
|
152
|
+
function maybeWarnWide({ logger, endpoint, envelope, req }) {
|
|
153
|
+
const items = envelope?.items?.length ?? 0
|
|
154
|
+
if (items <= WIDE_RESPONSE_ITEMS) return
|
|
155
|
+
const bytes = Buffer.byteLength(JSON.stringify(envelope))
|
|
156
|
+
if (bytes <= WIDE_RESPONSE_BYTES && items <= WIDE_RESPONSE_ITEMS) return
|
|
157
|
+
const queryStr = (req.originalUrl || req.url || '').split('?')[1] || '(none)'
|
|
158
|
+
const sizeLabel = bytes >= 1024 * 1024
|
|
159
|
+
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
160
|
+
: `${Math.round(bytes / 1024)} KB`
|
|
161
|
+
logger.warn(
|
|
162
|
+
'Api[%s] wide list response: %d items, %s — query=%s',
|
|
163
|
+
endpoint, items, sizeLabel, queryStr,
|
|
164
|
+
)
|
|
165
|
+
logger.warn(
|
|
166
|
+
' ↳ Consider a `fields:` projection, or move this query to a `data.catalog.<name>` snapshot loaded via the SDK\'s `initialUrl`.',
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Write the response envelope to <out>/<base>/<endpoint>/entities/<name>.json.
|
|
171
|
+
// The path matches what nginx's `try_files /<base>/<endpoint>/entities/$args.json
|
|
172
|
+
// /<base>/<endpoint>/entities/index.json` looks up on upstream failure.
|
|
173
|
+
async function writeQueryCache({ outputFolder, base, name, cacheName, envelope, logger }) {
|
|
174
|
+
const relBase = base.replace(/^\//, '')
|
|
175
|
+
const dir = path.join(outputFolder, relBase, name, 'entities')
|
|
176
|
+
const file = path.join(dir, `${cacheName}.json`)
|
|
177
|
+
try {
|
|
178
|
+
await mkdir(dir, { recursive: true })
|
|
179
|
+
await writeFile(file, JSON.stringify(envelope), 'utf8')
|
|
180
|
+
logger.trace('Api[%s] cache write: %s (%d items)', name, file, envelope.items.length)
|
|
181
|
+
} catch (err) {
|
|
182
|
+
// Filename-length / illegal-char failures land here. Logged but
|
|
183
|
+
// not propagated — the live response is already on the wire.
|
|
184
|
+
logger.error('Api[%s] cache write failed (%s): %s', name, file, err.message)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Clear the entire per-endpoint cache directory. Called when any
|
|
189
|
+
// catalog entity changes — coarse but correct, and on-demand writes
|
|
190
|
+
// rebuild the cache as queries come back in. The cost of warm-up
|
|
191
|
+
// after invalidation is bounded by traffic, which is what you'd want
|
|
192
|
+
// from a write-through cache anyway.
|
|
193
|
+
async function clearEndpointCache({ outputFolder, base, name, logger }) {
|
|
194
|
+
const relBase = base.replace(/^\//, '')
|
|
195
|
+
const dir = path.join(outputFolder, relBase, name, 'entities')
|
|
196
|
+
try {
|
|
197
|
+
await rm(dir, { recursive: true, force: true })
|
|
198
|
+
logger.trace('Api[%s] cache cleared: %s', name, dir)
|
|
199
|
+
} catch (err) {
|
|
200
|
+
logger.error('Api[%s] cache clear failed (%s): %s', name, dir, err.message)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
121
204
|
// MIME type lookup used when streaming a postprocessor's output back over
|
|
122
205
|
// HTTP. The renderer's output extension lives on entity.destination
|
|
123
206
|
// (assigned by the layouts plugin), so we use it as the source of truth.
|
|
@@ -211,6 +294,13 @@ export default ({
|
|
|
211
294
|
findEntities,
|
|
212
295
|
constants: { OPERATION },
|
|
213
296
|
}) => {
|
|
297
|
+
// Shared between onLoaded (populates) and onFinalize (consumes).
|
|
298
|
+
// Hoisted so the lifecycle hooks see the same registry; the body
|
|
299
|
+
// inside onLoaded is what actually fills it in based on
|
|
300
|
+
// runtime.config.api.endpoints.
|
|
301
|
+
let apiBase = '/api'
|
|
302
|
+
const cachedEndpoints = []
|
|
303
|
+
|
|
214
304
|
onLoaded(async () => {
|
|
215
305
|
const logger = useLogger()
|
|
216
306
|
|
|
@@ -237,10 +327,14 @@ export default ({
|
|
|
237
327
|
throw new Error('Express is required for the api plugin — run: npm install express')
|
|
238
328
|
})
|
|
239
329
|
|
|
240
|
-
|
|
330
|
+
apiBase = runtime.config.api?.base ?? '/api'
|
|
331
|
+
const base = apiBase // alias so existing local references still work
|
|
241
332
|
const globalPageSize = runtime.config.api?.pageSize ?? 10
|
|
242
333
|
const globalRenderTimeout = runtime.config.api?.renderTimeout ?? 30_000
|
|
243
334
|
|
|
335
|
+
// cachedEndpoints is hoisted above (shared with onFinalize).
|
|
336
|
+
// Per-endpoint setup loop pushes into it when cache: true is set.
|
|
337
|
+
|
|
244
338
|
// The plugin mirrors the vector / data plugin shape: a named map
|
|
245
339
|
// of endpoints, each with its own optional `token`, `query`
|
|
246
340
|
// scope, allowed `operations`, and overrides for pageSize /
|
|
@@ -267,6 +361,69 @@ export default ({
|
|
|
267
361
|
const pageSize = ep.pageSize ?? globalPageSize
|
|
268
362
|
const renderTimeout = ep.renderTimeout ?? globalRenderTimeout
|
|
269
363
|
|
|
364
|
+
// Server-enforced field projection. When set, every list /
|
|
365
|
+
// query / subscribe response is narrowed to exactly these
|
|
366
|
+
// dotted paths — regardless of what the client asks for.
|
|
367
|
+
//
|
|
368
|
+
// The right tool when an endpoint backs a broad list query
|
|
369
|
+
// and only a handful of fields per entity are actually
|
|
370
|
+
// useful to the client — sitemap, navigation menus,
|
|
371
|
+
// faceted search dimensions. A SPA's first paint shouldn't
|
|
372
|
+
// download every markdown body just to find out which
|
|
373
|
+
// routes exist.
|
|
374
|
+
//
|
|
375
|
+
// Skip it for endpoints that serve individual full
|
|
376
|
+
// documents (the typical `public` shape used by
|
|
377
|
+
// useDocument(id)) — those queries return one entity at a
|
|
378
|
+
// time so narrowing has no real benefit.
|
|
379
|
+
const allowedFields = Array.isArray(ep.fields) && ep.fields.length
|
|
380
|
+
? ep.fields
|
|
381
|
+
: null
|
|
382
|
+
|
|
383
|
+
// Pick the effective projection for a request. If the
|
|
384
|
+
// endpoint declares allowedFields, that's the ceiling: a
|
|
385
|
+
// client requesting more gets only the intersection. A
|
|
386
|
+
// client requesting nothing gets exactly allowedFields.
|
|
387
|
+
// No allowedFields → whatever the client asked for (or
|
|
388
|
+
// all fields when omitted).
|
|
389
|
+
function resolveFields(requested) {
|
|
390
|
+
if (!allowedFields) return requested ?? null
|
|
391
|
+
if (!requested || !requested.length) return allowedFields
|
|
392
|
+
const allowSet = new Set(allowedFields)
|
|
393
|
+
return requested.filter(f => allowSet.has(f))
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Endpoints with cache: true cache GET /entities responses
|
|
397
|
+
// to disk on a per-query-string basis. Path scheme:
|
|
398
|
+
// <out>/<base>/<name>/entities/<raw-query-string>.json
|
|
399
|
+
// <out>/<base>/<name>/entities/index.json (no params)
|
|
400
|
+
//
|
|
401
|
+
// On any catalog change, the whole cache directory is
|
|
402
|
+
// dropped — the next requests through repopulate whatever's
|
|
403
|
+
// needed. Coarse but correct, no per-query tracking.
|
|
404
|
+
//
|
|
405
|
+
// Reverse-proxy failover (stock nginx, no Lua):
|
|
406
|
+
// location /api/sitemap/entities {
|
|
407
|
+
// proxy_pass http://localhost:3001;
|
|
408
|
+
// proxy_intercept_errors on;
|
|
409
|
+
// error_page 502 503 504 = @cache;
|
|
410
|
+
// }
|
|
411
|
+
// location @cache {
|
|
412
|
+
// root /var/www/out;
|
|
413
|
+
// try_files /api/sitemap/entities/$args.json
|
|
414
|
+
// /api/sitemap/entities/index.json
|
|
415
|
+
// =502;
|
|
416
|
+
// }
|
|
417
|
+
//
|
|
418
|
+
// POST /entities/query responses aren't cached — there's no
|
|
419
|
+
// URL the proxy could derive a cache file path from. Use
|
|
420
|
+
// GET for cacheable queries (the SDK's client.list({...}) →
|
|
421
|
+
// GET URL is the canonical pattern).
|
|
422
|
+
if (ep.cache === true) {
|
|
423
|
+
cachedEndpoints.push({ name })
|
|
424
|
+
}
|
|
425
|
+
const cacheEnabled = ep.cache === true
|
|
426
|
+
|
|
270
427
|
const auth = (req, res, next) => {
|
|
271
428
|
if (!ep.token) return next()
|
|
272
429
|
if (req.headers.authorization === `Bearer ${ep.token}`) return next()
|
|
@@ -289,6 +446,7 @@ export default ({
|
|
|
289
446
|
const { render } = useRenderer(runtime, { defaultTimeout: renderTimeout })
|
|
290
447
|
|
|
291
448
|
router.get('/entities', allow('list'), auth, async (req, res) => {
|
|
449
|
+
const t0 = Date.now()
|
|
292
450
|
try {
|
|
293
451
|
const parsed = parseQueryString(req.query)
|
|
294
452
|
const limit = Math.min(100, Math.max(1, parsed.limit ?? pageSize))
|
|
@@ -297,7 +455,7 @@ export default ({
|
|
|
297
455
|
const { items, total } = await runQuery({
|
|
298
456
|
filter: parsed.filter,
|
|
299
457
|
sort: parsed.sort,
|
|
300
|
-
fields: parsed.fields,
|
|
458
|
+
fields: resolveFields(parsed.fields),
|
|
301
459
|
skip,
|
|
302
460
|
limit,
|
|
303
461
|
scope: query,
|
|
@@ -306,13 +464,37 @@ export default ({
|
|
|
306
464
|
|
|
307
465
|
const page = Math.floor(skip / limit) + 1
|
|
308
466
|
const totalPages = Math.ceil(total / limit) || 1
|
|
309
|
-
|
|
467
|
+
const envelope = {
|
|
310
468
|
items, page, limit, total, totalPages,
|
|
311
469
|
hasNext: skip + limit < total,
|
|
312
470
|
hasPrev: skip > 0,
|
|
313
|
-
}
|
|
471
|
+
}
|
|
472
|
+
res.json(envelope)
|
|
473
|
+
logger.trace('Api[%s] list %dms (%d/%d items)', name, Date.now() - t0, items.length, total)
|
|
474
|
+
maybeWarnWide({ logger, endpoint: name, envelope, req })
|
|
475
|
+
|
|
476
|
+
// Write-through cache: write the response to a file
|
|
477
|
+
// path that mirrors the request URL — same `$args`
|
|
478
|
+
// nginx's `try_files` sees, no hashing on either
|
|
479
|
+
// side. See the caching docs for the nginx config
|
|
480
|
+
// that wires the failover.
|
|
481
|
+
if (cacheEnabled && runtime.options.outputFolder) {
|
|
482
|
+
const url = req.originalUrl || req.url || ''
|
|
483
|
+
const qIdx = url.indexOf('?')
|
|
484
|
+
const rawQueryString = qIdx >= 0 ? url.slice(qIdx + 1) : ''
|
|
485
|
+
const cacheName = cacheNameForQueryString(rawQueryString)
|
|
486
|
+
// Fire-and-forget — the response is already sent.
|
|
487
|
+
writeQueryCache({
|
|
488
|
+
outputFolder: runtime.options.outputFolder,
|
|
489
|
+
base: apiBase,
|
|
490
|
+
name,
|
|
491
|
+
cacheName,
|
|
492
|
+
envelope,
|
|
493
|
+
logger,
|
|
494
|
+
}).catch(() => {})
|
|
495
|
+
}
|
|
314
496
|
} catch (err) {
|
|
315
|
-
logger.error('Api[%s] list error: %s', name, err.message)
|
|
497
|
+
logger.error('Api[%s] list error (%dms): %s', name, Date.now() - t0, err.message)
|
|
316
498
|
res.status(500).json({ error: err.message })
|
|
317
499
|
}
|
|
318
500
|
})
|
|
@@ -321,26 +503,40 @@ export default ({
|
|
|
321
503
|
// doesn't fit cleanly in a URL: $and/$or, nested operators,
|
|
322
504
|
// regex, projections, etc. Same shape as a Mongo find.
|
|
323
505
|
router.post('/entities/query', allow('list'), auth, async (req, res) => {
|
|
506
|
+
const t0 = Date.now()
|
|
324
507
|
try {
|
|
325
508
|
const { filter = {}, sort, fields, page: rawPage = 1, limit: rawLimit, skip: rawSkip } = req.body ?? {}
|
|
326
509
|
const limit = Math.min(100, Math.max(1, rawLimit ?? pageSize))
|
|
327
510
|
const skip = rawSkip ?? (Math.max(1, parseInt(rawPage) || 1) - 1) * limit
|
|
328
511
|
|
|
329
512
|
const { items, total } = await runQuery({
|
|
330
|
-
filter, sort,
|
|
513
|
+
filter, sort,
|
|
514
|
+
fields: resolveFields(fields),
|
|
515
|
+
skip, limit,
|
|
331
516
|
scope: query,
|
|
332
517
|
findEntities,
|
|
333
518
|
})
|
|
334
519
|
|
|
335
520
|
const page = Math.floor(skip / limit) + 1
|
|
336
521
|
const totalPages = Math.ceil(total / limit) || 1
|
|
337
|
-
|
|
522
|
+
const envelope = {
|
|
338
523
|
items, page, limit, total, totalPages,
|
|
339
524
|
hasNext: skip + limit < total,
|
|
340
525
|
hasPrev: skip > 0,
|
|
341
|
-
}
|
|
526
|
+
}
|
|
527
|
+
res.json(envelope)
|
|
528
|
+
logger.trace('Api[%s] query %dms (%d/%d items)', name, Date.now() - t0, items.length, total)
|
|
529
|
+
maybeWarnWide({ logger, endpoint: name, envelope, req })
|
|
530
|
+
|
|
531
|
+
// POST queries aren't disk-cached. The reverse proxy
|
|
532
|
+
// failover scheme is URL-based (cache file path =
|
|
533
|
+
// request URL), but POST has no URL-equivalent for
|
|
534
|
+
// its body. Cacheable queries should use GET — which
|
|
535
|
+
// the SDK's list() does by default. Body-only queries
|
|
536
|
+
// are by definition complex/non-canonicalizable so
|
|
537
|
+
// they're treated as live-only.
|
|
342
538
|
} catch (err) {
|
|
343
|
-
logger.error('Api[%s] query error: %s', name, err.message)
|
|
539
|
+
logger.error('Api[%s] query error (%dms): %s', name, Date.now() - t0, err.message)
|
|
344
540
|
res.status(500).json({ error: err.message })
|
|
345
541
|
}
|
|
346
542
|
})
|
|
@@ -373,6 +569,7 @@ export default ({
|
|
|
373
569
|
endpointName: name,
|
|
374
570
|
scope: query,
|
|
375
571
|
filter: filterFn,
|
|
572
|
+
allowedFields,
|
|
376
573
|
res,
|
|
377
574
|
})
|
|
378
575
|
|
|
@@ -451,27 +648,63 @@ export default ({
|
|
|
451
648
|
// before plugin onFinalized hooks. By Finalize we still have the
|
|
452
649
|
// cycle's journal entries; by Finalized they're already gone.
|
|
453
650
|
onFinalize(async (signal) => {
|
|
454
|
-
if (!subscriptions.size) return
|
|
455
651
|
const logger = useLogger()
|
|
456
652
|
const evMap = {
|
|
457
653
|
[OPERATION.CREATE]: 'create',
|
|
458
654
|
[OPERATION.UPDATE]: 'update',
|
|
459
655
|
[OPERATION.DELETE]: 'delete',
|
|
460
656
|
}
|
|
657
|
+
|
|
658
|
+
// Cache invalidation is intentionally coarse: if ANY entity
|
|
659
|
+
// changed in this cycle, rebuild every cached endpoint. Per-
|
|
660
|
+
// endpoint scope matching would shave a few file writes off a
|
|
661
|
+
// churning catalog but adds bug surface for no real win —
|
|
662
|
+
// buildDefaultEnvelope is microseconds (in-memory sift),
|
|
663
|
+
// writeFile is milliseconds. Simpler and safer to just rebuild.
|
|
664
|
+
let anyChange = false
|
|
665
|
+
|
|
666
|
+
// Single journal iteration drives both SSE push and the
|
|
667
|
+
// any-change flag — one pass per cycle.
|
|
461
668
|
for await (const { operation, entity } of useJournal(
|
|
462
669
|
'Api subscriptions',
|
|
463
670
|
[OPERATION.CREATE, OPERATION.UPDATE, OPERATION.DELETE],
|
|
464
671
|
signal,
|
|
465
672
|
)) {
|
|
673
|
+
anyChange = true
|
|
674
|
+
|
|
675
|
+
// SSE push to live subscribers
|
|
466
676
|
for (const [subId, sub] of subscriptions) {
|
|
467
677
|
if (sub.scope && !sub.scope(entity)) continue
|
|
468
678
|
if (sub.filter && !sub.filter(entity)) continue
|
|
679
|
+
// Apply the endpoint's field projection to SSE payloads
|
|
680
|
+
// too — so live updates leak no more than list/query
|
|
681
|
+
// responses do.
|
|
682
|
+
const projected = sub.allowedFields
|
|
683
|
+
? _.pick(entity, sub.allowedFields)
|
|
684
|
+
: entity
|
|
469
685
|
const payload = operation === OPERATION.DELETE
|
|
470
686
|
? { id: entity.id }
|
|
471
|
-
: { id: entity.id, entity }
|
|
687
|
+
: { id: entity.id, entity: projected }
|
|
472
688
|
sseSend(sub.res, evMap[operation], payload)
|
|
473
689
|
logger.trace('Api subscription %s %s: %s', subId, evMap[operation], entity.id)
|
|
474
690
|
}
|
|
475
691
|
}
|
|
692
|
+
|
|
693
|
+
// Clear each cached endpoint's directory on any entity change.
|
|
694
|
+
// Subsequent list requests re-warm the cache via the write-
|
|
695
|
+
// through path in the GET/POST handlers above. Coarse but
|
|
696
|
+
// correct — any stale entry, anywhere in the per-endpoint
|
|
697
|
+
// cache, gets dropped without us having to track which queries
|
|
698
|
+
// were affected by which entity changes.
|
|
699
|
+
if (anyChange && cachedEndpoints.length > 0 && runtime.options.outputFolder) {
|
|
700
|
+
for (const ep of cachedEndpoints) {
|
|
701
|
+
await clearEndpointCache({
|
|
702
|
+
outputFolder: runtime.options.outputFolder,
|
|
703
|
+
base: apiBase,
|
|
704
|
+
name: ep.name,
|
|
705
|
+
logger,
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
}
|
|
476
709
|
})
|
|
477
710
|
}
|