266 lines
9.9 KiB
TypeScript
266 lines
9.9 KiB
TypeScript
export interface LocalServiceRecord {
|
|
id: string;
|
|
createdAt: string;
|
|
[key: string]: unknown;
|
|
}
|
|
export interface LocalServicesState {
|
|
database: Map<string, unknown>;
|
|
cache: Map<string, { value: unknown; expiresAt?: number }>;
|
|
mail: LocalServiceRecord[];
|
|
sms: LocalServiceRecord[];
|
|
webhooks: LocalServiceRecord[];
|
|
storage: Map<string, Uint8Array>;
|
|
queue: LocalServiceRecord[];
|
|
cron: LocalServiceRecord[];
|
|
auth: Map<string, LocalServiceRecord>;
|
|
metrics: LocalServiceRecord[];
|
|
}
|
|
export function createLocalServicesState(): LocalServicesState {
|
|
return {
|
|
database: new Map(),
|
|
cache: new Map(),
|
|
mail: [],
|
|
sms: [],
|
|
webhooks: [],
|
|
storage: new Map(),
|
|
queue: [],
|
|
cron: [],
|
|
auth: new Map(),
|
|
metrics: [],
|
|
};
|
|
}
|
|
const json = (value: unknown, status = 200, origin = "https://localhost:3000") =>
|
|
Response.json(value, {
|
|
status,
|
|
headers: {
|
|
"cache-control": "no-store",
|
|
"access-control-allow-origin": origin,
|
|
"x-content-type-options": "nosniff",
|
|
},
|
|
});
|
|
async function boundedJson(
|
|
request: Request,
|
|
maximum = 256 * 1024,
|
|
): Promise<Record<string, unknown>> {
|
|
const text = await request.text();
|
|
if (new TextEncoder().encode(text).byteLength > maximum)
|
|
throw new RangeError("payload-too-large");
|
|
const value = JSON.parse(text) as unknown;
|
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
throw new TypeError("object-required");
|
|
return value as Record<string, unknown>;
|
|
}
|
|
function record(value: Record<string, unknown>): LocalServiceRecord {
|
|
return { id: crypto.randomUUID(), createdAt: new Date().toISOString(), ...value };
|
|
}
|
|
export function createLocalServicesHandler(
|
|
state = createLocalServicesState(),
|
|
options: { origin?: string } = {},
|
|
) {
|
|
const origin = options.origin ?? "https://localhost:3000";
|
|
return async (request: Request): Promise<Response> => {
|
|
const url = new URL(request.url);
|
|
const path = url.pathname;
|
|
if (request.method === "OPTIONS")
|
|
return new Response(null, {
|
|
status: 204,
|
|
headers: {
|
|
"access-control-allow-origin": origin,
|
|
"access-control-allow-methods": "GET,POST,PUT,DELETE",
|
|
"access-control-allow-headers": "content-type",
|
|
},
|
|
});
|
|
if (path === "/healthz" || path === "/readyz")
|
|
return json({ status: "up", service: "wrnexus-local-services" }, 200, origin);
|
|
if (path === "/" || path === "/__services")
|
|
return new Response(
|
|
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>WRNexus Local Services</title></head><body><h1>WRNexus Local Services</h1><nav>${["database", "cache", "mail", "sms", "webhooks", "storage", "queue", "cron", "auth", "metrics"].map((name) => `<a href="/${name}">${name}</a> `).join("")}</nav><p>Use the JSON endpoints to inspect or inject local development events.</p></body></html>`,
|
|
{
|
|
headers: {
|
|
"content-type": "text/html; charset=utf-8",
|
|
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
|
|
},
|
|
},
|
|
);
|
|
try {
|
|
if (path === "/database") {
|
|
if (request.method === "GET") return json(Object.fromEntries(state.database), 200, origin);
|
|
const body = await boundedJson(request);
|
|
if (typeof body.key !== "string") return json({ error: "key is required" }, 400);
|
|
state.database.set(body.key, body.value);
|
|
return json({ key: body.key, value: body.value }, 201, origin);
|
|
}
|
|
if (path === "/cache") {
|
|
if (request.method === "GET") {
|
|
const now = Date.now();
|
|
for (const [key, value] of state.cache)
|
|
if (value.expiresAt && value.expiresAt <= now) state.cache.delete(key);
|
|
return json(Object.fromEntries(state.cache), 200, origin);
|
|
}
|
|
const body = await boundedJson(request);
|
|
if (typeof body.key !== "string") return json({ error: "key is required" }, 400);
|
|
const ttlMs = typeof body.ttlMs === "number" ? Math.max(0, body.ttlMs) : undefined;
|
|
state.cache.set(body.key, {
|
|
value: body.value,
|
|
...(ttlMs ? { expiresAt: Date.now() + ttlMs } : {}),
|
|
});
|
|
return json({ stored: true }, 201, origin);
|
|
}
|
|
for (const [name, values] of [
|
|
["mail", state.mail],
|
|
["sms", state.sms],
|
|
["webhooks", state.webhooks],
|
|
["queue", state.queue],
|
|
["cron", state.cron],
|
|
["metrics", state.metrics],
|
|
] as const)
|
|
if (path === `/${name}`) {
|
|
if (request.method === "GET") return json(values, 200, origin);
|
|
const value = record(await boundedJson(request));
|
|
values.unshift(value);
|
|
if (values.length > 500) values.length = 500;
|
|
return json(value, 202, origin);
|
|
}
|
|
if (path === "/storage") {
|
|
if (request.method === "GET")
|
|
return json(
|
|
[...state.storage.entries()].map(([key, value]) => ({ key, bytes: value.byteLength })),
|
|
200,
|
|
origin,
|
|
);
|
|
const key = url.searchParams.get("key");
|
|
if (!key || key.includes("..") || key.length > 256)
|
|
return json({ error: "safe key is required" }, 400);
|
|
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
if (bytes.byteLength > 10 * 1024 * 1024) return json({ error: "object too large" }, 413);
|
|
state.storage.set(key, bytes);
|
|
return json({ key, bytes: bytes.byteLength }, 201, origin);
|
|
}
|
|
if (path.startsWith("/storage/") && request.method === "GET") {
|
|
const key = decodeURIComponent(path.slice(9));
|
|
const value = state.storage.get(key);
|
|
return value
|
|
? new Response(Uint8Array.from(value), {
|
|
headers: {
|
|
"content-type": "application/octet-stream",
|
|
"content-disposition": "attachment",
|
|
"x-content-type-options": "nosniff",
|
|
},
|
|
})
|
|
: json({ error: "not found" }, 404);
|
|
}
|
|
if (path === "/auth") {
|
|
if (request.method === "GET") return json([...state.auth.values()], 200, origin);
|
|
const value = record(await boundedJson(request));
|
|
if (typeof value.email !== "string") return json({ error: "email is required" }, 400);
|
|
state.auth.set(value.id, value);
|
|
return json({ user: value, accessToken: `local_${value.id}` }, 201, origin);
|
|
}
|
|
return json({ error: "not found" }, 404);
|
|
} catch (error) {
|
|
if (error instanceof RangeError) return json({ error: error.message }, 413);
|
|
return json({ error: error instanceof Error ? error.message : "invalid request" }, 400);
|
|
}
|
|
};
|
|
}
|
|
|
|
export interface LocalCertificate {
|
|
cert: string;
|
|
key: string;
|
|
certFile: string;
|
|
keyFile: string;
|
|
reused: boolean;
|
|
}
|
|
|
|
/** Generate and cache a localhost-only development certificate without requiring OpenSSL. */
|
|
export async function ensureLocalCertificate(appRoot: string): Promise<LocalCertificate> {
|
|
const directory = join(resolve(appRoot), ".wrnexus", "certificates");
|
|
const certFile = join(directory, "localhost.pem");
|
|
const keyFile = join(directory, "localhost-key.pem");
|
|
if (existsSync(certFile) && existsSync(keyFile)) {
|
|
try {
|
|
const cert = readFileSync(certFile, "utf8");
|
|
const key = readFileSync(keyFile, "utf8");
|
|
const certificate = new X509Certificate(cert);
|
|
if (Date.parse(certificate.validTo) > Date.now() + 7 * 24 * 60 * 60 * 1000) {
|
|
return { cert, key, certFile, keyFile, reused: true };
|
|
}
|
|
} catch {
|
|
// Replace invalid or expired development material below.
|
|
}
|
|
}
|
|
const now = new Date();
|
|
const expires = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
|
const generated = await generate([{ name: "commonName", value: "localhost" }], {
|
|
algorithm: "sha256",
|
|
keyType: "ec",
|
|
curve: "P-256",
|
|
notBeforeDate: new Date(now.getTime() - 60_000),
|
|
notAfterDate: expires,
|
|
extensions: [
|
|
{ name: "basicConstraints", cA: false, critical: true },
|
|
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true, critical: true },
|
|
{ name: "extKeyUsage", serverAuth: true },
|
|
{
|
|
name: "subjectAltName",
|
|
altNames: [
|
|
{ type: 2, value: "localhost" },
|
|
{ type: 2, value: "*.localhost" },
|
|
{ type: 7, ip: "127.0.0.1" },
|
|
{ type: 7, ip: "::1" },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
mkdirSync(directory, { recursive: true });
|
|
writeFileSync(certFile, generated.cert, { encoding: "utf8", mode: 0o600 });
|
|
writeFileSync(keyFile, generated.private, { encoding: "utf8", mode: 0o600 });
|
|
try {
|
|
chmodSync(certFile, 0o600);
|
|
chmodSync(keyFile, 0o600);
|
|
} catch {
|
|
// Windows ACLs are inherited from the private workspace directory.
|
|
}
|
|
return {
|
|
cert: generated.cert,
|
|
key: generated.private,
|
|
certFile,
|
|
keyFile,
|
|
reused: false,
|
|
};
|
|
}
|
|
|
|
export async function startLocalServices(
|
|
options: {
|
|
appRoot?: string;
|
|
port?: number;
|
|
hostname?: string;
|
|
https?: boolean;
|
|
origin?: string;
|
|
certificate?: LocalCertificate;
|
|
} = {},
|
|
) {
|
|
const port = options.port ?? 3099;
|
|
const hostname = options.hostname ?? "127.0.0.1";
|
|
const secure = options.https !== false;
|
|
const tls = secure
|
|
? (options.certificate ?? (await ensureLocalCertificate(options.appRoot ?? ".")))
|
|
: undefined;
|
|
const server = Bun.serve({
|
|
port,
|
|
hostname,
|
|
fetch: createLocalServicesHandler(undefined, { origin: options.origin }),
|
|
...(tls ? { tls: { cert: tls.cert, key: tls.key } } : {}),
|
|
});
|
|
console.log(
|
|
` ▸ Local services: ${secure ? "https" : "http"}://${hostname}:${server.port}/__services`,
|
|
);
|
|
if (tls) console.log(` certificate: ${tls.certFile} (trust locally to remove warnings)`);
|
|
console.log(" database cache mail sms webhooks storage queue cron auth metrics");
|
|
return server;
|
|
}
|
|
import { X509Certificate } from "node:crypto";
|
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { generate } from "selfsigned";
|