Server Extensibility

OPENING POST


Description

We should allow users, or any package, to extend the server (which is a Koa server) and do things like catch a URL, like for example ads.txt and then return a string, a 301, or whatever they want. Some examples of things users will be able to do once this is released:

  • Create 301 redirects from Frontity.
  • Send different headers. This could be useful for send custom Cache-Control headers for example.
  • It would enable the possibility of creating a package for ads.txt (@frontity/ads.txt) or robots.txt (@frontity/robots.txt).

Server extensibility will also allow other changes, like for example hooking into ReactDOM’s render or changing the HTML template.

Examples

It will work with a different export, probably server . Something like this:

// The normal package export.
export default {
  roots...
  state...
  actions...
}

// The server middleware export.
export const server = ({ app }) => {
  app.use(ctx => {
    // Add some headers...
    ctx.set({
       SomeHeader: "Value of the header",
       OtherHeader: "Value of the header"
    });
  })
  
  // Create custom routes....
  app.use(route('/custom-route', ctx => {
    ctx.body = 'Some body content';
  }));
}

CURRENT STATUS SUMMARY


Please, bear in mind that this section wasn’t part of the opening post. It has been added afterwards and its purpose is to keep a quick summary with the most relevant information about this FD at the top of the thread.

Implementation Proposal Draft

We started the research and came up with a proper Implementation Proposal that is currently opened for questions before defining the final proposal and the next steps:

@luisherranz Could you take a look at this Feature Discussion whenever you have time please? Feel free to add any information missing or correct anything that isn’t correct :slightly_smiling_face:

Sure, I’ll do :slight_smile:

@orballo suggested it’d be great to be able to capture SSR errors to display a more beautiful error, including the logo and a custom message.

I guess we can make sure that you can wrap the function that does the SSR with a middleware like:

app.use(({ ctx, next }) => {
  const oldRender = ctx.libraries.frontity.render;
  ctx.libraries.frontity.render = (...args) => {
    try {
      return oldRender(...args);
    } catch (error) {
      // Return my pretty HTML.
    }
  };
  next();
});
1 Like

@luisherranz I would like to understand the use cases here a little better before starting to research it further and so that we can come up with the API that’s appropriate for the problem.

This is the partial list so far:

  • Creating 301 redirects.
  • Sending different HTTP headers.
  • Change the HTML response from the SSR.
  • Show a nicer SSR error

I think it would make sense to show in the proposal how each of them could be solved with with the API that we are going to propose.

What other features are on the roadmap will require server extensibility?

I also saw that you’ve mentioned that:

Server extensibility will also allow other changes, like for example hooking into ReactDOM’s render or changing the HTML template. Everything that Frontity uses in the SSR will be exposed in a ctx.frontity object and passed to all the Koa middleware.

Could you elaborate on that?

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.