Server Extensibility

Well, we are going to expose all the things that Frontity is using for the SSR part in libraries, and the Koa middleware will be able to access libraries, so you can use it to modify the SSR.

I think it won’t be strictly necessary as we are going to expose it also for beforeSSR, like explained in the AMP package FD.

So… if you want to add a provider for example (I think you bumped into this problem before) you can do it with either:

  • A beforeSSR action:
// my-package/src/index.js
const myPackage = {
  actions: {
    myPackage: {
      beforeSSR: ({ libraries }) => {
        const OldApp = libraries.frontity.App;
        libraries.frontity.App = (props) => (
          <MyProvider>
            <OldApp {...props} />
          </MyProvider>
        );
      },
    },
  },
};
  • A Koa middleware:
// my-package/src/app.js
export default ({ app }) => {
  app.use((ctx) => {
    const OldApp = ctx.libraries.frontity.App;
    ctx.libraries.frontity.App = (props) => (
      <MyProvider>
        <OldApp {...props} />
      </MyProvider>
    );
  });
};

The same can be true for other parts, like the template, the React render function and so on…

1 Like

Interested to work on this. Do we have a preferred way of exposing the server? I like the idea of a custom server export in the settings file.

One important thing in my mind is creating custom routes/api endpoints (which should be fine if we just expose app, passing ctx, as an example, this would allow optimizing images on-demand (like next/image).

@nicholasio.oliveira that is awesome! :smile:

I plan to publish our ideas (a draft of the Implementation Proposal) at the beginning of next week to gather feedback.

I am more inclined now to do this in a separate entry point, like app.js.

The reason is that most of the times you will want to include Node-only packages:

  • 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.

Even though it is already possible to divide the index.js file into two different entry points: client.js and server.js, I think it’s better to do this in its own, separate entry point.


Other than that, I think it should be pretty straightforward. As I said, I will publish a draft of the Implementation Proposal next week and we can continue the conversation :slightly_smiling_face:

This is only a draft of the Implementation Proposal. It is still open for comments and suggestions.

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 is perfect 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 for next().

The combination of async/await for the next(), which means that the middleware functions have the opportunity to execute twice (from first to last and then from last to first) and the fact that the middleware can never send or “close” the request with something like req.send() but instead rely on mutations of the ctx properties, make Koa middleware especially good for extensibility.

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.

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 paramters, like headers.
  4. These packages/features can be used by other Frontity users just by installing the packages.

Out of Scope

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

  • Modifying the settings with code.
  • 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 is using the function exposed in build/server.js as a middleware for a NodeJS server which 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 and beforeSSR actions.

For example, to modify the template you could do:

import myTemplate from "./my-template";

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

The alternative using middleware functions would be something like:

import myTemplate from "./my-template";

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

Right now, I cannot think of anything regarding SSR that would be better managed in a middleware function as opposed to the init/beforeSSR methods or vice-versa.

Also, I don’t think we should restrict access to libraries.frontity in middleware functions, so I guess we will see over time which approach works better and/or if some things can only be done with one approach or the other.

Implementation Proposal Draft

There are different options for each part.

I am exposing all of them because I don’t have strong feelings for any of the approaches. I do have a current opinion, however, which I express later in this post.

A. Entry Points

- A1. Reuse the index/client/server.js entry points

This means we reuse the same entry points that we already have for the React part and we simply add a new property to the store. We could call it server.

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

Pros:

  • We don’t introduce a new entry point and reuse what we already have.
  • We can use the server name, which is more intuitive than app for this use.

Cons:

  • When Node-only libraries are needed, index.js cannot be used anymore and the user needs to divide it to client.js and server.js.

The previous index.js example would need to be divided in 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"))),
    },
  },
};

By the way, the possibility of using the client/server.js entry points is already working in Frontity, we would not need to add it as a new feature.

If we go with the same entry points option (A1), we could use the same export than the rest of the properties (default) or we could use a separate one, like server for example.

– A1-1 Reuse default export

export default {
  state: {
    // ...
  },
  actions: {
    // ...
  },
  server: {
    myPackage: {
      // ...
    },
  },
};

Pros:

  • It feels more natural because it works just like state, actions and libraries.
  • We can keep using a single type for the whole package.

– A1-2 Different export

export default {
  state: {
    // ...
  },
  actions: {
    // ...
  },
};

export const server = {
  myPackage: {
    // ...
  },
};

Pros:

  • It is isolated, which may be cleaner when refactoring to client.js and server.js is required, doing this:

    // client.js
    export default {
      state: {
        // ...
      },
      actions: {
        // ...
      },
    };
    
    // server.js
    export { default } from "./client";
    
    export const server = {
      myPackage: {
        // ...
      },
    };
    

Cons:

  • So far we have managed to export a single type for a whole package. If we use a separate export, we would need to do two type exports.

    This would not be possible:

    import SomePackage from "some-package/types";
    import OtherPackage from "other-package/types";
    
    export type Packages = MergePackages<MyPackage, SomePackage, OtherPackage>;
    

    And we would have to do something like:

    import SomePackage, {
      Server as SomePackageServer,
    } from "some-packages/types";
    import OtherPackage, {
      Server as OtherPackageServer,
    } from "some-packages/types";
    
    export type Packages = MergePackages<
      MyPackage,
      SomePackage,
      SomePackageServer,
      OtherPackage,
      OtherPackageServer
    >;
    

- A2. Create a new app.js entry point

The other option would be to use a new entry point, exclusive for the middleware functions, like app.js.

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

Pros:

  • People don’t need to decide when to divide between client/server.js, they will just learn to use app.js.

Cons:

  • It cannot be named server.js because that name is already been used when you need to divide index.js in both client and server code, for the case when beforeSSR contains Node-only code, so this would be confusing:
    – “Is not server.js supposed to be the file for the server??”
    – “Nope…” :man_shrugging:
  • If we use a separate entry point, we would need to do two type exports.

B. Namespaces

- B1. Use namespaces

We can promote the use of namespaces as we do for other store properties.

export default {
  state: {
    myPackage: {
      //...
    },
  },
  actions: {
    myPackage: {
      //...
    },
  },
  server: {
    myPackage: {
      // ...
    },
  },
};

Pros:

  • Packages will be able to reuse or overwrite the middleware functions exposed by other packages.

Cons:

  • More boilerplate.

- B2. Don’t use namespaces

Or we can just expose app and let packages add middleware as they please.

export const server = (app) => {
  app.use(({ ctx }) => {
    // ...
  });

  app.use(({ ctx }) => {
    // ...
  });
};

Pros:

  • Less boilerplate

Cons:

  • Less extensible/hackable because middleware functions will not be accessible.

C. Expose app or abstract app.use()

Koa works with an app instance that has a small API:

  • app.use(middlewareFunction): Add middleware functions to the server.
  • app.callback(): Return a callback function suitable for the http.createServer() method to handle a request. You may also use this callback function to mount your Koa app in a Connect/Express app.
  • app.listen(): Starts the server.
  • app.env: The same value than process.env.NODE_ENV.
  • app.proxy: Setting to trust proxy headers.
  • app.subdomainOffset: Setting to ignore subdomains.
  • app.keys: Set signed cookie keys.
  • app.context: The contect ctx prototype. Useful to add things that can be accessed later in the middleware functions, like ctx.libraries.
  • app.silent: Turn off error output to stderr.
  • app.on(): Listen to events. For example listen to "error".

From those, the main one packages will need to use 99% of the time is app.use().

These are not required at all:

  • app.callback()
  • app.listen()
  • app.env
  • app.proxy (this on will be turned on by default)

And these may be useful at some point, but it is going to be quite rare.

  • app.subdomainOffset
  • app.keys
  • app.context
  • app.silent
  • app.on()

- C1 Expose app

We could expose the app itself and let packages use app.use() explicitly.

This could be the way to do so if we use the same entry points (A1) and namespaces (B1):

export default {
  // ...
  server: {
    myPackage: {
      myMiddlewares: (app) => {
        // Configure app here:
        app.silent = true;
        app.context.db = new Database();

        app.use((ctx) => {
          // ...
        });
      },
    },
  },
};

And this could be the way to do so if we use a separate entry point (A2) and no namespaces (B2):

// packages/my-package/src/app.js

export default (app) => {
  // Configure app here:
  app.silent = true;
  app.context.db = new Database();

  app.use((ctx) => {
    // ...
  });

  app.use((ctx) => {
    // ...
  });
};

Pros:

  • Only one API for managing app and app.use

Cons:

  • We cannot pass ctx as part of an object, like this:

    app.use(({ ctx }) => {
      // ...
    });
    

    So if want to add more things, like the store, we would need to do so inside ctx.

    app.use((ctx) => {
      const { state, libraries } = ctx;
      // ...
    });
    

    Destructuring is not straightforward when you need to mutate a prop of the context, like ctx.status or ctx.body, so this could be a bit confusing.

    app.use(({ state, libraries, ...ctx }) => {
      ctx.status = 200;
      ctx.body = "Hi mum!";
    });
    

    As opposed to:

    app.use(({ ctx, state, libraries }) => {
      ctx.status = 200;
      ctx.body = "Hi mum!";
    });
    

- C2 Abstract app.use

We can accept funtions where we abstract app.use() away and find an alternative way to expose the app for the uses cases where packages want to hook in the other APIs/settings.

export default {
  // ...
  server: {
    myPackage: {
      myMiddleware: ({ ctx, next }) => {
        // This is `app.use(ctx, next) => ...`
        // but abstracted within the store.
      },
    },
  },
};

Pros:

  • It is less verbose.
  • It will cover 99% of the server extensibility use cases.
  • We can run our own logic before or after the middleware function, so it is possible to pass an object with more elements than only ctx in the first parameter, and maybe will simplify things if we ever add DevTools for this part of Frontity.

Cons:

  • We have to find an alternative way to expose the app.

To expose the app, I think we have two options.

– C2-1 Expose app in the ctx

We could simply expose app inside the middleware functions:

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

I am not sure that app settings can be configured inside middleware functions, so if we choose this option we would have to check that out.

– C2-2 Expose app in the package creation

Package modules are usually an object, but they can also be a function. That means we have the opportunity to pass app in that call:

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

  return {
    state: {},
    actions: {},
    server: {},
  };
};

D. Keep app.use(ctx, next) or pass an object like ({ ctx, next })

- D1 Keep the app.use(ctx, next) API

We can keep the exact same API than app.use() is using:

app.use((ctx, next) => {
  const { state, libraries } = ctx;
  // ...
});

This is required if we don’t abstract app.use (C1).

This would mean the store would be part of the ctx object.

export default {
  // ...
  server: {
    myPackage: {
      myMiddleware: (ctx) => {
        // Access state, actions, libraries or even server.
        const { state, server } = ctx;

        if (state.frontity.name === "my-site") {
          // Delete the middleware from other package...
          delete server.otherPackage.someMiddleware;
        }
      },
    },
  },
};

Cons:

  • People could use destructuring, but it is not straightforward when they need to mutate a prop of the context, like ctx.status or ctx.body, so this could be a bit confusing. This was explained previously in the C1 section.

- D2 Pass an object like ({ ctx, next })

Or we can pass an object that contains ctx, next and maybe other things, like the store.

app.use(({ ctx, next, state, libraries }) => {
  // ...
});

If we use namespaces, this would be:

export default {
  // ...
  server: {
    myPackage: {
      myMiddleware: ({ ctx, state, server }) => {
        // Access state, actions, libraries and now even server.
        if (state.frontity.name === "one-of-my-sites") {
          delete server.otherPackage.someMiddleware;
          ctx.status = 200;
          ctx.body = "Hi mum!";
        }
      },
    },
  },
};

Cons:

  • There are a lot of packages for Koa packages and sometimes they are passed directly to the app.use() function, like this:

    const ratelimit = require("koa-ratelimit");
    
    export default {
      // ...
      server: {
        myPackage: {
          rateLimiter: ratelimit(options),
        },
      },
    };
    

    These packages expect to receive (ctx, next), so they won’t work with ({ ctx, next }) directly.

    The way to use them would be this:

    export default {
      // ...
      server: {
        myPackage: {
          rateLimiter: ({ ctx, next }) => ratelimit(options)(ctx, next),
        },
      },
    };
    

- D3 Expose both APIs

Finally, we could expose both APIs because they are compatible with each other.

The trick is to do add a reference to the ctx in the ctx itself. Something like this:

const middlewareWrapper = (middlewareFunction) => {
  app.use((ctx, next) =>
    middlewareFunction(
      {
        ctx, // Pass ctx.
        next, // Pass next.
        ...store, // Pass state, actions, libraries...
        ...ctx, // And also spread the ctx itself to copy the original API.
      },
      next // Pass next as second argument to copy the original API.
    )
  );
};

I don’t know if genearting a new ctx object is necessary, so this is just an example. I have assumed it is because the reference to the next object needs to be kept in the function’s closure. It will depend on how Koa builds the ctx object that passes to the middleware functions.

Then, these two options are possible:

app.use(({ ctx, next, state, libraries }) => {
  // ...
});

app.use(ratelimit(options));

If we use namespaces, this would be:

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

Pros:

  • We retain the same API we use in actions and derived state.
  • We allow people to use koa packages directly.

Cons:

  • People could get confused when debugging the app because they will see ctx repeated: ctx.ctx, ctx.body, ctx.ctx.body and so on…
  • Some of the packages are not so straightforward. For example, when combining get with serve, this will work:
export default {
  // ...
  server: {
    myPackage: {
      serveSomeStatics: get("/public/:file", serve("./public")),
    },
  },
};

But if you want to use get for your own logic, you may end up back to the app.use(ctx, next) API, because that is the standard in Koa and therefore what get accepts in its callback:

export default {
  // ...
  server: {
    robots: {
      serveRobotsTxT: get("/robots.txt", async (ctx, next) => {
        // Serve the robots.txt found in the root.
        if (await promisify(exists)("./robots.txt")) {
          await serve("./")(ctx, next);
        } else {
          // Output a default robots.txt.
          ctx.type = "text/plain";
          ctx.body = "User-agent: *\nAllow: /";
        }
      }),
    },
  },
};

So if you need access to the store, you need to access it through ctx.

export default {
  // ...
  server: {
    robots: {
      serveRobotsTxT: get("/robots.txt", async (ctx, next) => {
        ctx.type = "text/plain";
        if (await promisify(exists)("./robots.txt")) {
          // Serve the robots.txt found in the root.
          await serve("./")(ctx, next);
        } else if (ctx.state.robots.txt) {
          // Output the robots.txt that is stored in the state.
          ctx.body = ctx.state.robots.txt;
        } else {
          // Output the default robots.txt.
          ctx.body = "User-agent: *\nAllow: /";
        }
      }),
    },
  },
};

E. Share utils using libraries or ctx

We have two options when it comes to enabling middleware functions to share utils with each other.

- E1 Promote sharing utils through libraries

Expose utils in libraries, like the way we do so in the React side.

// 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);
      },
    },
  },
};

Pros:

  • It is the standard way in Frontity.

- E2 Promote sharing utils through ctx

Expose utils in ctx. This requires one of the methods to expose app to be able to access app.conext.

// Other Package.
export default ({ app }) => {
  app.context.processHeaders: (headers) => {
    // Do stuff...
  };
};

Consume them in the middleware functions.

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

Pros:

  • It is the standard way in Koa.

Cons:

  • I guess middleware functions will end up consuming things from libraries which are also exposed to the React app anyway, so that would mean we have two ways to do the same thing.

Personal preference

Before writing the whole Implementation Proposal Draft I was more inclined towards using a separate entry point, like app.js, and no namespaces.

Now, after seeing the whole picture together, I think it makes more sense to keep this as close to the React app as possible, and fulfill these design principles:

  1. Frontity should have as few concepts as possible.
    • Avoid introducing new concepts. Instead, reuse the same concepts.
    • Prioritize using the same concept in different areas over having nicer APIs.
  2. Frontity should be as extensible by default as possible.
  3. Frontity should have a simple surface API but a hackable low-level API.

So I would go with:

  • A1: Use the same entry points (because of 1.)
    • A1-1: Reuse the default export (because of 1.)
  • B1: Use namespaces (because of 1. and 2.)
  • C2: Abstract app.use() (because of 3.)
    • C2-2: Expose app in the package creation (because of 3.)
  • D3: Use both (ctx, next) and ({ ctx, state }) APIs (because of 1. and 3.)
  • E1: Share utils using libraries (because of 1.)

This approach still has an inconsistency with another API: the beforeSSR action when it needs to use ctx.

Right now that API is:

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

To make it consistent with the middleware functions, we would have to migrate it to:

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

Thankfully, this can be done with backward compatibility.

Another option would be to deprecate this beforeSSR option altogether and move the logic that needs to deal with ctx to middleware functions. After all, we passed ctx to beforeSSR as an easy fix because we didn’t have server extensibility yet.


Feedback and remaining work

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

Once we make a decision about the final Implementation Proposal, I will finish it adding:

  • The final requirements.
  • The test plan draft.
  • The individual tasks for the final implementation.
  • The suggestion for the documentation.
1 Like

@SantosGuillamot I have used user stories because that is what we still have in the template, but if you want to turn them into regular goals let me know :slight_smile:

I updated yesterday this template. Did you use that one or there is another template in another place?

I think that with the user stories is clear, so we can keep them in this case. The purpose of changing it was to make it easier to write the Implementation Proposal and make the functionalities clearer.

Btw, I loved the explanation of the different possibilities, it makes it much easier to understand. I am not used to work with this, but overall it seems what you suggest makes sense, and it is based on the design principles. I just have a couple of comments:

  • In point A, as you say, I would reuse the current entry points. I feel adding something like app.js, as we’re already using server.js, would be pretty confusing. Apart from that, if I would have to use Node-only libraries, it makes sense to me to add them only in the server.js file, although we can’t use index.js anymore.
  • The only thing that looks a bit weird to me is D3 mainly because of the ctx repeated. But it’s true that D2 reduces extensibility and D1 is not straightforward. I never work with this kind of things so I have no idea what is better from a developer experience, so I trust your opinion here as well :smile: .

Oh, I started on Monday so I used the old copy then :laughing:

I am going to rewrite the goals more as “features” to see how they would look like.

EDIT: Done.

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