release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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";
|
||||
Reference in New Issue
Block a user