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 vitestimport { 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.
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 devIn 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/statusThe 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.
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.tsThis 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.
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:
context.storage and the injected context, never a project-global path;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.