Documents injecute 1.x · Migrating from 0.x
Skip to content

Hono ​

Hono runs on every JavaScript runtime, and so does injecute: no runtime dependencies, no decorators, no reflect-metadata, no build step. The same modules run on Node.js, Bun, Deno and Cloudflare Workers.

Hono's context variables (c.set(), c.var) carry the values of one request, typed by hand in the app's Env. They are a poor place for services: a middleware sets them again on every request, and only handlers can read them. With injecute:

  • Services built once, typed from the registrations. Handlers close over the services they need; a missing service is a compile error.
  • Request data reaches every layer. A middleware starts the request context, and services deep in the graph read it through an accessor. Keep c.var for what only handlers use.
  • Modules bring their own routes. Each feature module contributes a Hono sub-app; the HTTP module doesn't import the features.
  • Shutdown in the right order on Node.js, Bun and Deno: the server finishes open requests before the connections close.
  • Tests with app.request(), without a server.

The HTTP module ​

The HTTP module declares an extension point for routes, and starts the request context for each request:

ts
// http.ts
import { Hono } from 'hono';
import { collect, createTag, type ServiceRegistry } from 'injecute';
import { storage } from './context.ts'; // export const storage = new AsyncLocalStorage<RequestContext>()

/** A Hono app a module mounts under a path. */
export interface Route {
  readonly path: string;
  readonly app: Hono;
}
/** The extension point: every module's routes. */
export const route = createTag('route').of<Route>();

export const addHttp = (c: ServiceRegistry) =>
  c.addSingleton(
    'http',
    (routes) => {
      const http = new Hono();
      http.use((c, next) =>
        storage.run(
          {
            traceId: c.req.header('x-request-id') ?? crypto.randomUUID(),
            tenantId: c.req.header('x-tenant-id'),
          },
          next,
        ),
      );
      for (const { path, app } of routes) http.route(path, app);
      return http;
    },
    [collect(route)],
  );

Hono's context storage middleware works too: add contextStorage() and read the accessor from it, { current: () => tryGetContext<Env>()?.var.requestContext }.

A feature module ​

A module registers its services and contributes a sub-app. The handlers close over orders:

ts
// orders.ts
import { Hono } from 'hono';
import type { ServiceRegistry } from 'injecute';
import { route, type Route } from './http.ts';

export const addOrders = (c: ServiceRegistry<{ db: Database }>) =>
  c.addSingleton('orders', OrderService, ['db']).addSingleton(
    route('orders'),
    (orders): Route => ({
      path: '/orders',
      app: new Hono()
        .get('/', async (c) => c.json(await orders.list()))
        .post('/', async (c) => {
          const { item } = await c.req.json<{ item: string }>();
          return c.json(await orders.place(item), 201);
        }),
    }),
    ['orders'],
  );

The application, on every runtime ​

The application is a module. It needs a database and a context accessor, and each runtime's entry point provides them:

ts
// app.ts
import type { ServiceRegistry } from 'injecute';
import { addHttp } from './http.ts';
import { addOrders } from './orders.ts';

export const addApp = (
  c: ServiceRegistry<{
    db: Database;
    context: ContextAccessor<RequestContext>;
  }>,
) => c.extend(addHttp).namespace('Orders', addOrders);

Node.js, Bun and Deno ​

The entry point registers the database and the context, the application, and the server as a lifecycle hook:

ts
// server.ts
import { once } from 'node:events';
import { serve } from '@hono/node-server';
import { DIContainer, lifecycle, startLifecycle } from 'injecute';
import { addApp } from './app.ts';
import { storage } from './context.ts';

const app = new DIContainer()
  .addInstance('config', loadConfig())
  .addInstance('context', { current: () => storage.getStore() })
  .addSingleton('db', (config) => new Database(config.databaseUrl), {
    dependencies: ['config'],
    dispose: (db) => db.close(),
  })
  .extend(addApp)
  .addSingleton(
    lifecycle.start('server'),
    async (http, config) => {
      const server = serve({ fetch: http.fetch, port: config.port });
      await once(server, 'listening'); // rejects when the port is taken
      return () =>
        new Promise<void>((resolve, reject) =>
          server.close((error) => (error ? reject(error) : resolve())),
        );
    },
    ['http', 'config'],
  )
  .seal();

const running = await startLifecycle(app);
process.once('SIGTERM', () =>
  running.stop().catch((error) => console.error(error)),
);
ts
// the hook in server.ts
.addSingleton(
  lifecycle.start('server'),
  (http, config) => {
    const server = Bun.serve({ fetch: http.fetch, port: config.port });
    return () => server.stop(); // waits for open requests
  },
  ['http', 'config'],
)
ts
// the hook in server.ts
.addSingleton(
  lifecycle.start('server'),
  (http, config) => {
    const server = Deno.serve({ port: config.port }, http.fetch);
    return () => server.shutdown(); // waits for open requests
  },
  ['http', 'config'],
)

running.stop() stops the server, then dispose() closes the database. See Graceful shutdown in Node.js for a grace period and a second signal.

Cloudflare Workers ​

A Worker has no server to start: it exports fetch. Build the root once per isolate, with the bindings from cloudflare:workers:

ts
// worker.ts
import { env } from 'cloudflare:workers';
import { DIContainer } from 'injecute';
import { addApp } from './app.ts';
import { storage } from './context.ts'; // the storage the HTTP module runs each request in

const app = new DIContainer()
  .addInstance('context', { current: () => storage.getStore() })
  .addInstance('db', createDatabase(env.DB)) // a D1 binding: safe to share between requests
  .extend(addApp)
  .seal();

export default {
  fetch: (request: Request, env: Env, ctx: ExecutionContext) =>
    app.get('http').fetch(request, env, ctx),
};
  • A connection belongs to one request. Workers don't let a request use a socket or a stream that another request opened ("Cannot perform I/O on behalf of a different request"). Bindings (KV, D1, R2, queues) are safe in singletons; a database client that holds a connection is not. Register a factory and connect per request with await using (a unit of work), or keep one instance per request.
  • No shutdown. An isolate is evicted without notice, so there is no lifecycle to stop and the container is never disposed. Hand work that outlives the response to ctx.waitUntil().
  • AsyncLocalStorage needs the nodejs_compat compatibility flag.

Tests ​

Build the application on a test root with a fake database, and send requests with app.request():

ts
// orders.test.ts
import { DIContainer } from 'injecute';
import { expect, it } from 'vitest';
import { addApp } from './app.ts';

it('places an order', async () => {
  await using test = new DIContainer()
    .addInstance('db', new FakeDatabase())
    .addInstance('context', { current: () => undefined })
    .extend(addApp);

  const response = await test.get('http').request('/orders', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ item: 'book' }),
  });

  expect(response.status).toBe(201);
});

No server runs, and the test doesn't touch the runtime: the same test covers the Node.js, Bun, Deno and Workers builds. To replace a service of an entry point's root instead, use an isolated fork; see Testing.

Released under the MIT License.