Server Extensibility

EDIT: No need to read, we are going to start a new Koa application for each request. You can jump here: Server Extensibility


I’m working on the Final Implementation Proposal for this but I stumbled upon a problem that I wanted to share.

We need to dynamically populate the middleware of the application depending on the packages of each site because the Frontity server can handle requests for multiple sites and sites can use different packages.

We need to find a way to isolate each request from each other. I’ve been reading Koa’s code and it doesn’t seem like that is the case, so we could have leakage from the requests to one site to the requests of another.

I’ve made a video to explain it a bit better: https://www.loom.com/share/206b60c83319451c96d32b75b2675e70

I’ll keep thinking about it but any help/feedback is welcomed :slightly_smiling_face:

Well, maybe it was not that hard to solve. I think we can use a second compose passing an array created inside the first middleware. Each compose then would start the dispatch(i) from 0 and each array is unique to each request. It will go a bit like this:

On app creation:

const app = new Koa();
app.use(init); // same than app.middleware = [init]`

On each request:

const init = (ctx, next) => {
  const isolatedMiddleware = [pkgA, pkgB, SSR];
  ctx.app.middleware[1] = compose(isolatedMiddleware);
  next();
};
  1. Koa runs dispatch(0): the init middleware.
  2. init creates and populates an internal array: isolatedMiddleware.
  3. init adds it as second item of app.middleware using compose().
  4. init runs next().
  5. Koa runs dispatch(1): compose(isolatedMiddleware).
  6. compose runs dispatch(0): pkgA.
    • pkgA runs next().
    • compose runs dispatch(1): pkgB.
    • pkgB runs next().
    • compose runs dispatch(2): SSR.
    • SSR runs next().
  7. compose runs next().
  8. Koa runs dispatch(2).
  9. It doesn’t exist, even if other requests modified app.middleware to replace the item 1 with its own internal array.
  10. Koa returns the response.

It is also explained here: https://www.loom.com/share/32418c44d5e9487c9aff342e21a46e96

It is probably not the best approach so open to suggestions but at least we have one working approach :slightly_smiling_face:

I will continue with the rest of the IP.


EDIT: By the way, another option would be to create a new Koa application for each request. I’ve been trying to avoid that to make the application as performant as possible but it is true that Koa itself is incredibly lightweight. So that could be a much simpler option.

Ok, after a bit more thought it is clear to me that we need to go with the new Koa app per request because there are other things that could be leaked from one site to another, not only middleware, like event handlers or options:

app.silent = false;

app.on("error", (err) => {
  log.error("server error", err);
});

So we need an isolated app instance per request.

And after reading Koa’s code I am not worried about that performance cost anymore because it will be orders of magnitude smaller than the real bottleneck of this type of application, which is the data fetching.

All good then, sorry for the trouble :+1::sweat_smile:

2 Likes

Nice one Luis!

In the past I’ve used the same approach with multiple Koa instances per website. In that case where multiple brands but same technique. But as you said the perf bottleneck wasn’t the multiple Koa instances but the data layer :sweat_smile:.

1 Like

Nice to hear. Thanks Cris :slightly_smiling_face:

1 Like

This is the Final Implementation Proposal.

The Final Implementation will be written once all the tasks are completed and the feature is released.

Description

Frontity uses a Koa server to handle the HTTP requests.

Right now, the server only handles the server-side rendering of React and a few other cases:

  • Do React SSR.
  • Serve static assets generated by Webpack in the /static folder.
  • Serve some special files like favicon.ico or robots.txt.

Koa uses middleware functions for configuration. To hook into the Koa server and the SSR process of Frontity, we need to add server extensibility, which means packages are allowed to create middleware functions for Koa and expose the objects/functions used by Frontity in the SSR process so they can be modified.

Glossary

Middleware Functions

The functions of the Koa server, like this one:

app.use(async (ctx) => {
  ctx.body = "Hello World";
});

Koa Context or ctx

The context that middleware functions receive. It is an abstraction on top of the req/res params of Node servers.

Full documentation can be found at https://koajs.com/.

Node-only modules

For Node-only modules I mean:

  • Built-in modules, like fs, path, and so on…
  • Packages that are not meant to be sent to the client like koa packages, image optimizers, and so on.

Context / Background

Why do we use Koa for the Frontity server?

We chose Koa instead of other frameworks, like Express, because:

  • It is lighter (half the size of Express) so it is faster for serverless environments.
  • It doesn’t have dynamic imports, so it can be easily bundled for serverless environments.
  • Its middleware functions use a ctx abstraction that it is easier to understand than the req/res objects of Node.
  • Its middleware functions use async/await to call the next middleware using await next().

The usage of async/await for the next function makes Koa middleware especially interesting for extensibility because it means that the middleware functions have the opportunity to execute twice (from first to last and then from last to first) and that the middleware won’t “close” the request with something like req.send() but instead rely on mutations of the ctx properties like ctx.body = "...".

Why do we need to extend the Frontity server?

Frontity packages need to be able to hook into the Frontity server for different reasons:

  • Modify the SSR cycle, to do things like:
    • Change the template used.
    • Modify the root <App> to add a new <Provider> for the whole app.
  • Modify the HTTP response to do things like:
    • Return 301 redirections.
    • Modify the Cache-Control headers.
  • Add new URLs to handle things like:
    • Return static assets, like ads.txt.
    • Proxy requests to other servers, like /wp-content to WordPress.
  • Cache HTML/API assets:
    • Use different caching strategies, like for example Incremental Static Generation.
    • Intercept and cache all the fetch requests.
    • Control the caching storage for those strategies, like for example:
      • Static files in the file system.
      • Redis/Memcached.
      • Node in-memory cache.
    • Add an endpoint for cache invalidation, like /frontity/cache/purge.
  • Other optimizations:
    • Don’t send the state within the HTML file and serve it using a different endpoint, like /some-post/?frontity_state=true.

Right now users are forced to add their own NodeJS server on top of Frontity if they want to modify/control the requests, but that approach is not ideal as I explain in the Existing Solution section.

For the modification/control of the SSR process, we are already adding the different parts to the libraries.frontity namespace as I explain in the Existing Solution section below.

Goals

The main goal is that packages can expose middleware functions that:

  1. Can hook and modify the Frontity SSR process.
  2. Can handle other types of requests, apart from the SSR.
  3. Can modify server parameters, like response headers.
  4. These packages/features can be used by other Frontity users just by installing the packages.
  5. These packages can be configured using the state (frontity.settings.js).
  6. Multiple packages can modify/hook into the same server APIs.

Out of Scope

Even though this is called “Server Extensibility”, these middleware functions run only on HTTP requests. It is out of the scope the hooking and modification of the build process:

  • Modifying the site settings.
  • Modifying/extending Webpack.
  • Modifying/extending Babel.

Those will be managed with code and/or configuration in the frontity.config.js files, which are planned to be run during the build process.

Existing Solution / Workaround

Controlling the Node server

Right now people that need to hook into the NodeJS server are using the function exposed in build/server.js as a middleware for a NodeJS server that runs on top.

const frontity = require("./build/server.js").default;
const http = require("http");
const server = http.createServer(frontity);
server.listen(3000);
console.log("Listening on port 3000");

That way, they can add more behavior to the NodeJS server as they please. For example, change the headers of the static assets.

const addStaticHeaders = (req, res) => {
  const matched = /^\/static\//.url.match(regex);
  if (matched) res.setHeader("Cache-Control", "max-age=31536000");
};

But, this is not extensible. Other users of Frontity can’t add a package that adds this functionality and configure it in their frontity.settings.js file.

Also, if people use their own framework to control these middleware functions on top of the Frontity server, they are adding unnecessary layers because Frontity is already running a server powered by Koa.

Modifying the SSR

There is an ongoing process of exposing the parts involved in the SSR in libraries.frontity as part of the AMP package, which means the SSR will be able to be modified from the init, beforeSSR and afterSSR actions.

For example, to modify the HTML template you could do:

import myTemplate from "./my-template";

export default {
  actions: {
    myPackage: {
      beforeSSR: ({ libraries }) => {
        libraries.frontity.template = myTemplate;
      },
    },
  },
};

The alternative middleware function would be something like:

import myTemplate from "./my-template";

export default {
  server: {
    myPackage: {
      changeTemplate: ({ libraries }) => {
        libraries.frontity.template = myTemplate;
      },
    },
  },
};

I haven’t found any SSR modification that would be better managed in a middleware function as opposed to the init/beforeSSR/afterSSR methods or vice-versa.

Despite that, I think we should still expose ctx to the actions because they run at different times and only for React’s SSR, so they may be simpler to use in some cases.

Once we finish this feature, the execution order of React’s SSR will be:

  1. Get the settings of that site.
  2. Create the store.
  3. Run the middleware functions (before next()).
  4. Run the SSR middleware function.
    4.1. Run the init actions.
    4.2. Run the beforeSSR actions.
    4.3. Do the React rendering.
    4.4. Run the afterSSR actions.
    4.5. Take the state snapshot.
  5. Resume middleware functions (after next()).
  6. Send the response to the client.

So, for example, if you want to modify the Cache-Control headers based on the content of each URL, the afterSSR action is probably the best one because it only runs for React’s SSR:

export default {
  actions: {
    myPackage: {
      afterSSR: ({ ctx, state }) => {
        const data = state.source.get(state.router.link);

        // Check if this is a post.
        if (data.isPost) {
          // Check how old it is, in days.
          const post = state.source.post[data.id];
          const now = new Date();
          const postDate = new Date(post.date);
          const postAge = (now - postDate) / (1000 * 60 * 60 * 24);

          if (postAge > 365) {
            // If the post is older than a year, cache it during a month.
            ctx.set("Cache-Control", `max-age=${60 * 60 * 24 * 30}`);
          } else if (postAge > 30) {
            // If post is older than a month, cache it during a week.
            ctx.set("Cache-Control", `max-age=${60 * 60 * 24 * 7}`);
          } else {
            // If none of the above, cache it during an hour.
            ctx.set("Cache-Control", `max-age=${60 * 60}`);
          }
        }
      },
    },
  },
};

It is true that we could also use a middleware function, although it is less straightforward:

export default {
  server: {
    myPackage: {
      changeCacheControl: async ({ ctx, state, next }) => {
        // Wait until the SSR has finished.
        await next();

        // Check that we are in React's SSR because we don't want to
        // change the Cache-Control headers for other responses.
        if (!!state.router.link) {
          // Change the Cache-Control header. Same logic as above.
          // ...
        }
      },
    },
  },
};

Implementation Proposal

We have decided to go with the following options.

  • Reuse the index/client/server.js entry points
  • Use the default export
  • Use namespaces
  • Abstract app.use
  • Accept both fn(ctx, next) and fn({ ctx, next }) signatures
  • Expose app in the ctx
  • Promote sharing utils through libraries

This means we will reuse the same entry points and export that we already have for the React part and we will simply add the property server to the store. We will also use namespaces.

// packages/my-package/src/index.js
export default {
  state: {
    myPackage: {
      //...
    },
  },
  actions: {
    myPackage: {
      //...
    },
  },
  server: {
    myPackage: {
      // Change static headers for the static assets (served at publicPath).
      addStaticHeaders: ({ ctx, next }) => {
        if (ctx.href.startsWith(ctx.frontity.publicPath))
          ctx.set("Cache-Control", "max-age=31536000");

        next();
      },
    },
  },
};

When Node-only libraries are needed, index.js users will need to divide their entry points into client.js and server.js, like this:

// packages/my-package/src/client.js
export default {
  state: {
    myPackage: {
      //...
    },
  },
  actions: {
    myPackage: {
      //...
    },
  },
};
// packages/my-package/src/server.js
import myPackage from "./client";
import serve from "koa-static"; // Node-only package.
import { get } from "koa-route"; // Node-only package.

export default {
  ...myPackage,
  server: {
    myPackage: {
      // Serve the ads.txt using the /public/ads.txt file.
      adsTxt: get("/ads.txt", serve("./public"))),
    },
  },
};

The possibility of using the client/server.js entry points is already working in Frontity, we don’t need to add it as a new feature.

We need to create a new Koa() application on each request to make sure that packages from different sites are not leaked between requests.

I didn’t notice this before, but app is already part of the context in ctx.app, so we don’t need to do anything for that one either:

export default {
  server: {
    myPackage:
      myMiddleware: ({ ctx }) => {
        // Configure app here:
        ctx.app.silent = true;
        ctx.app.on("error", () => {
          // Do something...
        });
      },
    },
  },
};

The middleware functions will accept both the fn(ctx, next) and fn({ ctx, next }) signatures. In the second case, the object received in the first argument will contain:

  • ctx
  • next
  • The store (spreaded):
    • state
    • libraries
    • actions
    • roots
    • server

So we need to add those properties to the ctx. Something like this:

Object.assign(ctx, {
  ctx,
  ...store,
});
export default {
  server: {
    myPackage:
      myMiddleware: ({ ctx, state, libraries, actions, roots, server }) => {
        // ...
      },
    },
  },
};

Adding next is a bit trickier because it needs to be different for each middleware call.

I’ve been reading Koa’s code and it is very simple and straightforward. The middleware functions are run one after another, so replacing ctx.next within a wrapper before calling the middleware seems to work fine.

const wrapper = (middleware) => (ctx, next) => {
  ctx.next = next;
  return middleware(ctx, next);
};

These options will be possible:

export default {
  // ...
  server: {
    myPackage: {
      someMiddleware: ({ ctx, state, libraries, next }) => {
        // Some logic...
      },

      rateLimiter: ratelimit({
        /* options... */
      }),

      serveSomeStatics: get("/public/:file", serve("./public")),

      serveRobotsTxT: get("/robots.txt", async ({ ctx, next, state }) => {
        ctx.type = "text/plain";

        if (await exists("./robots.txt")) {
          // Serve the robots.txt found in the root.
          return serve("./")(ctx, next);
        } else if (!!state.robots.txt) {
          // Use info defined in the settings.
          ctx.body = state.robots.txt;
        } else {
          // Output a default robots.txt.
          ctx.body = "User-agent: *\nAllow: /";
        }
      }),
    },
  },
};

We will expose utils using libraries, as we do in the React app.

// Other Package.
export default {
  // ...
  libraries: {
    otherPackage: {
      processHeaders: (headers) => {
        // Do stuff...
      },
    },
  },
};

And consume them in the middleware functions.

// My Package.
export default {
  // ...
  server: {
    myPackage: {
      myMiddleware: ({ ctx, libraries }) => {
        // Use utilities from other packages.
        libraries.otherPackage.processHeaders(ctx.headers);
      },
    },
  },
};

This middleware functions API is inconsistent with how the ctx is passed to the init/beforeSSR/afterSSR actions.

Right now the server actions API is:

export default {
  actions: {
    myPackage: {
      beforeSSR: ({ state }) => ({ ctx }) => {
        // ...
      },
    },
  },
};

But we want the new middleware API to be:

export default {
  server: {
    myPackage: {
      someMiddleware: ({ state, ctx }) => {
        // ...
      },
    },
  },
};

So we have to change the server actions API to this:

export default {
  actions: {
    myPackage: {
      beforeSSR: ({ state, ctx }) => {
        // ...
      },
    },
  },
};

We need to do this with backward compatibility.

Acceptance Criteria

  • Packages must be able to expose Koa middleware in the server property of their default export.
  • The middleware functions exposed by packages must be contained within namespaces.
  • Both fn(ctx, next) and fn({ ctx, next }) signatures must be valid.
  • Middleware functions must receive the complete store (including things like server and roots).
  • For each request, the server must populate Koa’s middleware array only with the middleware from the packages present in that site. There must not be leakage from one request to another.
  • The API of server actions must be changed to the new middleware API with backward compatibility.

Test Plan

Apart from the normal unit/e2e test, we must make sure that there is no leakage between sites with different packages.

Dependencies

There are no dependencies and this work could start right away.

Individual Tasks

Things that are mentioned in the IP but don’t require a task because they are already working:

  • Use of src/client.js and src/server.js entry points to be able to import Node-only libraries.
  • Population of ctx.app.

List of tasks:

  1. Create a clean Koa application per request.
  2. Make sure the middleware (server) is properly merged in the creation of the store.
  3. Populate ctx with the ctx itself and the store.
  4. Populate the middleware array with the correct packages.
  5. Create a wrapper to modify ctx.next.
  6. Change the API of the server actions.

1. Create a clean Koa application per request

To make sure that we don’t leak middleware and app configuration from one request to another, my proposal is to create a new Koa application in each request.

Right now this is our @frontity/core/src/server export:

const server = ({ packages }) => {
  // Create new app.
  const app = new Koa();

  // Configure the app...
  app.use(/* ... */);

  // Return the req/res function.
  return app.callback();
};

We should switch to something like this:

const server = ({ packages }) => (req, res) => {
  // Create a new app for each request.
  const app = new Koa();

  // Configure the app...
  app.use(/* ... */);

  // Return the final response.
  return app.callback()(req, res);
};

Dependencies: none.

Relevant code:

2. Make sure the middleware (server) is properly merged in the creation of the store

I think this should work almost out of the box, but we have to make sure that the server middleware is populated in the store.

This is where the merge is happening right now. At least we need to add the server prop to the initialization:

let config: Package = {
  roots: {},
  state: {},
  actions: {},
  libraries: {},
  server: {},
};

Also, we need to make sure that if a package wants to overwrite the middleware of another package, it can do so by exporting the new version with the same namespace and name, in the same way that people can overwrite state and actions now.

// frontity.settings.js
const settings = {
  name: "my-site",
  packages: ["some-package", "my-custom-package"],
};
// some-package
export default {
  server: {
    somePackage: {
      someMiddleware: ({ state, ctx }) => {
        // Original implementation.
      },
    },
  },
};
// my-custom-package
export default {
  server: {
    somePackage: {
      someMiddleware: ({ ctx }) => {
        // My custom overwrite!
      },
    },
  },
};

Again, that should work out of the box.

Dependencies: None

Relevant code:

3. Populate ctx with the ctx itself and the store

In the initialization middleware, we should get the settings, create the store and populate the ctx before we run the middleware.

Something like this:

const server = ({ packages }) => (req, res) => {
  // Create a new app for each request.
  const app = new Koa();

  // Initialization.
  app.use((ctx, next) => {
    // ...
    const settings = getSettings(/* ... */);
    const store = createStore(settings, packages /* ... */);

    // Populate the `ctx`.
    Object.assign(ctx, {
      // Populate the context with itself, for `fn({ ctx })`.
      ctx,
      // Populate the context with the store, for `fn({ state... })`.
      ...store,
    });
  });

  // Return the final response.
  return app.callback()(req, res);
};

Dependencies:

None.

Relevant code:

4. Populate the middleware array with the correct packages

In the initialization middleware, we should add the middleware exposed by the packages to the app.middleware array, using app.use().

The core middleware that needs to run after the package middleware (like the React SSR, the statics folder and so on) should be added dynamically after the package middleware to make sure it runs in the last place.

const server = ({ packages }) => (req, res) => {
  // Create a new app for each request.
  const app = new Koa();

  // Initialization.
  app.use((ctx, next) => {
    // ...
    const settings = getSettings(/* ... */);
    const store = createStore(settings, packages /* ... */);
    Object.assign(ctx, { ctx, ...store });

    // Add package middleware.
    const middleware = getMiddlwareArray(store.server);
    middleware.forEach(app.use);

    // Add core middleware.
    app.use(staticFolder);
    app.use(robotsTxt);
    app.use(serverSideRendering);
    // ...
  });

  return app.callback()(req, res);
};

It’s worth noting here that by extracting the middleware from the store, packages won’t be able to overwrite middleware from other packages on-the-fly. We could solve this by using a wrapper that retains the reference to the namespace and the name of the middleware (similar to target and key in proxies) but I don’t think it’s worth the complexity.

If a package really needs to overwrite the middleware of another package on the fly, they can search for the middleware function in the app.middleware array.

export default {
  server: {
    somePackage: {
      someMiddleware: ({ app, server }) => {
        const fnRef = server.someNamespace.otherMiddleware;
        const index = app.middleware.findIndex(fnRef);
        app.middleware[index] = () => {
          /* New logic */
        };
      },
    },
  },
};

Dependencies:

    1. Make sure the middleware (server) is properly merged in the creation of the store.

Relevant code:

5. Create a wrapper to modify ctx.next

The next step would be to create a wrapper to populate ctx.next so the middleware can work with the signature fn({ ctx, next }).

I’ve been reading the Koa’s code and I think this simple wrapper should be enough:

const wrapper = (fn) => (ctx, next) => {
  ctx.next = next;
  return fn(ctx, next);
};

Dependencies:

    1. Populate the middleware array with the correct packages.

Relevant code:

6. Change the API of the server actions.

Finally, we should change the API of the server actions from this:

export default {
  actions: {
    myPackage: {
      beforeSSR: () => ({ ctx }) => {
        // ...
      },
    },
  },
};

To this:

export default {
  actions: {
    myPackage: {
      beforeSSR: ({ ctx }) => {
        // ...
      },
    },
  },
};

We need to do so while maintaining backward compatibility. I’ve done a video to explain one possible solution:

We should expose converToAction and the raw action in actions.namespace.actionName.raw for example and use those two to call the init/beforeSSR/afterSSR actions like this:

converToAction(beforeSSR.raw, { ...store, ctx })();

By the way, right now we are not passing ctx to the init actions, we should fix that as well.

Dependencies: None.

Relevant code:

Documentation

This will require a whole new section in the docs, inside the Core Concepts.

Open Questions

We need to find a way to allow packages to “skip React SSR” so they can return other responses for URLs that should not be managed by React.

My current idea is that, if a package middleware populates ctx.body, it means that the response is not managed by React and we should skip the React SSR.

I’ll keep thinking about it but any feedback is welcomed :slightly_smiling_face:

Related Featured Discussions

References

Feedback

All feedback is welcomed so please go ahead and share yours :slightly_smiling_face:

@nicholasio.oliveira: it would be especially great to hear your impressions about both the IP and the list of tasks.


If I am not mistaken @cristianbote is currently refactoring the core middleware. Cristian, could you please leave a message with the status of that refactoring? Thanks :slightly_smiling_face:

6 Likes

Hi @luisherranz, great IP! :smiley: This will make Frontity amazingly extensible.

  1. Order of middleware execution.
    While reading through the details noticed that there is no guarantee of the order of middleware execution. Do you think this is important? Would this be something that folks would expect?

  2. Escape hatch by exposing only the app.
    While having a server property it’s pretty explicit and it makes a clear separation between what part of the app it’s involving, have you thought about having this kind of api? This practically will be called after the beforeSSR.

export default {
  server: {
    myPackage: ({ app }) => {

      // Sample of a middleware code
      app.use((ctx, next) => {
        const store = ctx.store;

        // At this point the `store` is available and it can do anything it wants with it.
      });
    },
  },
};
  1. Allow interrupting the render flow.

Open Questions: We need to find a way to allow packages to “skip React SSR” so they can return other responses for URLs that should not be managed by React.

If we would allow the api at point #2 this will turn into simply handle the response on the server. Like this: https://codesandbox.io/s/bold-feistel-4og51?file=/index.js

Example:

export default {
  server: {
    myPackage: ({ app }) => {

      // Custom `/api` endpoint
      app.use((ctx, next) => {
        if (ctx.url.startsWith('/api')) {
          // Serialize the state, or other API responses, auth
          ctx.body = JSON.stringify(ctx.store.state, null, 2);
          return;
        }
        
        await next();
      });
    },
  },
};

If I am not mistaken @cristianbote is currently refactoring the core middleware. Cristian, could you please leave a message with the status of that refactoring? Thanks :slightly_smiling_face:

Yup! I am done with it. The changes can be seen here: https://github.com/frontity/frontity/pull/687/files#diff-2aa5d096a9b58e6ba83f934e3f72bcff6ab4f27ca5cfe05dde74fe087ab53df5R93-R100.
At this point the store and settings are exposed on ctx.state but it’s not something that my implementation relies on so we can redefine those at will.

I am confident that allowing and exposing a function instead of a dictionary with middlewares, will empower the developers to extend at will the server as they wish.

What do you think @luisherranz? Did something slipped through my mind?

1 Like

Our plans for this were to add priorities to packages. There is a small description on the AMP Implementation Proposal: AMP package - #10 by luisherranz

What I am wondering lately is at which level we should add priorities:

  • At a package level.
  • At a function level.

For some of the things, like Html2React processors and Source handlers, we have defined them at a function level. It’s more complex, much also more versatile.

WordPress also has them at a function level for actions (add_action) and filters (add_filter).

We should start a conversation about this :slightly_smiling_face:

The code you mention will work because app is exposed in ctx.

However, I think our recommendation should be to use a normal Frontity middleware, as described in this IP, and await for next() when you need to run something after the SSR.

You are right. Most of the time, when the rendering should be skipped, it would be enough to not call next().

I wonder if there will be sometimes when skipping the React SSR AND keep executing the rest of the middleware (from other packages) will be necessary.

I guess it’s better to wait until we see more real usage of this :slightly_smiling_face:

By the way, the API you describe is not required for this. "Not calling next()" is possible with the normal Frontity middleware functions described in this IP.

Awesome, thanks!

Yes, we will have to change that when we work in this implementation.

If you read my IP draft, that was one of the options explained there (section C). It included the pros and cons of not only that approach but also the alternatives: Server Extensibility - #13 by luisherranz.

It’s not too late to change some parts of the IP, so if you have a strong opinion about that specific part or any other, or you want to point out additional pros/cons of each approach that were not reflected in that draft, please do so :slightly_smiling_face:

2 Likes

Awesome IP @luisherranz :clap:

I’m sure there’s a reason that I missed here, but why do we want to support both signatures?

By the way, good points @cristianbote I also read the Final Implementation first and didn’t realize that priorities were discussed elsewhere and exposing the app was in the draft IP :blush:

I have just realized there is another important reason to use namespaces that I forgot to add to the Proposal Implementation Draft (section B1): The DevTools.

If we use namespaces, we will have much better information for the server DevTools in the future, because after we run each middleware we can report it to the DevTools, the same way we will do with actions:

  • Run server.somePackage.some
    • It mutated state.xxx.counter: 1 → 2
    • It run actions.zzz.toggle(true).
      • It mutated state.zzz.other: false → true
  • Run server.otherPackage.some
    • …

Because one is “the Frontity way” and the other is needed to reuse Koa middleware. It is explained in the draft, section D3.

import ratelimit from "koa-ratelimit";

export default {
  // ...
  server: {
    myPackage: {
      myMiddleware: ({ ctx, state }) => {
        // Do stuff with ctx, state and so on...
      },
      rateLimiter: ratelimit({
        // Add koa packages directly.
      }),
    },
  },
};

Ok, took some more time to read through the IP and I am confident that I don’t see my proposal :sweat_smile:. Maybe I didn’t lay it properly. Let me reiterate over it again, in a more structured way.

D4 Scoped app handler

In a middleware based, server framework, the order of the middleware matters. @luisherranz has talked about it more in this Onboarding Session Video. So, in order to let the server be extensible by using a package, we can define a scoped app handler per package:

export default {
  // ...rest of the configuration
  server: {
    myPackage: ({ ctx, app }) => {
      // Configure the app, middleware and such.
      app.silent = true;
      app.on("error", () => {
        // Do something...
      });

      // Package specific middleware
      app.use(/* middleware fn foo */);

      // Package specific middleware
      app.use(get('/api/cart', /* ... */));
    },
  },
};

For a safer multi package execution, each scoped server handler could be called not with the main app instance but with a new one, that would be mounted. A sub-app per se.

Object.values(store.server).forEach(handler => {
  const app = new Koa();
  handler({ ctx, app});

  // Mount the app.
  rootApp.mount(app);
});

Pros:

  • Stays true to the Koa pattern and paradigm. It is after all direct access to app instance.
  • Direct access to an app instance could lead to more experimentations(React Server Components with their custom api, static serve of resources, Etag with a in-memory cache layer, etc).
  • The order of the middleware is defined and owned by the package.
  • Easier mounting of existing node Koa server. They could use their existing server with the predefined middleware, by mounting their previous one:
import previousServer from './legacy/server';

export default {
  // ...rest of the configuration
  server: {
    myPackage: ({ ctx, app }) => {
      // Mount an existing server
      app.use(mount(previousServer));
    },
  },
};

Cons:

  • I believe this is the first deviation of how frontity has previously defined the scoped settings for a given namespace, so needs to be handled and explained in a such manner.

At the end of the day, the most important aspect is to let the server be extendable. I do not hold any variant close to heart. I see them as different ways of achieving the extensibility we desire.

I do lean a bit more on the above D4 as I can see more usages out of it.

Thanks for the explanation Cris! :slightly_smiling_face:

Absolutely! That’s the main goal I think.

This is a really good point. I didn’t include anything about priorities in this IP, but I think we should start taking a deeper look at them.

The problem that I see with D4, or similar approaches, is that they solve the middleware prioritization (order of execution) problem only for the middleware of a given package. It doesn’t solve the problem between packages. It also does so at the expense of obscuring/isolating the middleware functions of a package from the rest of the application, which is something I would prefer to avoid.

In the AMP Implementation Proposal I added a “Package Priorities” feature which would give packages the opportunity to set a package-wide priority in their frontity.config.js file. I don’t think that that is going to be flexible enough either. We need to find a way to set more fine-grained priorities that work across packages.

So we should find a way to handle priorities in Frontity so that:

  • They can be used at a function level.
  • They work consistently across all the different APIs.
  • They work across packages.
  • They can be easily configured by package creators.
  • They can be easily modified by other packages if needed.
  • Ideally, they can be easily modified by final users if needed.

The Frontity APIs that need priorities right now are:

  • Source Handlers.
  • Html2React Processors.
  • Fills (Slot and Fill).

Other APIs that are going to need priorities soon are:

  • Server Middleware Functions.
  • Packages themselves (the order in which they are merged together).
  • Frontity Hooks/Filters (this FD).
  • Custom configurations of frontity.config.js:
    • Webpack modifications.
    • Babel modifications.
    • Site Settings modifications.

I am sure there will be more in the future.

I am going to open a separate Feature Discussion for “Frontity Priorities” so we can start brainstorming some ideas there :slightly_smiling_face:


EDIT: I have opened the Feature Discussion Frontity Priorities

Are you referring here that a package should be able to change/modify/remove another’s package server middleware?
If that’s the case, I can’t think of an end-result where a package would modify another package middleware. Do you have an example of a use case in mind? Or is this related to something like:

// `foo` package
{
  server: {
    foo({ app }) {
      app.use(mount('/api/state', async (ctx, next) => {
        ctx.body = JSON.stringify({ foo: true });
        // not calling `await next();` here stops the whole execution
      }));
    }
  }
}

// `baz` package
{
  server: {
    baz({ app }) {
      app.use(mount('/api/state', async (ctx, next) => {
        ctx.body = JSON.stringify({ baz: true });
        // not calling `await next();` here stops the whole execution
      }));
    }
  }
}

// What would this endpoint return?
-> http://localhost:3000/api/state

By “rest of the application” you mean the root Koa app instance? As I look over my proposal that’s the only part that would not be able to be changed directly, which can be a good thing, but rather through its own isolated, nested app.

I’ve recorded a short video about it as well.

Understood. Thank you a lot for the video Cris :grinning_face_with_smiling_eyes:

I’ve made another video with my impressions:

As I say in it, I think your proposal makes a lot of sense, although I still think the other one is slightly more flexible in terms of extensibility.

Let me know what you think! :slightly_smiling_face::+1:

@luisherranz I spent some time reviewing the IP and I think it makes a lot of sense, this will make Frontity extremely flexible.

I’m still going through some of the back and forth on this topic but I’m planning on starting helping to implement this in the next few days.

5 Likes

Totally make sense @luisherranz. Thanks for taking the time to explain it.

It’s much clear now what we try to achieve, even though an applied use-case of that middleware prioritisation, it’s a bit difficult to visualise in a proper context.

Ok, I think we can conclude this. Thank you for listening to my comments. :pray:

1 Like

On the contrary, thanks to you for taking a look from a new perspective at this :grinning_face_with_smiling_eyes:

That’s awesome @nicholasio.oliveira! Let us know if you need any help or have any questions.


By the way, just as a reminder for people reading this, this is the Feature Board where we are going to keep track of our progress: Server Extensibility · GitHub

I wanted to share some thoughts about passing the ctx to the server actions like beforeSSR() afterSSR() and init().

It’s a good point, Michal.

If we follow our design principle “We should not create more than one API for the same purpose” it is clear to me that we should deprecate context in the actions and suggest people to use only middleware. It also has the benefits of prioritization and so on.

I didn’t remove it because I thought that for some use cases the code of beforeSSR/afterSSR would be simpler than the code of the middleware.

I wrote an example in this section (you can click on the arrow and it should scroll):

But to be honest, I think Michal is right. This slight improvement doesn’t justify the confusion and complexity of having two ways to access the server context.

What do you think guys?

Actually, we could have a state property that guarantees that this request has been SSR’ed by Frontity. Fro example:

export default {
  server: {
    myPackage: {
      afterReactSSR: async ({ ctx, state, next }) => {
        await next();
        if (state.frontity.rendering === "ssr") {
          // ...
        }
      },
    },
  },
};