@livo-build/runtime 0.2.6 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/aes.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Encrypt a string → "ivBase64:ciphertextBase64". */
2
+ export declare function encrypt(plaintext: string, secret: string): Promise<string>;
3
+ /** Decrypt a value produced by encrypt(). Throws if tampered / wrong secret. */
4
+ export declare function decrypt(value: string, secret: string): Promise<string>;
package/dist/aes.js ADDED
@@ -0,0 +1,38 @@
1
+ // AES-256-GCM encrypt/decrypt for protecting sensitive values at rest in D1/KV
2
+ // (e.g. per-user data). The key is a passphrase/secret; output is base64 "iv:ct".
3
+ // Web Crypto only (Worker-safe).
4
+ const enc = new TextEncoder();
5
+ const dec = new TextDecoder();
6
+ function b64(bytes) {
7
+ let bin = "";
8
+ for (let i = 0; i < bytes.length; i++)
9
+ bin += String.fromCharCode(bytes[i]);
10
+ return btoa(bin);
11
+ }
12
+ function unb64(s) {
13
+ const bin = atob(s);
14
+ const out = new Uint8Array(bin.length);
15
+ for (let i = 0; i < bin.length; i++)
16
+ out[i] = bin.charCodeAt(i);
17
+ return out;
18
+ }
19
+ async function deriveKey(secret) {
20
+ const hash = await crypto.subtle.digest("SHA-256", enc.encode(secret));
21
+ return crypto.subtle.importKey("raw", hash, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
22
+ }
23
+ /** Encrypt a string → "ivBase64:ciphertextBase64". */
24
+ export async function encrypt(plaintext, secret) {
25
+ const key = await deriveKey(secret);
26
+ const iv = crypto.getRandomValues(new Uint8Array(12));
27
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, enc.encode(plaintext)));
28
+ return `${b64(iv)}:${b64(ct)}`;
29
+ }
30
+ /** Decrypt a value produced by encrypt(). Throws if tampered / wrong secret. */
31
+ export async function decrypt(value, secret) {
32
+ const [ivB64, ctB64] = value.split(":");
33
+ if (!ivB64 || !ctB64)
34
+ throw new Error("decrypt: malformed ciphertext");
35
+ const key = await deriveKey(secret);
36
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(ivB64) }, key, unb64(ctB64));
37
+ return dec.decode(pt);
38
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { privateKeyToAccount } from "viem/accounts";
3
+ import { createSiweMessage, verifySiweMessage } from "./siwe.js";
4
+ import { createSession, verifySession } from "./sessions.js";
5
+ import { encrypt, decrypt } from "./aes.js";
6
+ import { Router, json } from "./http.js";
7
+ const PK = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
8
+ const account = privateKeyToAccount(PK);
9
+ const ADDR = account.address;
10
+ describe("SIWE", () => {
11
+ it("builds a canonical message and verifies a real signature (cross-checked vs viem)", async () => {
12
+ const msg = createSiweMessage({ domain: "app.livo.build", address: ADDR, uri: "https://app.livo.build", chainId: 11155111, nonce: "abc123" });
13
+ expect(msg).toContain("app.livo.build wants you to sign in");
14
+ expect(msg).toContain("Nonce: abc123");
15
+ const signature = await account.signMessage({ message: msg });
16
+ const r = await verifySiweMessage(msg, signature, { nonce: "abc123", domain: "app.livo.build" });
17
+ expect(r.valid).toBe(true);
18
+ expect(r.address?.toLowerCase()).toBe(ADDR.toLowerCase());
19
+ });
20
+ it("rejects a wrong nonce and an expired message", async () => {
21
+ const good = createSiweMessage({ domain: "d", address: ADDR, uri: "u", chainId: 1, nonce: "n1" });
22
+ const sig = await account.signMessage({ message: good });
23
+ expect((await verifySiweMessage(good, sig, { nonce: "n2" })).valid).toBe(false);
24
+ const expired = createSiweMessage({ domain: "d", address: ADDR, uri: "u", chainId: 1, nonce: "n1", expirationTime: new Date(Date.now() - 1000).toISOString() });
25
+ const sig2 = await account.signMessage({ message: expired });
26
+ expect((await verifySiweMessage(expired, sig2)).valid).toBe(false);
27
+ });
28
+ });
29
+ describe("sessions", () => {
30
+ it("round-trips a payload and rejects tampering / expiry", async () => {
31
+ const token = await createSession({ sub: "0xabc", role: "member" }, "secret");
32
+ const p = await verifySession(token, "secret");
33
+ expect(p?.sub).toBe("0xabc");
34
+ expect(await verifySession(token, "wrong-secret")).toBeNull();
35
+ expect(await verifySession(token + "x", "secret")).toBeNull();
36
+ const expired = await createSession({ sub: "x" }, "s", { ttlSeconds: -1 });
37
+ expect(await verifySession(expired, "s")).toBeNull();
38
+ });
39
+ });
40
+ describe("aes", () => {
41
+ it("encrypts and decrypts; wrong secret throws", async () => {
42
+ const ct = await encrypt("hello world", "k1");
43
+ expect(ct).not.toContain("hello");
44
+ expect(await decrypt(ct, "k1")).toBe("hello world");
45
+ await expect(decrypt(ct, "k2")).rejects.toBeTruthy();
46
+ });
47
+ });
48
+ describe("Router", () => {
49
+ it("matches method + :params and 404s otherwise", async () => {
50
+ const r = new Router("/api").get("/u/:id", (_req, { params }) => json({ id: params.id }));
51
+ const res = await r.handle(new Request("https://x/api/u/42"), {});
52
+ expect(res.status).toBe(200);
53
+ expect(await res.json()).toEqual({ id: "42" });
54
+ const miss = await r.handle(new Request("https://x/api/nope"), {});
55
+ expect(miss.status).toBe(404);
56
+ });
57
+ });
@@ -0,0 +1,12 @@
1
+ import type { KvLike } from "./ratelimit.js";
2
+ export interface CacheOptions {
3
+ /** Time-to-live in seconds. */
4
+ ttlSeconds: number;
5
+ /** Optional key namespace (default "cache:"). */
6
+ prefix?: string;
7
+ }
8
+ /**
9
+ * Return the cached JSON value for `key`, or run `produce()`, store it with a TTL,
10
+ * and return it. A miss or a corrupt cached value falls through to `produce()`.
11
+ */
12
+ export declare function cached<T>(kv: KvLike, key: string, produce: () => Promise<T>, opts: CacheOptions): Promise<T>;
package/dist/cache.js ADDED
@@ -0,0 +1,27 @@
1
+ // KV-backed memoize — cache an expensive computation (an RPC read, an upstream API
2
+ // call) under a key with a TTL, so repeat requests are cheap. Pairs with any
3
+ // Workers-KV-shaped binding.
4
+ /**
5
+ * Return the cached JSON value for `key`, or run `produce()`, store it with a TTL,
6
+ * and return it. A miss or a corrupt cached value falls through to `produce()`.
7
+ */
8
+ export async function cached(kv, key, produce, opts) {
9
+ const k = (opts.prefix ?? "cache:") + key;
10
+ const hit = await kv.get(k);
11
+ if (hit != null) {
12
+ try {
13
+ return JSON.parse(hit);
14
+ }
15
+ catch {
16
+ /* corrupt entry — recompute */
17
+ }
18
+ }
19
+ const value = await produce();
20
+ try {
21
+ await kv.put(k, JSON.stringify(value), { expirationTtl: opts.ttlSeconds });
22
+ }
23
+ catch {
24
+ /* cache write best-effort */
25
+ }
26
+ return value;
27
+ }
@@ -0,0 +1,74 @@
1
+ interface MinimalEnv {
2
+ [key: string]: unknown;
3
+ }
4
+ export interface EmailOptions {
5
+ /** Platform email API base, e.g. https://<site>/email. Default env EMAIL_API_URL. */
6
+ apiUrl?: string;
7
+ /** Bearer scoping calls to this project. Default env EMAIL_API_TOKEN. */
8
+ apiToken?: string;
9
+ }
10
+ export type EmailDirection = "in" | "out";
11
+ /** One message in the project's inbox/outbox (newest first from `list`). */
12
+ export interface EmailMessage {
13
+ id: string;
14
+ direction: EmailDirection;
15
+ from: string;
16
+ to: string[];
17
+ cc: string[];
18
+ subject: string;
19
+ text: string | null;
20
+ html: string | null;
21
+ messageId: string | null;
22
+ inReplyTo: string | null;
23
+ read: boolean;
24
+ receivedAt: number;
25
+ }
26
+ export interface ListEmailsOptions {
27
+ /** Only unread inbound/outbound messages. */
28
+ unread?: boolean;
29
+ /** Max messages to return (1–100, default 20). */
30
+ limit?: number;
31
+ }
32
+ export interface SendEmailOptions {
33
+ to: string | string[];
34
+ subject: string;
35
+ text?: string;
36
+ html?: string;
37
+ /** Display name for the From (address is always the project's own). */
38
+ fromName?: string;
39
+ /** Message-ID this is replying to (sets In-Reply-To/References for threading). */
40
+ inReplyTo?: string;
41
+ }
42
+ export interface SendEmailResult {
43
+ ok: boolean;
44
+ message_id: string | null;
45
+ }
46
+ export declare class Email {
47
+ private readonly apiUrl?;
48
+ private readonly apiToken?;
49
+ constructor(env: MinimalEnv | undefined, options?: EmailOptions);
50
+ /** Recent messages, newest first. Defaults to the whole inbox+outbox (20). */
51
+ list(opts?: ListEmailsOptions): Promise<EmailMessage[]>;
52
+ /** Unread inbound messages, newest first (shorthand for list({ unread: true })). */
53
+ unread(limit?: number): Promise<EmailMessage[]>;
54
+ /** Fetch one message by id (full body). Returns null if it isn't in this inbox. */
55
+ get(id: string): Promise<EmailMessage | null>;
56
+ /** Mark a message read (or unread with read=false). */
57
+ markRead(id: string, read?: boolean): Promise<void>;
58
+ /** Send a new message AS the project ({slug}@livo.build). */
59
+ send(opts: SendEmailOptions): Promise<SendEmailResult>;
60
+ /**
61
+ * Reply to an inbound message: sends back to its sender, threads via Message-ID,
62
+ * prefixes "Re:" if needed, and marks the original read. Pass `to` to override
63
+ * the recipient.
64
+ */
65
+ reply(msg: EmailMessage, opts: {
66
+ text?: string;
67
+ html?: string;
68
+ fromName?: string;
69
+ to?: string | string[];
70
+ }): Promise<SendEmailResult>;
71
+ /** Internal: call an email API sub-path with the project bearer; throws on error. */
72
+ private call;
73
+ }
74
+ export {};
package/dist/email.js ADDED
@@ -0,0 +1,100 @@
1
+ // Email — the project's own inbox at {slug}@livo.build. Every Livo project gets a
2
+ // canonical address; this helper lets a deployed bot/server/keeper read incoming
3
+ // mail and send (or reply) AS the project, with zero key/SMTP handling. All calls
4
+ // go to the platform (EMAIL_API_URL, authorized by EMAIL_API_TOKEN, both injected
5
+ // at deploy); inbound is parsed + stored by the platform Email Worker, and sends
6
+ // go out through the platform's transactional provider — so a leaked Worker can
7
+ // only send AS this project, never spoof another.
8
+ //
9
+ // const email = new Email(env);
10
+ // const inbox = await email.list({ unread: true }); // newest first
11
+ // for (const m of inbox) {
12
+ // const full = await email.get(m.id); // full body
13
+ // await email.reply(full, { text: "thanks!" }); // sends + marks read
14
+ // }
15
+ // await email.send({ to: "a@b.com", subject: "hi", text: "hello" });
16
+ //
17
+ // The From address is always the project's own ({slug}@livo.build) — pass
18
+ // `fromName` for a display name. To poll on a schedule, call list() from a keeper;
19
+ // to react instantly, point a webhook at your api/ Worker (see livo://skill/email).
20
+ export class Email {
21
+ apiUrl;
22
+ apiToken;
23
+ constructor(env, options = {}) {
24
+ this.apiUrl = (options.apiUrl ?? env?.EMAIL_API_URL)?.replace(/\/$/, "");
25
+ this.apiToken = options.apiToken ?? env?.EMAIL_API_TOKEN;
26
+ }
27
+ /** Recent messages, newest first. Defaults to the whole inbox+outbox (20). */
28
+ async list(opts = {}) {
29
+ const qs = new URLSearchParams();
30
+ if (opts.unread)
31
+ qs.set("unread", "1");
32
+ if (opts.limit)
33
+ qs.set("limit", String(opts.limit));
34
+ const r = await this.call("GET", `list${qs.toString() ? `?${qs}` : ""}`);
35
+ return (r.messages ?? []);
36
+ }
37
+ /** Unread inbound messages, newest first (shorthand for list({ unread: true })). */
38
+ async unread(limit) {
39
+ return this.list({ unread: true, limit });
40
+ }
41
+ /** Fetch one message by id (full body). Returns null if it isn't in this inbox. */
42
+ async get(id) {
43
+ const r = await this.call("POST", "get", { id });
44
+ return r.message ?? null;
45
+ }
46
+ /** Mark a message read (or unread with read=false). */
47
+ async markRead(id, read = true) {
48
+ await this.call("POST", "mark-read", { id, read });
49
+ }
50
+ /** Send a new message AS the project ({slug}@livo.build). */
51
+ async send(opts) {
52
+ const r = await this.call("POST", "send", {
53
+ to: Array.isArray(opts.to) ? opts.to : [opts.to],
54
+ subject: opts.subject,
55
+ text: opts.text,
56
+ html: opts.html,
57
+ fromName: opts.fromName,
58
+ inReplyTo: opts.inReplyTo,
59
+ });
60
+ return { ok: r.ok === true, message_id: r.message_id ?? null };
61
+ }
62
+ /**
63
+ * Reply to an inbound message: sends back to its sender, threads via Message-ID,
64
+ * prefixes "Re:" if needed, and marks the original read. Pass `to` to override
65
+ * the recipient.
66
+ */
67
+ async reply(msg, opts) {
68
+ const subject = /^re:/i.test(msg.subject) ? msg.subject : `Re: ${msg.subject}`;
69
+ const result = await this.send({
70
+ to: opts.to ?? msg.from,
71
+ subject,
72
+ text: opts.text,
73
+ html: opts.html,
74
+ fromName: opts.fromName,
75
+ inReplyTo: msg.messageId ?? undefined,
76
+ });
77
+ if (result.ok && !msg.read)
78
+ await this.markRead(msg.id).catch(() => { });
79
+ return result;
80
+ }
81
+ /** Internal: call an email API sub-path with the project bearer; throws on error. */
82
+ async call(method, path, body) {
83
+ if (!this.apiUrl || !this.apiToken) {
84
+ throw new Error("Email: not provisioned — EMAIL_API_URL/EMAIL_API_TOKEN missing (redeploy so the platform injects them).");
85
+ }
86
+ const res = await fetch(`${this.apiUrl}/${path}`, {
87
+ method,
88
+ headers: {
89
+ authorization: `Bearer ${this.apiToken}`,
90
+ ...(body ? { "content-type": "application/json" } : {}),
91
+ },
92
+ body: body ? JSON.stringify(body) : undefined,
93
+ });
94
+ const j = (await res.json().catch(() => ({})));
95
+ if (!res.ok || j.error) {
96
+ throw new Error(`Email ${path}: ${String(j.message ?? j.error ?? `HTTP ${res.status}`)}`);
97
+ }
98
+ return j;
99
+ }
100
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { Email } from "./email.js";
3
+ const ENV = { EMAIL_API_URL: "https://site.example/email", EMAIL_API_TOKEN: "tok_123" };
4
+ function mockFetch(handler) {
5
+ const calls = [];
6
+ const fn = vi.fn(async (url, init) => {
7
+ const call = {
8
+ url,
9
+ method: init?.method ?? "GET",
10
+ auth: init?.headers?.authorization ?? null,
11
+ body: init?.body ? JSON.parse(init.body) : undefined,
12
+ };
13
+ calls.push(call);
14
+ const { status = 200, json } = handler(call);
15
+ return { ok: status >= 200 && status < 300, status, json: async () => json };
16
+ });
17
+ vi.stubGlobal("fetch", fn);
18
+ return calls;
19
+ }
20
+ afterEach(() => vi.unstubAllGlobals());
21
+ describe("Email", () => {
22
+ it("lists newest-first, passing the bearer and unread/limit query", async () => {
23
+ const calls = mockFetch(() => ({ json: { ok: true, address: "p@livo.build", messages: [{ id: "e1" }] } }));
24
+ const email = new Email(ENV);
25
+ const msgs = await email.list({ unread: true, limit: 5 });
26
+ expect(msgs).toEqual([{ id: "e1" }]);
27
+ expect(calls[0].url).toBe("https://site.example/email/list?unread=1&limit=5");
28
+ expect(calls[0].method).toBe("GET");
29
+ expect(calls[0].auth).toBe("Bearer tok_123");
30
+ });
31
+ it("send wraps a single recipient into an array and reports the message id", async () => {
32
+ const calls = mockFetch(() => ({ json: { ok: true, message_id: "m_42" } }));
33
+ const email = new Email(ENV);
34
+ const r = await email.send({ to: "a@b.com", subject: "hi", text: "yo" });
35
+ expect(r).toEqual({ ok: true, message_id: "m_42" });
36
+ expect(calls[0].url).toBe("https://site.example/email/send");
37
+ expect(calls[0].body).toMatchObject({ to: ["a@b.com"], subject: "hi", text: "yo" });
38
+ });
39
+ it("reply threads via Message-ID, prefixes Re:, and marks the original read", async () => {
40
+ const calls = mockFetch((c) => (c.url.endsWith("/send") ? { json: { ok: true, message_id: "out1" } } : { json: { ok: true } }));
41
+ const msg = {
42
+ id: "e9", direction: "in", from: "sender@x.com", to: ["p@livo.build"], cc: [],
43
+ subject: "Question", text: "?", html: null, messageId: "<abc@x.com>", inReplyTo: null,
44
+ read: false, receivedAt: 1,
45
+ };
46
+ const email = new Email(ENV);
47
+ const r = await email.reply(msg, { text: "answer" });
48
+ expect(r.ok).toBe(true);
49
+ const send = calls.find((c) => c.url.endsWith("/send"));
50
+ expect(send.body).toMatchObject({ to: ["sender@x.com"], subject: "Re: Question", inReplyTo: "<abc@x.com>" });
51
+ const mark = calls.find((c) => c.url.endsWith("/mark-read"));
52
+ expect(mark?.body).toMatchObject({ id: "e9", read: true });
53
+ });
54
+ it("does not double-prefix Re: on an already-Re subject", async () => {
55
+ const calls = mockFetch(() => ({ json: { ok: true, message_id: "x" } }));
56
+ const msg = { id: "e", direction: "in", from: "s@x.com", to: [], cc: [], subject: "Re: Hi", text: null, html: null, messageId: null, inReplyTo: null, read: true, receivedAt: 0 };
57
+ await new Email(ENV).reply(msg, { text: "k" });
58
+ expect(calls[0].body).toMatchObject({ subject: "Re: Hi" });
59
+ });
60
+ it("throws a helpful error when not provisioned", async () => {
61
+ mockFetch(() => ({ json: {} }));
62
+ await expect(new Email({}).list()).rejects.toThrow(/not provisioned/);
63
+ });
64
+ it("surfaces API errors with the server message", async () => {
65
+ mockFetch(() => ({ status: 403, json: { error: "forbidden", message: "bad token" } }));
66
+ await expect(new Email(ENV).list()).rejects.toThrow(/bad token/);
67
+ });
68
+ });
package/dist/http.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ export declare function json(body: unknown, status?: number, headers?: Record<string, string>): Response;
2
+ export declare function error(message: string, status?: number): Response;
3
+ export declare const CORS_HEADERS: Record<string, string>;
4
+ /** Add permissive CORS headers to a response. (Same-origin api/ usually doesn't need this.) */
5
+ export declare function withCors(res: Response): Response;
6
+ export interface SseConnection {
7
+ /** Push an event. Non-string `data` is JSON-stringified. */
8
+ send(data: unknown, opts?: {
9
+ event?: string;
10
+ id?: string;
11
+ }): void;
12
+ /** Send a comment line to keep the connection alive (heartbeat). */
13
+ ping(): void;
14
+ /** End the stream. */
15
+ close(): void;
16
+ /** True once the stream has been closed or cancelled by the client. */
17
+ readonly closed: boolean;
18
+ /** The Response to return from your fetch handler. */
19
+ readonly response: Response;
20
+ }
21
+ /**
22
+ * Open a Server-Sent Events stream from an api/ Worker — push realtime updates to a
23
+ * browser (e.g. live prices feeding a chart/dashboard) with no WebSocket plumbing.
24
+ * Return `conn.response` from your handler, then `conn.send(...)` as data arrives.
25
+ * Pass `{ signal: req.signal }` so it tears down when the client disconnects.
26
+ */
27
+ export declare function sse(init?: {
28
+ headers?: Record<string, string>;
29
+ signal?: AbortSignal;
30
+ }): SseConnection;
31
+ export interface RouteContext<Env = unknown> {
32
+ params: Record<string, string>;
33
+ url: URL;
34
+ env: Env;
35
+ }
36
+ export type RouteHandler<Env = unknown> = (req: Request, ctx: RouteContext<Env>) => Response | Promise<Response>;
37
+ export declare class Router<Env = unknown> {
38
+ private readonly basePath;
39
+ private routes;
40
+ constructor(basePath?: string);
41
+ private add;
42
+ get(path: string, h: RouteHandler<Env>): this;
43
+ post(path: string, h: RouteHandler<Env>): this;
44
+ put(path: string, h: RouteHandler<Env>): this;
45
+ delete(path: string, h: RouteHandler<Env>): this;
46
+ /** Match + dispatch a request. Returns the handler's response, or a 404. */
47
+ handle(req: Request, env: Env): Promise<Response>;
48
+ }
package/dist/http.js ADDED
@@ -0,0 +1,133 @@
1
+ // Tiny HTTP helpers for api/ Workers, bots, and servers — stop hand-rolling JSON
2
+ // responses and request routing.
3
+ export function json(body, status = 200, headers = {}) {
4
+ return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } });
5
+ }
6
+ export function error(message, status = 400) {
7
+ return json({ error: message }, status);
8
+ }
9
+ export const CORS_HEADERS = {
10
+ "access-control-allow-origin": "*",
11
+ "access-control-allow-methods": "GET,POST,PUT,DELETE,OPTIONS",
12
+ "access-control-allow-headers": "content-type,authorization",
13
+ };
14
+ /** Add permissive CORS headers to a response. (Same-origin api/ usually doesn't need this.) */
15
+ export function withCors(res) {
16
+ const h = new Headers(res.headers);
17
+ for (const [k, v] of Object.entries(CORS_HEADERS))
18
+ h.set(k, v);
19
+ return new Response(res.body, { status: res.status, headers: h });
20
+ }
21
+ /**
22
+ * Open a Server-Sent Events stream from an api/ Worker — push realtime updates to a
23
+ * browser (e.g. live prices feeding a chart/dashboard) with no WebSocket plumbing.
24
+ * Return `conn.response` from your handler, then `conn.send(...)` as data arrives.
25
+ * Pass `{ signal: req.signal }` so it tears down when the client disconnects.
26
+ */
27
+ export function sse(init = {}) {
28
+ const encoder = new TextEncoder();
29
+ let controller = null;
30
+ let isClosed = false;
31
+ const stream = new ReadableStream({
32
+ start(c) {
33
+ controller = c;
34
+ },
35
+ cancel() {
36
+ isClosed = true;
37
+ },
38
+ });
39
+ const write = (s) => {
40
+ if (isClosed || !controller)
41
+ return;
42
+ try {
43
+ controller.enqueue(encoder.encode(s));
44
+ }
45
+ catch {
46
+ isClosed = true;
47
+ }
48
+ };
49
+ const conn = {
50
+ send(data, opts) {
51
+ const payload = typeof data === "string" ? data : JSON.stringify(data);
52
+ let frame = "";
53
+ if (opts?.event)
54
+ frame += `event: ${opts.event}\n`;
55
+ if (opts?.id)
56
+ frame += `id: ${opts.id}\n`;
57
+ for (const line of payload.split("\n"))
58
+ frame += `data: ${line}\n`; // SSE: one data: line per \n
59
+ write(frame + "\n");
60
+ },
61
+ ping() {
62
+ write(": ping\n\n");
63
+ },
64
+ close() {
65
+ if (isClosed)
66
+ return;
67
+ isClosed = true;
68
+ try {
69
+ controller?.close();
70
+ }
71
+ catch {
72
+ /* already closed */
73
+ }
74
+ },
75
+ get closed() {
76
+ return isClosed;
77
+ },
78
+ response: new Response(stream, {
79
+ headers: {
80
+ "content-type": "text/event-stream",
81
+ "cache-control": "no-cache",
82
+ connection: "keep-alive",
83
+ ...(init.headers ?? {}),
84
+ },
85
+ }),
86
+ };
87
+ init.signal?.addEventListener("abort", () => conn.close());
88
+ return conn;
89
+ }
90
+ // A minimal method + path router with `:param` segments. `basePath` is stripped
91
+ // first (e.g. "/api/telegram"). Returns 404 when nothing matches.
92
+ export class Router {
93
+ basePath;
94
+ routes = [];
95
+ constructor(basePath = "") {
96
+ this.basePath = basePath;
97
+ }
98
+ add(method, path, handler) {
99
+ const keys = [];
100
+ const pattern = new RegExp("^" +
101
+ path.replace(/:[A-Za-z0-9_]+/g, (m) => {
102
+ keys.push(m.slice(1));
103
+ return "([^/]+)";
104
+ }) +
105
+ "/?$");
106
+ this.routes.push({ method, keys, pattern, handler });
107
+ return this;
108
+ }
109
+ get(path, h) { return this.add("GET", path, h); }
110
+ post(path, h) { return this.add("POST", path, h); }
111
+ put(path, h) { return this.add("PUT", path, h); }
112
+ delete(path, h) { return this.add("DELETE", path, h); }
113
+ /** Match + dispatch a request. Returns the handler's response, or a 404. */
114
+ async handle(req, env) {
115
+ const url = new URL(req.url);
116
+ let path = url.pathname;
117
+ if (this.basePath && path.startsWith(this.basePath))
118
+ path = path.slice(this.basePath.length) || "/";
119
+ if (req.method === "OPTIONS")
120
+ return withCors(new Response(null, { status: 204 }));
121
+ for (const r of this.routes) {
122
+ if (r.method !== req.method)
123
+ continue;
124
+ const m = r.pattern.exec(path);
125
+ if (!m)
126
+ continue;
127
+ const params = {};
128
+ r.keys.forEach((k, i) => (params[k] = decodeURIComponent(m[i + 1])));
129
+ return r.handler(req, { params, url, env });
130
+ }
131
+ return error("not_found", 404);
132
+ }
133
+ }