mikser-io 9.2.0 → 9.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/plugins/api.js +82 -7
- package/src/runtime.js +17 -0
package/package.json
CHANGED
package/src/plugins/api.js
CHANGED
|
@@ -394,6 +394,37 @@ export function api(options = {}) {
|
|
|
394
394
|
// api.endpoints.admin { token: '...', operations: ['list','update','delete','render'] }
|
|
395
395
|
for (const [name, ep] of Object.entries(endpoints)) {
|
|
396
396
|
const router = express.Router()
|
|
397
|
+
|
|
398
|
+
// Nothing is served until the first build cycle has finished.
|
|
399
|
+
//
|
|
400
|
+
// The server binds at the end of the loaded phase — before
|
|
401
|
+
// process() has emitted an entity — so without this the endpoint
|
|
402
|
+
// spends the whole first build answering against an empty catalog.
|
|
403
|
+
// Renders fail outright (the layouts registry fills during
|
|
404
|
+
// process()), and reads are worse than that: a list returns
|
|
405
|
+
// whichever subset exists at that instant, which is a wrong answer
|
|
406
|
+
// wearing a 200. A consumer seeding its routes from that gets zero
|
|
407
|
+
// routes and renders blank pages.
|
|
408
|
+
//
|
|
409
|
+
// 503, not 4xx, and Retry-After: this is the one honest status
|
|
410
|
+
// here. It says the request was fine and the server was not, which
|
|
411
|
+
// is exactly the case, and it is the status every HTTP client
|
|
412
|
+
// already knows to retry. A 422 or a 500 invites the caller to
|
|
413
|
+
// treat a transient build as a permanent defect and give up.
|
|
414
|
+
//
|
|
415
|
+
// The window is normally sub-second and easy to miss. It stretches
|
|
416
|
+
// whenever the cache has to be rebuilt from scratch — most notably
|
|
417
|
+
// after an engine upgrade, where the schema stamp no longer matches
|
|
418
|
+
// and the catalog is wiped before the rebuild.
|
|
419
|
+
router.use((req, res, next) => {
|
|
420
|
+
if (runtime.ready) return next()
|
|
421
|
+
res.set('Retry-After', '1')
|
|
422
|
+
res.status(503).json({
|
|
423
|
+
error: 'Mikser is still building — the catalog is not ready yet',
|
|
424
|
+
ready: false,
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
|
|
397
428
|
router.use(express.json({ limit: ep.bodyLimit ?? globalBodyLimit }))
|
|
398
429
|
|
|
399
430
|
// Operations default to the safer shape when no token is set
|
|
@@ -415,6 +446,20 @@ export function api(options = {}) {
|
|
|
415
446
|
const query = (typeof ep.query === 'function' || (ep.query && typeof ep.query === 'object'))
|
|
416
447
|
? ep.query
|
|
417
448
|
: null
|
|
449
|
+
|
|
450
|
+
// The same scope as a PREDICATE. Two paths hold one entity in
|
|
451
|
+
// hand and have no query to push a filter into — admitting a
|
|
452
|
+
// POST /render body, and the graph-subscription filter — so
|
|
453
|
+
// they have to test it directly. Calling `query` there works
|
|
454
|
+
// only while the scope is a function; the sift-object form
|
|
455
|
+
// this endpoint now accepts is not callable, and the failure
|
|
456
|
+
// is a bare `query is not a function` surfacing from whichever
|
|
457
|
+
// route touched it first.
|
|
458
|
+
const matchesScope = typeof ep.query === 'function'
|
|
459
|
+
? ep.query
|
|
460
|
+
: query
|
|
461
|
+
? sift(query)
|
|
462
|
+
: null
|
|
418
463
|
const pageSize = ep.pageSize ?? globalPageSize
|
|
419
464
|
const renderTimeout = ep.renderTimeout ?? globalRenderTimeout
|
|
420
465
|
|
|
@@ -614,7 +659,7 @@ export function api(options = {}) {
|
|
|
614
659
|
: null
|
|
615
660
|
const handle = runtime.refs.subscribeGraph({
|
|
616
661
|
filter: (entity) => {
|
|
617
|
-
if (
|
|
662
|
+
if (matchesScope && !matchesScope(entity)) return false
|
|
618
663
|
if (requestFilter && !requestFilter(entity)) return false
|
|
619
664
|
return true
|
|
620
665
|
},
|
|
@@ -645,7 +690,10 @@ export function api(options = {}) {
|
|
|
645
690
|
// is a 500. Same shape used by the POST handler below.
|
|
646
691
|
const status = err instanceof ExpandError ? err.status : 500
|
|
647
692
|
if (status >= 500) {
|
|
648
|
-
logger.error(
|
|
693
|
+
logger.error(
|
|
694
|
+
'Api[%s] list error (%dms): %s\n%s',
|
|
695
|
+
name, Date.now() - t0, err.message, err.stack || '(no stack)',
|
|
696
|
+
)
|
|
649
697
|
} else {
|
|
650
698
|
logger.debug('Api[%s] list rejected (%dms): %s', name, Date.now() - t0, err.message)
|
|
651
699
|
}
|
|
@@ -803,6 +851,10 @@ export function api(options = {}) {
|
|
|
803
851
|
})
|
|
804
852
|
|
|
805
853
|
router.post('/render', allow('render'), auth, async (req, res) => {
|
|
854
|
+
// Hoisted so the catch can name WHICH entity failed. A
|
|
855
|
+
// render that throws before this is assigned is itself the
|
|
856
|
+
// finding — it means the body never parsed.
|
|
857
|
+
let renderId
|
|
806
858
|
try {
|
|
807
859
|
// Body shape mirrors the JS API: entity fields at top
|
|
808
860
|
// level, control flags grouped under `options`. Forwarded
|
|
@@ -813,19 +865,42 @@ export function api(options = {}) {
|
|
|
813
865
|
// options.save: false → skip the final disk write
|
|
814
866
|
// (bytes still in the response)
|
|
815
867
|
const { options = {}, ...entityShape } = req.body
|
|
868
|
+
renderId = entityShape.id
|
|
816
869
|
// When the endpoint declares a scope, reject anything
|
|
817
870
|
// outside it BEFORE pushing through the renderer.
|
|
818
|
-
if (
|
|
871
|
+
if (matchesScope && !matchesScope(entityShape)) {
|
|
819
872
|
return res.status(403).json({ error: 'Entity is outside this endpoint\'s scope' })
|
|
820
873
|
}
|
|
821
874
|
const { output, entity } = await render(entityShape, options)
|
|
822
875
|
await sendRenderOutput(res, output, entity)
|
|
823
876
|
} catch (err) {
|
|
824
|
-
|
|
877
|
+
// useRenderer tags an unrenderable entity (no layout)
|
|
878
|
+
// with err.status = 422; everything else is a 500.
|
|
879
|
+
const status = err.status ?? 500
|
|
880
|
+
if (status >= 500) {
|
|
881
|
+
// The stack, not just the message — and this is the
|
|
882
|
+
// one route where that is not optional. A render
|
|
883
|
+
// reaches here through a renderer, a postprocessor
|
|
884
|
+
// chain and any template helper they call, so the
|
|
885
|
+
// message is routinely a bare TypeError from a frame
|
|
886
|
+
// the operator cannot name. Logging `{error: message}`
|
|
887
|
+
// alone leaves bisecting deployed versions as the only
|
|
888
|
+
// way to find out where it came from, which is exactly
|
|
889
|
+
// as expensive as it sounds. The id says which entity;
|
|
890
|
+
// `undefined` there means the body never parsed.
|
|
891
|
+
logger.error(
|
|
892
|
+
'Api[%s] render error for %s: %s\n%s',
|
|
893
|
+
name, renderId ?? '(no id in body)', err.message,
|
|
894
|
+
err.stack || '(no stack)',
|
|
895
|
+
)
|
|
896
|
+
} else {
|
|
897
|
+
logger.debug(
|
|
898
|
+
'Api[%s] render rejected for %s (%d): %s',
|
|
899
|
+
name, renderId ?? '(no id in body)', status, err.message,
|
|
900
|
+
)
|
|
901
|
+
}
|
|
825
902
|
if (!res.headersSent) {
|
|
826
|
-
|
|
827
|
-
// with err.status = 422; everything else is a 500.
|
|
828
|
-
res.status(err.status ?? 500).json({ error: err.message })
|
|
903
|
+
res.status(status).json({ error: err.message })
|
|
829
904
|
}
|
|
830
905
|
}
|
|
831
906
|
})
|
package/src/runtime.js
CHANGED
|
@@ -12,6 +12,22 @@ const runtime = {
|
|
|
12
12
|
journal: [],
|
|
13
13
|
validators: [],
|
|
14
14
|
started: false,
|
|
15
|
+
// Whether the FIRST build cycle has finished — i.e. whether the catalog
|
|
16
|
+
// reflects the sources yet. Not the same question as `started`, which is
|
|
17
|
+
// true from the moment the loaded phase ends, before process() has emitted
|
|
18
|
+
// a single entity.
|
|
19
|
+
//
|
|
20
|
+
// Transports need this. The server binds at the end of the loaded phase, by
|
|
21
|
+
// design, so that every plugin has registered its routes first — which also
|
|
22
|
+
// means requests are accepted while the catalog is still empty. A render
|
|
23
|
+
// arriving then cannot resolve its layout (the layouts registry is filled
|
|
24
|
+
// during process()), and a list returns whatever subset happens to exist,
|
|
25
|
+
// which is worse: it is wrong without being an error.
|
|
26
|
+
//
|
|
27
|
+
// Set once and never cleared. Later cycles rebuild against a catalog that
|
|
28
|
+
// is already populated, so they are serveable; flapping this on every watch
|
|
29
|
+
// rebuild would take the endpoint down for every keystroke.
|
|
30
|
+
ready: false,
|
|
15
31
|
// Name of the lifecycle phase currently executing — null between
|
|
16
32
|
// phases. Set inside start() / process() / render() etc. before
|
|
17
33
|
// each callHooks(), cleared on completion. Read by mikser-io-mcp's
|
|
@@ -85,6 +101,7 @@ const runtime = {
|
|
|
85
101
|
|
|
86
102
|
this.started = true
|
|
87
103
|
await this.process()
|
|
104
|
+
this.ready = true
|
|
88
105
|
},
|
|
89
106
|
|
|
90
107
|
async process() {
|