Skip to content

feat(registry): dataProvider.middleware — wrap or replace component data providers #1537

Description

@ricardo-devis-agullo

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:

type DataProviderMiddleware = (
  ctx: DataProviderContext,
  next: () => Promise<unknown>
) => Promise<unknown>;

dataProvider: {
  enabled?: boolean;              // shipped in #1535
  middleware?: DataProviderMiddleware;
}

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:

middleware: async (ctx, next) => auth(ctx, () => metrics(ctx, next))

No power is lost, the config surface stays minimal, ordering needs no documentation, and fnfn | 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: booleanwill next() run real component code? i.e. the component ships a server.js and dataProvider.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:

const mw = conf.dataProvider?.middleware;
const hasDataProvider =
  !!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) {
  return returnComponent(null, defaultModel());
}

fromPromise(getEnv)(component, (err, env) => {
  if (err) { /* existing 502 */ }

  const contextObj = { /* as today */, component: {...}, hasDataProvider };

  // existing domain + timeout + vm block, promisified
  const runComponentDataProvider = () =>
    new Promise((resolve, reject) => {
      const done = (e, data) => (e ? reject(e) : resolve(data));
      // ...processData(contextObj, done) instead of returnComponent
    });

  const terminal = 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 returnComponent directly, 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions