Plugins

Build your first plugin

Build, run, and test a file-backed status service through its application API and typed control operations.

This tutorial builds one complete plugin. The files below are the checked examples/status-plugin example, not shortened teaching variants.

pnpm add -D localhost2137 hono@^4.13.4 zod@^4.4.3 vitest

Define the service

src/status-plugin.ts
import { readFile, writeFile } from "node:fs/promises";
import { Hono } from "hono";
import { defineOperation, definePlugin, type PluginEnv } from "localhost2137";
import { z } from "zod";

const statusSchema = z.object({
	message: z.string().nullable(),
	state: z.enum(["operational", "degraded", "outage"]),
});
const setStatusInput = z.object({
	message: z.string().optional(),
	state: statusSchema.shape.state,
});

type Config = Readonly<Record<string, never>>;
type State = Readonly<{ statusPath: string }>;
type Status = z.output<typeof statusSchema>;

const initialStatus: Status = { message: null, state: "operational" };
const operation = defineOperation<"status", State, Config>();

const readStatus = operation({
	description: "Read the current status",
	input: z.object({}),
	output: statusSchema,
	run: (context) => loadStatus(context.state.statusPath),
});

const setStatus = operation({
	description: "Set the status exposed to the application",
	input: setStatusInput,
	output: statusSchema,
	run: async (context, input) => {
		const status: Status = {
			message: input.message ?? null,
			state: input.state,
		};
		await saveStatus(context.state.statusPath, status);
		return status;
	},
});

const api = new Hono<PluginEnv<State, Config>>();
api.get("/v1/status", async (context) => {
	const { state } = context.get("lh");
	return context.json(await loadStatus(state.statusPath));
});

export const statusPlugin = definePlugin({
	api,
	configSchema: z.object({}),
	connection: ({ baseUrl, instanceId, serviceKey }) => {
		const apiUrl = `${baseUrl}/${instanceId}/${serviceKey}`;
		return {
			env: { STATUS_API_URL: apiUrl },
			values: { apiUrl },
		};
	},
	description: "Local status service",
	id: "status",
	lifecycle: {
		create: (context) => saveStatus(context.storage.path("status.json"), initialStatus),
		start: (context): State => ({
			statusPath: context.storage.path("status.json"),
		}),
	},
	operations: { readStatus, setStatus },
	stateVersion: 1,
});

async function loadStatus(path: string): Promise<Status> {
	return statusSchema.parse(JSON.parse(await readFile(path, "utf8")));
}

async function saveStatus(path: string, status: Status): Promise<void> {
	await writeFile(path, `${JSON.stringify(status)}\n`, "utf8");
}

The public route and both operations use the same persistence functions. They do not call each other. The shared Hono route table reads the selected instance from context.get("lh"); mutable module state would leak data between instances.

create initializes durable data and may be retried after interruption. start returns the live state used by routes and operations. This plugin opens no process resource, so it needs no stop.

Mount it

localhost.config.ts
import { defineConfig } from "localhost2137";
import { statusPlugin } from "./src/status-plugin.js";

export default defineConfig({
	services: {
		status: statusPlugin({ config: {} }),
	},
});

The status service key becomes the route segment, storage namespace, CLI selector, and typed instance property. Start the persistent dev world:

pnpm exec localhost dev

In a second terminal, change the world through the control surface and read it through the application surface:

pnpm exec localhost exec status set-status \
  --state degraded --message "database maintenance" --json
curl --fail --silent --show-error \
  http://127.0.0.1:2137/dev/status/v1/status

The operation can arrange emulator state because it is privileged. The HTTP request proves the application-facing route observes that state. Stop the foreground daemon with Ctrl-C; reset the persistent dev instance when you want a fresh world.

Test the public contract

test/status-plugin.test.ts
import { createTestRuntime } from "localhost2137/testing";
import { expect, it } from "vitest";
import config from "../localhost.config.js";

it("exposes isolated status through the application API", async () => {
	const runtime = await createTestRuntime({
		config,
		port: 0,
		storage: "temporary",
	});

	try {
		const degraded = await runtime.createInstance();
		try {
			const fresh = await runtime.createInstance();
			try {
				await degraded.status.setStatus({
					message: "database maintenance",
					state: "degraded",
				});

				const degradedResponse = await fetch(`${degraded.status.connection.apiUrl}/v1/status`);
				expect(degradedResponse.status).toBe(200);
				await expect(degradedResponse.json()).resolves.toEqual({
					message: "database maintenance",
					state: "degraded",
				});

				const freshResponse = await fetch(`${fresh.status.connection.apiUrl}/v1/status`);
				expect(freshResponse.status).toBe(200);
				await expect(freshResponse.json()).resolves.toEqual({
					message: null,
					state: "operational",
				});
			} finally {
				await fresh.destroy();
			}
		} finally {
			await degraded.destroy();
		}
	} finally {
		await runtime.close();
	}
});
pnpm exec vitest run test/status-plugin.test.ts

This test makes one public claim: state arranged through a control operation is visible through the application API and remains isolated from another instance. It owns its temporary runtime and both instances on every successful creation path. It does not inspect storage or call the control HTTP protocol directly.

Grow from evidence

One file is enough while this service has one reason to change. Split domain, persistence, HTTP, operations, lifecycle, and delivery code when those responsibilities acquire separate behavior or tests. Keep these boundaries:

  • routes and operations call shared domain behavior; they do not call each other;
  • instance data comes from context.storage and the injected context, never a project-global path;
  • operations return schema-validated data and contain no CLI logic;
  • application tests use the API or SDK boundary they claim to prove;
  • unsupported behavior remains explicit rather than gaining a test-only response.

The file store is sufficient for this sequential example. It does not establish transaction, concurrency, migration, recovery, or provider compatibility properties. Continue with Plugin design and testing for those production concerns.