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:
- Can hook and modify the Frontity SSR process.
- Can handle other types of requests, apart from the SSR.
- Can modify server parameters, like response headers.
- These packages/features can be used by other Frontity users just by installing the packages.
- These packages can be configured using the
state
(frontity.settings.js
).
- 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:
- Get the settings of that site.
- Create the store.
- Run the middleware functions (before
next()
).
- 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.
- Resume middleware functions (after
next()
).
- 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:
- Create a clean
Koa
application per request.
- Make sure the middleware (
server
) is properly merged in the creation of the store.
- Populate
ctx
with the ctx
itself and the store.
- Populate the
middleware
array with the correct packages.
- Create a wrapper to modify
ctx.next
.
- 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:
-
- 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:
-
- 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
Related Featured Discussions
References
Feedback
All feedback is welcomed so please go ahead and share yours
@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