You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-up to #1535, which added dataProvider.enabled.
Goal
Let the registry inject logic into the data provider step: wrap a component's server.js, or supply the model entirely when a component has none (or when they're all switched off via dataProvider.enabled: false).
API
One middleware, onion-shaped — next() returns the inner model:
Why one primitive rather than a handler and a middleware
A middleware that never calls next()is a handler, so a separate fallback option would be strict sugar. Middleware additionally buys things a terminal handler structurally cannot: catching errors thrown by the component, timing its execution, and short-circuiting on a cached model.
If the "hoist 40 identical server.js files into the registry" case proves common enough to want sugar, fallback: f can be added later as a mechanical desugaring — prepend (ctx, next) => ctx.hasDataProvider ? next() : promisify(f)(ctx). Non-breaking, and defining it in terms of the primitive keeps ordering unambiguous.
Why a single function rather than an array
Arrays exist in Express/Koa because middleware arrive from different packages at different times, so the list is the composition mechanism. A registry is configured in one object literal by one owner, so composition is just nesting:
No power is lost, the config surface stays minimal, ordering needs no documentation, and fn → fn | fn[] is a non-breaking widening if third-party oc-middleware-* packages ever materialise. Export a composeDataProvider([a, b, c]) helper for people chaining more than two — it lives in userland and is independently testable, so the registry itself just calls middleware(ctx, terminal).
Context
Today's contextObj (get-component.ts:648-695) plus two fields:
component: { name, version }
hasDataProvider: boolean — will next() run real component code? i.e. the component ships a server.jsanddataProvider.enabled is true. This makes enabled: false read as "every component now looks server-less", with no special-casing in the middleware.
Invariant: no req/res on the context, ever.RendererOptions (get-component.ts:28-37) is already plain data — headers, ip, parameters — so the renderer sits below the HTTP adapter boundary and middleware is Express/Fastify agnostic by construction. Exposing the raw request would couple userland middleware to Express and silently break the Fastify adapter.
Implementation sketch
Replaces the if/else at get-component.ts:616-624:
constmw=conf.dataProvider?.middleware;consthasDataProvider=!!component.oc.files.dataProvider&&conf.dataProvider?.enabled!==false;// FAST PATH: nothing configured -> today's behaviour byte for byte,// no getEnv, no contextObj construction.if(!hasDataProvider&&!mw){returnreturnComponent(null,defaultModel());}fromPromise(getEnv)(component,(err,env)=>{if(err){/* existing 502 */}constcontextObj={/* as today */,component: {...}, hasDataProvider };// existing domain + timeout + vm block, promisifiedconstrunComponentDataProvider=()=>newPromise((resolve,reject)=>{constdone=(e,data)=>(e ? reject(e) : resolve(data));// ...processData(contextObj, done) instead of returnComponent});constterminal=hasDataProvider
? runComponentDataProvider
: async()=>defaultModel();Promise.resolve(mw ? mw(contextObj,terminal) : terminal()).then((data)=>returnComponent(null,data)).catch((e)=>returnComponent(e,undefined));});
Everything downstream (returnComponent, 386-611) is untouched.
Blockers / decisions needed
setEmptyResponse (line 643) is emptyResponseHandler.contextDecorator(returnComponent) and calls returnComponentdirectly, bypassing the return value. In a promise chain the component can settle the request while middleware is still awaiting next(). Needs to resolve the terminal with a sentinel that the chain honours.
The stream symbol (line 67, exposed as ctx.streamSymbol) means a model may be a stream. Middleware doing { ...(await next()) } destroys it. Either document the check or skip post-processing for streamed models.
executionTimeout (695-704) currently races returnComponent. Once that moves to the end of the chain, a mid-chain timeout and a later normal resolve both try to settle. Promisifying the terminal so the timeout rejects that promise is what keeps it sane. Decide whether the timeout covers middleware at all — I lean no, since middleware is trusted registry code and widening the timeout is a behavioural change.
Error mapping.Number(err.status) || 500 at line 444 already means throw Object.assign(new Error('...'), { status: 401 }) yields a 401 — good. But the message is wrapped as "Component execution error: ..." with code GENERIC_ERROR, and response.details carries message, stack and originalError, which appear not to be stripped in routes/component.ts. Middleware errors should get their own code and a details-free mapping, or auth failures will ship a registry stack trace to the client.
Nested rendering.renderComponent/renderComponents re-enter the same renderer, so middleware runs per nested component and can recurse if it renders. Confirm this is wanted and document it.
Model shape asymmetry. The server-less path yields { component: { props } }, a real server.js yields its own shape. So await next() is shape-unstable depending on hasDataProvider. Probably document rather than normalise, since normalising breaks templates.
Adapter parity (surfaced by, not caused by, this feature)
ctx.requestIp derives from req.ip, and the adapters disagree: the Fastify adapter passes trustProxy (packages/oc-fastify-server-adapter/src/index.ts:195) while the Express adapter has no trust proxy handling at all. Behind a load balancer, identical IP-based middleware sees the client IP on Fastify and the LB IP on Express. Worth closing before shipping middleware, since IP checks are a natural thing to put in it. (Smaller: signed: true cookies throw on Fastify without a configured secret.)
Suggested coverage: one middleware that sets a header, sets a cookie, reads requestIp and throws with status: 401, exercised against both adapters — that is the whole surface where an adapter can diverge.
Use cases
Wrapping (only possible with the onion shape):
degrade to a fallback model instead of a 500 when a component's server.js throws
per-component latency and error-rate metrics around the component's own execution
server-side model caching, short-circuiting next() on a hit
Replacing:
generalised auth — verify a param or header, enrich ctx.params, throw with status to reject. Caveat: not a registry security boundary. The accept: application/vnd.oc.info+json short-circuit (lines 265-280), static asset serving and the discovery API all bypass the data provider entirely. This protects the model, not the component's existence, metadata or bundles.
hoisting duplicated server.js files into the registry, paired with enabled: false
injecting ambient context into every model: feature flags, locale/currency, experiment buckets, CSP nonces
param validation with a per-component schema, rejecting with status: 400 before the VM spins up
maintenance/kill-switch model for a named component without republishing it
Follow-up to #1535, which added
dataProvider.enabled.Goal
Let the registry inject logic into the data provider step: wrap a component's
server.js, or supply the model entirely when a component has none (or when they're all switched off viadataProvider.enabled: false).API
One middleware, onion-shaped —
next()returns the inner model:Why one primitive rather than a handler and a middleware
A middleware that never calls
next()is a handler, so a separatefallbackoption would be strict sugar. Middleware additionally buys things a terminal handler structurally cannot: catching errors thrown by the component, timing its execution, and short-circuiting on a cached model.If the "hoist 40 identical
server.jsfiles into the registry" case proves common enough to want sugar,fallback: fcan be added later as a mechanical desugaring — prepend(ctx, next) => ctx.hasDataProvider ? next() : promisify(f)(ctx). Non-breaking, and defining it in terms of the primitive keeps ordering unambiguous.Why a single function rather than an array
Arrays exist in Express/Koa because middleware arrive from different packages at different times, so the list is the composition mechanism. A registry is configured in one object literal by one owner, so composition is just nesting:
No power is lost, the config surface stays minimal, ordering needs no documentation, and
fn→fn | fn[]is a non-breaking widening if third-partyoc-middleware-*packages ever materialise. Export acomposeDataProvider([a, b, c])helper for people chaining more than two — it lives in userland and is independently testable, so the registry itself just callsmiddleware(ctx, terminal).Context
Today's
contextObj(get-component.ts:648-695) plus two fields:component: { name, version }hasDataProvider: boolean— willnext()run real component code? i.e. the component ships aserver.jsanddataProvider.enabledis true. This makesenabled: falseread as "every component now looks server-less", with no special-casing in the middleware.Invariant: no
req/reson the context, ever.RendererOptions(get-component.ts:28-37) is already plain data —headers,ip,parameters— so the renderer sits below the HTTP adapter boundary and middleware is Express/Fastify agnostic by construction. Exposing the raw request would couple userland middleware to Express and silently break the Fastify adapter.Implementation sketch
Replaces the
if/elseatget-component.ts:616-624:Everything downstream (
returnComponent, 386-611) is untouched.Blockers / decisions needed
setEmptyResponse(line 643) isemptyResponseHandler.contextDecorator(returnComponent)and callsreturnComponentdirectly, bypassing the return value. In a promise chain the component can settle the request while middleware is still awaitingnext(). Needs to resolve the terminal with a sentinel that the chain honours.streamsymbol (line 67, exposed asctx.streamSymbol) means a model may be a stream. Middleware doing{ ...(await next()) }destroys it. Either document the check or skip post-processing for streamed models.executionTimeout(695-704) currently racesreturnComponent. Once that moves to the end of the chain, a mid-chain timeout and a later normal resolve both try to settle. Promisifying the terminal so the timeout rejects that promise is what keeps it sane. Decide whether the timeout covers middleware at all — I lean no, since middleware is trusted registry code and widening the timeout is a behavioural change.Number(err.status) || 500at line 444 already meansthrow Object.assign(new Error('...'), { status: 401 })yields a 401 — good. But the message is wrapped as"Component execution error: ..."with codeGENERIC_ERROR, andresponse.detailscarriesmessage,stackandoriginalError, which appear not to be stripped inroutes/component.ts. Middleware errors should get their own code and a details-free mapping, or auth failures will ship a registry stack trace to the client.renderComponent/renderComponentsre-enter the same renderer, so middleware runs per nested component and can recurse if it renders. Confirm this is wanted and document it.{ component: { props } }, a realserver.jsyields its own shape. Soawait next()is shape-unstable depending onhasDataProvider. Probably document rather than normalise, since normalising breaks templates.Adapter parity (surfaced by, not caused by, this feature)
ctx.requestIpderives fromreq.ip, and the adapters disagree: the Fastify adapter passestrustProxy(packages/oc-fastify-server-adapter/src/index.ts:195) while the Express adapter has notrust proxyhandling at all. Behind a load balancer, identical IP-based middleware sees the client IP on Fastify and the LB IP on Express. Worth closing before shipping middleware, since IP checks are a natural thing to put in it. (Smaller:signed: truecookies throw on Fastify without a configured secret.)Suggested coverage: one middleware that sets a header, sets a cookie, reads
requestIpand throws withstatus: 401, exercised against both adapters — that is the whole surface where an adapter can diverge.Use cases
Wrapping (only possible with the onion shape):
server.jsthrowsnext()on a hitReplacing:
ctx.params, throw withstatusto reject. Caveat: not a registry security boundary. Theaccept: application/vnd.oc.info+jsonshort-circuit (lines 265-280), static asset serving and the discovery API all bypass the data provider entirely. This protects the model, not the component's existence, metadata or bundles.server.jsfiles into the registry, paired withenabled: falsestatus: 400before the VM spins up