A plugin system to build composable systems.
The quickest way to give it a spin is to use the create CLI.
Use create-zemble-app to bootstrap an app (choose between bare
and graphql
templates):
bun create zemble-app <name-of-app> [template]
There is also create-zemble-plugin to similarly bootstrap a plugin (here you can choose between bare
, graphql
and middleware
templates):
bun create zemble-plugin <name-of-plugin> [template]
Both comes with all you need to start building the next build thing, including tests! :)
Here we go through all the basics of @zemble:
- Take it for a spin
- Using routes
- Using GraphQL
- Ok, so what about composability?
- What about testing?
- What about auth?
Let's start simple
- If you haven't already, install bun. It's fast and TypeScript-native. We'll support more runtimes moving forward.
curl -fsSL https://bun.sh/install | bash
- Install some packages:
bun install @zemble/bun @zemble/graphql @zemble/routes
- Add a file, call it
app.ts
for example:
import serve from '@zemble/bun'
import GraphQL from '@zemble/graphql'
import Routes from '@zemble/routes'
export default serve({
plugins: [
Routes,
GraphQL,
],
})
- Run it:
bun --hot app.ts
- You now have a server running on port
http://localhost:3000
. Try it out, you have a GraphQL playground with some stuff to try out - even subscriptions just work :)
- Add a folder called
/routes
(configurable throughRoutes.configure(/* your config here */)
). - In this folder add a
hello/world.ts
with something like this:
export const get = (ctx: Zemble.RouteContext) => ctx.json({
hello: 'world',
})
- Now try navigating to http://localhost:3000/hello/world
- Also add a file - maybe your app icon(?) - in the /routes folder and navigate to it :)
We use hono as web server, it's fast and supports a wide range of runtimes. Check out their docs on more details.
Picking up what you put in your /routes
folder is custom to @zemble though, some patterns we support:
- my-route.get.ts <- will expose the default export when GETting /my-route
- my-route.ts <- will expose the default export for any HTTP verb, use named exports like get and post for specific verbs.
We love GraphQL so a lot of focus has been on providing the best possible DX for it.
- Create a folder called
/graphql
. - Add a
schema.graphql
with a simple query:
type Query {
hello: String!
}
- Add
/graphql/Query/hello.ts
:
const hello = () => 'world!'
export default hello
- Try it out in the playground.
Some cool stuff you can try out is exposing your GraphQL as a REST API with Sofa, full with Swagger at /api/docs
: GraphQL.configure({ sofa: { basePath: '/api' } })
Until now we've built a monolith,. Now - let's talk effortless composability. Microservices are great - but it often adds complexity both in development and maintenance workflows. @zemble brings a plugin system that keeps your logic in separate easily composable parts. Let's try it out.
- Add a
plugins/my-app-routes/plugin.ts
with something like this:
import Plugin from '@zemble/core/Plugin'
import Routes from '@zemble/routes'
export default new Plugin(
import.meta.dir,
{
name: 'files',
version: '0.0.1',
dependencies: [
{
plugin: Routes,
},
],
},
)
- Move your routes folder to
/plugins/my-app-routes
- Update your
app.ts
and add the plugin we just created:
import serve from '@zemble/bun'
import GraphQL from '@zemble/graphql'
import MyAppRoutes from './plugins/my-app-routes'
export default serve({
plugins: [
GraphQL,
MyAppRoutes,
],
})
- Try it out :)
To make it easy to reuse plugins it's recommended to publish them to NPM as separate packages. If you don't explicitely add name and version to the plugin it will automatically try to pick it up from a package.json in the same directory.
Testing your GraphQL is super-easy. We recommend using graphql-codegen with client-preset
- Install @graphql-codegen/cli:
bun install @graphql-codegen/cli
- Add
graphql-codegen
to your scripts section of your package.json:
"scripts": {
// other stuff
"codegen": "graphql-codegen"
}
- Add a
codegen.ts
to your app (you can easily extend it to suit your needs):
import defaultConfig from '@zemble/graphql/codegen'
export default defaultConfig
- Add a
hello.test.ts
to your app (we suggest putting it next to your resolver, but anything goes):
import { it, expect } from 'bun:test'
import { createApp } from '@zemble/core'
import GraphQL from '@zemble/graphql'
import MyAppRoutes from './plugins/my-app-routes'
import { graphql } from '../client.generated'
const HelloWorldQuery = graphql(`
query Hello {
hello
}
`)
it('Should return world!', async () => {
// the config here is identical to what you use when calling serve in your app.ts, so you probably want to break it out to it's file, maybe config.ts or something :)
const app = await createApp({
plugins: [Routes, MyAppRoutes]
})
const response = await app.gqlRequest(HelloWorldQuery, {})
expect(response.data).toEqual({
hello: 'world!',
})
})
Ok, let's take it one step further. You usually need to authenticate users in an app, both to provide an individualized experience as well as protect your users privacy. How can we make this composable? Let's try it out.
- Install @zemble/auth-anonymous:
bun install @zemble/auth-anonymous
- Add it to your app config:
import serve from '@zemble/bun'
import GraphQL from '@zemble/graphql'
import Routes from '@zemble/routes'
import AnonymousAuth from '@zemble/auth-anonymous'
export default serve({
plugins: [
Routes,
GraphQL,
AnonymousAuth
],
})
- We need a public/private keypair, run
bunx zemble-generate-keys
to generate a .env file with a PUBLIC_KEY and PRIVATE_KEY:
bunx zemble-generate-keys
- Run your app and try to call your hello query again :)
@zemble/auth-anonymous
depends on the @zemble/auth
which does the following:
- Protectes all GraphQL queries by default. You can opt-out or configure this with the auth directive, for example:
@auth(skip: true)
- Allows an authentication token to be sent either as a header (by default a bearer token,
authorization: "Bearer {token}"
) or a cookie. - It uses public/private key encryption which makes it possible to verify the authenticity of an authentication token without knowing the private key. It exposes a standard
/.well-known/jwks.json
REST endpoint as well as GraphQL queries for the public key, validating and parsing a token.
@zemble/auth-anonymous
in turn adds a simple login
mutation to GraphQL, which returns an authentication token. Check out @zemble/auth-otp
for another approach, where it adds a flow for authenticating a user by sending a one-time-password to their email address.
We're also providing @zemble/bull
out of the box, which contains middleware to set up queue jobs. We hope we can build a thriving ecosystem around this together.
Check out apps/minimal for a simple example with routes and graphql, and a few tests. For a simple plugin example check out packages/apple-app-site-association which simply adds a route. An app consists of a set of plugins which can be configured, and can also contain routes and/or graphql functionality of it's own.
- DX-optimized plugin development
- Enabling reusing 80% when building a new app/system
- Loose coupling
- Infrastructure agnostic (default behaviour should be that plugins work on edge/node/wherever)
Everything is a plugin. Here are some of the core plugins:
Package | Description |
---|---|
@zemble/core | Core functionality, wiring up configuration between plugins and apps |
@zemble/graphql | Magically wires up GraphQL from the /graphql folder of every plugin, and if you have it in your app |
@zemble/routes | Magically wires up REST endpoints and files in your /routes folder |
@zemble/auth | Magically handles JWT authentication, use @auth directive for public GraphQL queries (or granular permissions) |
@zemble/auth-otp | Adds mutations to authorize through OTP with an email, works seamlessly with @zemble/auth |
Core functionality:
- GraphQL
- REST Routes
- Queues
- Key-Value Store
- Pub/Sub
Plugins are reusable pieces of functionality that can be added to a system. Plugins can depend on other plugins, and can be depended on by other plugins. Plugins should preferably contain one piece of functionality, and should be as loosely coupled as possible.
Every plugin is a singleton and can expose a configuration for an app (or other plugins depending on it) to customize it's functionality. Another pattern is for middleware to extend MiddlewareConfig with configuration so it's behaviour in dealing with all other plugins can be customized.
Some plugins act as middleware, which means they can traverse other plugins without any direct dependency on them. Examples of this is:
- the core GraphQL plugin, which adds schema and resolvers from other plugins.
- the core Queue plugin, which adds queues from other plugins.
Every plugin exposes a config. The config is fully typed and can be configured in detail when composing the app. Environment variables (including .env-files) are also fully supported it is suggested that plugins listens to environment variables wherever possible, for example the core KV, Queue and PubSub plugins accepts the REDIS_URL environment variable to configure the Redis connection, while still allowing overriding this for every plugin individually.
An app can be just a set of plugins without any custom code. It can also contain custom code, any middleware will traverse this custom code in the same way as it traverses plugins.
Concepts:
- An app is a set of configured plugins with or without custom code.
- A plugin is a reusable piece of functionality that can be added to an app. It can provide middleware that traverses other plugins.
- Every plugin has a config that can be configured in detail when composing an app.
A provider exposes some kind of functionality to all other plugins, for example a function for sending an email or accessing a database. Multiple plugins can provide the same functionality and there are a few different ways to configure this:
- last (default, the last configured provider will be used)
- all (all providers will be used - if we for example have an app supporting both apple and expo push notifications)
- failover (the last provider will be used - but if it fails the others will be attempted in LIFO order, good for avoiding single-point-of-failures for system-critical providers)
- round-robin (if you want to distribute usage across multiple provider implementations, for example to reduce load)
To make the provider available with good DX for consuming libraries and apps the provider has to expose both it's type as well as the functionality itself. An example:
// add to the global Zemble.Providers type, to make it clear for consumers it's available
declare global {
namespace Zemble {
interface MiddlewareConfig {
readonly ['@zemble/email-resend']?: Zemble.DefaultMiddlewareConfig
}
interface Providers {
sendEmail: IStandardSendEmailService
}
}
}
type YourPluginConfig = {
// ..
}
const plugin = new Plugin<YourPluginConfig>(import.meta.dir, {
middleware: async ({
app,
}) => {
const initializeProvider = (): IStandardSendEmailService => {
// something that returns the IStandardSendEmailService
}
await setupProvider({
app,
initializeProvider: myIni,
providerKey: 'sendEmail',
middlewareKey: '@zemble/email-resend',
})
}
})
The JWT handling of @zemble/auth plugin allows for flexible authorization stitched to every use case. Every plugin or app can specify directly in the GraphQL schema what is required to exist in the JWT token in a flexible manner, see examples here accompanied by test cases here.
Each app is responsible for generating the structure of it's tokens, usually by looking up a user email/phone number/other identity (coming from an auth plugin like @zemble/auth-apple or @zemble/auth-otp) in their database and sending back whatever user data and authorization claims applies to that user.
For operations that requires a more granular scope than just being authenticated we recommend plugins and apps to adhere to the following JWT structure:
- In a
permissions
array, allow scoped permission that adheres to the plugin name, potentially with additional scope (examplespermissions: ['my-package-name:read']
orpermissions: ['my-package-name:write']
) - In a
permissions
array, allow for an applicable general scope:- Admin (should be allowed for any operation):
admin
- Operations (meant for maintenance/system/settings):
operations:read
oroperations
- Developer:
developer:read
ordeveloper
- Manage users:
manage-users:write
,manage-users:read
,manage-users:add
,manage-users:delete
ormanage-users
- API Tokens:
api-tokens:issue
,api-tokens:delete
orapi-tokens
- Editor:
editor:write
,editor:read
,editor:add
,editor:delete
oreditor
- Admin (should be allowed for any operation):
Often a use case requires more fine-grained authorization (for example on group or organisation level). We recommend this to be handled in one of two ways:
- By adding custom permissions for your custom resolvers, like
manage-users:my-organisation
. As you see in these test cases we ids are supported. - By checking this inside your resolvers. For example if a user has a
manage-users:read
permission by only returning users in their organisations(s).
If you provide a way to change the permissions of a user (like a user management system) you should take appropriate measures to invalidate tokens. The naive out-of-the-box solution will allow the user access until the bearer token expires. To mitigate this one or more of the following approaches can be taken:
- Use short-lived bearer tokens (easily configurable). You might want to use refresh tokens for this to not impact the user experience, this requires implementing the
reissueBearerToken
function configuration in the @zemble/auth configuration, and appropriately call therefreshBearerToken
mutation on the client. - Implement the
checkIfBearerTokenIsValid
function in the @zemble/auth configuration. An example of this would be setting a invalidateAllTokensBeforeTimestamp in your database/key value store when needed (on a permission change for example), and verifying the token was issued after this timestamp incheckIfBearerTokenIsValid
.
-
queues (och kanske även graphql) borde nog vara egna plugins baserat på någon typ av “setup”-funktion. Strukturen är bra men vore nice med möjlighet att switcha implementationer
-
Autentisering och middleware för graphql? (key)
-
TypeScript-baserat config för alla installerade plugins (och kanske avstå från env-variabel magi). Kanske mer explicit hämtning av plugins här? Minskar "black-box" samtidigt som det nog är lättare att få typerna 100%.
-
Codegen och typsäkerhet (det löser vi, bättre att ta när strukturen sitter)
-
Eslint o testning (det löser vi, bättre att ta när strukturen sitter)
-
Orkestrering av contexts (t ex flera URQL-instanser) på klienten?
-
Considering moving entities to JSON entirely. Means we can check it into source control as well as don't have a database dependency there.
-
Cover edge cases for required fields (maybe remove some flexibility).
-
PoC of third-party field
-
displayName for entity entries
-
Publish to npm
Plugin types:
- "Regular" packages. Importable from npm. Code can be called on as usual.
- Middleware. Has access to all plugins, can modify how they work, can modify global config.
- Plugins. Uses the structure set up by the middleware.
KeyValue ska nog vara plugin som supportar x antal providers med samma interface utåt: InMemory Redis Cloudflare MongoDB ?
Plugin philosophy: Försöka köra ett plugin per implementation (alltså en KV som har separata dependencies på redis etc). Utmaningen är att streamlinea interface? Så att vi så enkelt som möjligt för både plugin-utvecklare och plugin-användare kan återanvända samma interface oavsett implementation. Kanske erbjuda ett antal standard-interface i core (för KV, pubsub etc)?
CMS-plugin: Gör så att det är lätt att extenda åt "båda hållen". Har vi en entitet (Recipe) så vore det coolt att kunna hantera det dynamiskt på CMS-nivå - men lägga till resolvers som extendar funktionaliteten.