lopata 0.0.1

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.
Files changed (147) hide show
  1. package/README.md +15 -0
  2. package/package.json +51 -0
  3. package/runtime/bindings/ai.ts +132 -0
  4. package/runtime/bindings/analytics-engine.ts +96 -0
  5. package/runtime/bindings/browser.ts +64 -0
  6. package/runtime/bindings/cache.ts +179 -0
  7. package/runtime/bindings/cf-streams.ts +56 -0
  8. package/runtime/bindings/container-docker.ts +225 -0
  9. package/runtime/bindings/container.ts +662 -0
  10. package/runtime/bindings/crypto-extras.ts +89 -0
  11. package/runtime/bindings/d1.ts +315 -0
  12. package/runtime/bindings/do-executor-inprocess.ts +140 -0
  13. package/runtime/bindings/do-executor-worker.ts +368 -0
  14. package/runtime/bindings/do-executor.ts +45 -0
  15. package/runtime/bindings/do-websocket-bridge.ts +70 -0
  16. package/runtime/bindings/do-worker-entry.ts +220 -0
  17. package/runtime/bindings/do-worker-env.ts +74 -0
  18. package/runtime/bindings/durable-object.ts +992 -0
  19. package/runtime/bindings/email.ts +180 -0
  20. package/runtime/bindings/html-rewriter.ts +84 -0
  21. package/runtime/bindings/hyperdrive.ts +130 -0
  22. package/runtime/bindings/images.ts +381 -0
  23. package/runtime/bindings/kv.ts +359 -0
  24. package/runtime/bindings/queue.ts +507 -0
  25. package/runtime/bindings/r2.ts +759 -0
  26. package/runtime/bindings/rpc-stub.ts +267 -0
  27. package/runtime/bindings/scheduled.ts +172 -0
  28. package/runtime/bindings/service-binding.ts +217 -0
  29. package/runtime/bindings/static-assets.ts +481 -0
  30. package/runtime/bindings/websocket-pair.ts +182 -0
  31. package/runtime/bindings/workflow.ts +858 -0
  32. package/runtime/bunflare-config.ts +56 -0
  33. package/runtime/cli/cache.ts +39 -0
  34. package/runtime/cli/context.ts +105 -0
  35. package/runtime/cli/d1.ts +163 -0
  36. package/runtime/cli/dev.ts +392 -0
  37. package/runtime/cli/kv.ts +84 -0
  38. package/runtime/cli/queues.ts +109 -0
  39. package/runtime/cli/r2.ts +140 -0
  40. package/runtime/cli/traces.ts +251 -0
  41. package/runtime/cli.ts +102 -0
  42. package/runtime/config.ts +148 -0
  43. package/runtime/d1-migrate.ts +37 -0
  44. package/runtime/dashboard/api.ts +174 -0
  45. package/runtime/dashboard/app.tsx +220 -0
  46. package/runtime/dashboard/components/breadcrumb.tsx +16 -0
  47. package/runtime/dashboard/components/buttons.tsx +13 -0
  48. package/runtime/dashboard/components/code-block.tsx +5 -0
  49. package/runtime/dashboard/components/detail-field.tsx +8 -0
  50. package/runtime/dashboard/components/empty-state.tsx +8 -0
  51. package/runtime/dashboard/components/filter-input.tsx +11 -0
  52. package/runtime/dashboard/components/index.ts +16 -0
  53. package/runtime/dashboard/components/key-value-table.tsx +23 -0
  54. package/runtime/dashboard/components/modal.tsx +23 -0
  55. package/runtime/dashboard/components/page-header.tsx +11 -0
  56. package/runtime/dashboard/components/pill-button.tsx +14 -0
  57. package/runtime/dashboard/components/refresh-button.tsx +7 -0
  58. package/runtime/dashboard/components/service-info.tsx +45 -0
  59. package/runtime/dashboard/components/status-badge.tsx +7 -0
  60. package/runtime/dashboard/components/table-link.tsx +5 -0
  61. package/runtime/dashboard/components/table.tsx +26 -0
  62. package/runtime/dashboard/components.tsx +19 -0
  63. package/runtime/dashboard/index.html +23 -0
  64. package/runtime/dashboard/lib.ts +45 -0
  65. package/runtime/dashboard/rpc/client.ts +20 -0
  66. package/runtime/dashboard/rpc/handlers/ai.ts +71 -0
  67. package/runtime/dashboard/rpc/handlers/analytics-engine.ts +53 -0
  68. package/runtime/dashboard/rpc/handlers/cache.ts +24 -0
  69. package/runtime/dashboard/rpc/handlers/config.ts +137 -0
  70. package/runtime/dashboard/rpc/handlers/containers.ts +194 -0
  71. package/runtime/dashboard/rpc/handlers/d1.ts +84 -0
  72. package/runtime/dashboard/rpc/handlers/do.ts +117 -0
  73. package/runtime/dashboard/rpc/handlers/email.ts +82 -0
  74. package/runtime/dashboard/rpc/handlers/errors.ts +32 -0
  75. package/runtime/dashboard/rpc/handlers/generations.ts +60 -0
  76. package/runtime/dashboard/rpc/handlers/kv.ts +76 -0
  77. package/runtime/dashboard/rpc/handlers/overview.ts +94 -0
  78. package/runtime/dashboard/rpc/handlers/queue.ts +79 -0
  79. package/runtime/dashboard/rpc/handlers/r2.ts +72 -0
  80. package/runtime/dashboard/rpc/handlers/scheduled.ts +91 -0
  81. package/runtime/dashboard/rpc/handlers/traces.ts +64 -0
  82. package/runtime/dashboard/rpc/handlers/workers.ts +65 -0
  83. package/runtime/dashboard/rpc/handlers/workflows.ts +171 -0
  84. package/runtime/dashboard/rpc/hooks.ts +132 -0
  85. package/runtime/dashboard/rpc/server.ts +70 -0
  86. package/runtime/dashboard/rpc/types.ts +396 -0
  87. package/runtime/dashboard/sql-browser/data-browser-tab.tsx +122 -0
  88. package/runtime/dashboard/sql-browser/editable-cell.tsx +117 -0
  89. package/runtime/dashboard/sql-browser/filter-row.tsx +99 -0
  90. package/runtime/dashboard/sql-browser/history-panels.tsx +110 -0
  91. package/runtime/dashboard/sql-browser/hooks.ts +137 -0
  92. package/runtime/dashboard/sql-browser/index.ts +4 -0
  93. package/runtime/dashboard/sql-browser/insert-row-form.tsx +85 -0
  94. package/runtime/dashboard/sql-browser/modals.tsx +116 -0
  95. package/runtime/dashboard/sql-browser/schema-browser-tab.tsx +67 -0
  96. package/runtime/dashboard/sql-browser/sql-browser.tsx +52 -0
  97. package/runtime/dashboard/sql-browser/sql-console-tab.tsx +124 -0
  98. package/runtime/dashboard/sql-browser/table-data-view.tsx +566 -0
  99. package/runtime/dashboard/sql-browser/table-sidebar.tsx +38 -0
  100. package/runtime/dashboard/sql-browser/types.ts +61 -0
  101. package/runtime/dashboard/sql-browser/utils.ts +167 -0
  102. package/runtime/dashboard/style.css +177 -0
  103. package/runtime/dashboard/views/ai.tsx +152 -0
  104. package/runtime/dashboard/views/analytics-engine.tsx +169 -0
  105. package/runtime/dashboard/views/cache.tsx +93 -0
  106. package/runtime/dashboard/views/containers.tsx +197 -0
  107. package/runtime/dashboard/views/d1.tsx +81 -0
  108. package/runtime/dashboard/views/do.tsx +168 -0
  109. package/runtime/dashboard/views/email.tsx +235 -0
  110. package/runtime/dashboard/views/errors.tsx +558 -0
  111. package/runtime/dashboard/views/home.tsx +287 -0
  112. package/runtime/dashboard/views/kv.tsx +273 -0
  113. package/runtime/dashboard/views/queue.tsx +193 -0
  114. package/runtime/dashboard/views/r2.tsx +202 -0
  115. package/runtime/dashboard/views/scheduled.tsx +89 -0
  116. package/runtime/dashboard/views/trace-waterfall.tsx +410 -0
  117. package/runtime/dashboard/views/traces.tsx +768 -0
  118. package/runtime/dashboard/views/workers.tsx +55 -0
  119. package/runtime/dashboard/views/workflows.tsx +473 -0
  120. package/runtime/db.ts +258 -0
  121. package/runtime/env.ts +362 -0
  122. package/runtime/error-page/app.tsx +394 -0
  123. package/runtime/error-page/build.ts +269 -0
  124. package/runtime/error-page/index.html +16 -0
  125. package/runtime/error-page/style.css +31 -0
  126. package/runtime/execution-context.ts +18 -0
  127. package/runtime/file-watcher.ts +57 -0
  128. package/runtime/generation-manager.ts +230 -0
  129. package/runtime/generation.ts +411 -0
  130. package/runtime/plugin.ts +292 -0
  131. package/runtime/request-cf.ts +28 -0
  132. package/runtime/rpc-validate.ts +154 -0
  133. package/runtime/tracing/context.ts +40 -0
  134. package/runtime/tracing/db.ts +73 -0
  135. package/runtime/tracing/frames.ts +75 -0
  136. package/runtime/tracing/instrument.ts +186 -0
  137. package/runtime/tracing/span.ts +138 -0
  138. package/runtime/tracing/store.ts +499 -0
  139. package/runtime/tracing/types.ts +47 -0
  140. package/runtime/vite-plugin/config-plugin.ts +68 -0
  141. package/runtime/vite-plugin/dev-server-plugin.ts +493 -0
  142. package/runtime/vite-plugin/dist/index.mjs +52333 -0
  143. package/runtime/vite-plugin/globals-plugin.ts +94 -0
  144. package/runtime/vite-plugin/index.ts +43 -0
  145. package/runtime/vite-plugin/modules-plugin.ts +88 -0
  146. package/runtime/vite-plugin/react-router-plugin.ts +95 -0
  147. package/runtime/worker-registry.ts +52 -0
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # bunflare
2
+
3
+ To install dependencies:
4
+
5
+ ```bash
6
+ bun install
7
+ ```
8
+
9
+ To run:
10
+
11
+ ```bash
12
+ bun run index.ts
13
+ ```
14
+
15
+ This project was created using `bun init` in bun v1.3.2. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "lopata",
3
+ "version": "0.0.1",
4
+ "module": "index.ts",
5
+ "type": "module",
6
+ "bin": {
7
+ "lopata": "runtime/cli.ts"
8
+ },
9
+ "exports": {
10
+ "./vite-plugin": "./runtime/vite-plugin/index.ts"
11
+ },
12
+ "files": [
13
+ "runtime/",
14
+ "!runtime/tests/",
15
+ "index.ts"
16
+ ],
17
+ "scripts": {
18
+ "wrangler:dev": "wrangler dev",
19
+ "deploy": "wrangler deploy",
20
+ "types": "wrangler types",
21
+ "cli": "bun runtime/cli.ts",
22
+ "dev": "bun runtime/cli.ts dev",
23
+ "lint": "biome lint .",
24
+ "lint:fix": "biome lint . --write",
25
+ "format": "dprint fmt",
26
+ "format:check": "dprint check"
27
+ },
28
+ "devDependencies": {
29
+ "@biomejs/biome": "^2.3.13",
30
+ "@types/bun": "latest",
31
+ "dprint": "^0.51.1",
32
+ "bun-plugin-tailwind": "^0.1.2",
33
+ "vite": "^7.3.1",
34
+ "wrangler": "^4.65.0"
35
+ },
36
+ "peerDependencies": {
37
+ "typescript": "^5"
38
+ },
39
+ "optionalDependencies": {
40
+ "puppeteer": "*",
41
+ "puppeteer-core": "*"
42
+ },
43
+ "dependencies": {
44
+ "@cloudflare/sandbox": "^0.7.4",
45
+ "html-rewriter-wasm": "^0.4.1",
46
+ "preact": "^10.28.3",
47
+ "sharp": "^0.34.5",
48
+ "smol-toml": "^1.6.0",
49
+ "tailwindcss": "^4.1.18"
50
+ }
51
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Local implementation of the Cloudflare Workers AI binding.
3
+ * Proxies requests to the Cloudflare AI API and logs them to SQLite.
4
+ */
5
+ import type { Database } from "bun:sqlite";
6
+
7
+ const MAX_LOG_SIZE = 1024;
8
+
9
+ function truncate(value: unknown): string {
10
+ const str = typeof value === "string" ? value : JSON.stringify(value);
11
+ if (!str) return "";
12
+ return str.length > MAX_LOG_SIZE ? str.slice(0, MAX_LOG_SIZE) + "…" : str;
13
+ }
14
+
15
+ export class AiBinding {
16
+ private readonly db: Database;
17
+ private readonly accountId?: string;
18
+ private readonly apiToken?: string;
19
+ aiGatewayLogId: string | null = null;
20
+
21
+ constructor(db: Database, accountId?: string, apiToken?: string) {
22
+ this.db = db;
23
+ this.accountId = accountId;
24
+ this.apiToken = apiToken;
25
+ }
26
+
27
+ private ensureCredentials(): { accountId: string; apiToken: string } {
28
+ if (!this.accountId || !this.apiToken) {
29
+ throw new Error(
30
+ "Workers AI requires CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN in .dev.vars",
31
+ );
32
+ }
33
+ return { accountId: this.accountId, apiToken: this.apiToken };
34
+ }
35
+
36
+ async run(model: string, inputs: Record<string, unknown>, options?: { returnRawResponse?: boolean }): Promise<unknown> {
37
+ const { accountId, apiToken } = this.ensureCredentials();
38
+ const isStreaming = !!inputs.stream;
39
+ const id = crypto.randomUUID();
40
+ const start = Date.now();
41
+
42
+ let status = "ok";
43
+ let error: string | undefined;
44
+ let outputSummary = "";
45
+
46
+ try {
47
+ const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}`;
48
+ const response = await fetch(url, {
49
+ method: "POST",
50
+ headers: {
51
+ Authorization: `Bearer ${apiToken}`,
52
+ "Content-Type": "application/json",
53
+ },
54
+ body: JSON.stringify(inputs),
55
+ });
56
+
57
+ if (!response.ok) {
58
+ const text = await response.text();
59
+ status = "error";
60
+ error = `HTTP ${response.status}: ${text}`;
61
+ throw new Error(error);
62
+ }
63
+
64
+ if (isStreaming) {
65
+ outputSummary = "<streaming>";
66
+ return response.body;
67
+ }
68
+
69
+ if (options?.returnRawResponse) {
70
+ outputSummary = "<raw response>";
71
+ return response;
72
+ }
73
+
74
+ const json = await response.json() as { result?: unknown };
75
+ outputSummary = truncate(json.result);
76
+ return json.result;
77
+ } catch (err) {
78
+ if (status !== "error") {
79
+ status = "error";
80
+ error = err instanceof Error ? err.message : String(err);
81
+ }
82
+ throw err;
83
+ } finally {
84
+ const duration = Date.now() - start;
85
+ this.db.prepare(
86
+ `INSERT INTO ai_requests (id, model, input_summary, output_summary, duration_ms, status, error, is_streaming, created_at)
87
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
88
+ ).run(
89
+ id,
90
+ model,
91
+ truncate(inputs),
92
+ outputSummary,
93
+ duration,
94
+ status,
95
+ error ?? null,
96
+ isStreaming ? 1 : 0,
97
+ start,
98
+ );
99
+ }
100
+ }
101
+
102
+ async models(params?: Record<string, string>): Promise<unknown[]> {
103
+ const { accountId, apiToken } = this.ensureCredentials();
104
+ const url = new URL(`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/models/search`);
105
+ if (params) {
106
+ for (const [key, value] of Object.entries(params)) {
107
+ url.searchParams.set(key, value);
108
+ }
109
+ }
110
+ const response = await fetch(url.toString(), {
111
+ headers: { Authorization: `Bearer ${apiToken}` },
112
+ });
113
+ if (!response.ok) {
114
+ const text = await response.text();
115
+ throw new Error(`Workers AI models() failed: HTTP ${response.status}: ${text}`);
116
+ }
117
+ const json = await response.json() as { result?: unknown[] };
118
+ return json.result ?? [];
119
+ }
120
+
121
+ gateway(_id: string): never {
122
+ throw new Error("ai.gateway() is not supported in local dev mode");
123
+ }
124
+
125
+ autorag(_id: string): never {
126
+ throw new Error("ai.autorag() is not supported in local dev mode");
127
+ }
128
+
129
+ toMarkdown(): never {
130
+ throw new Error("ai.toMarkdown() is not supported in local dev mode");
131
+ }
132
+ }
@@ -0,0 +1,96 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { randomUUIDv7 } from "bun";
3
+
4
+ export interface AnalyticsEngineDataPoint {
5
+ indexes?: ((ArrayBuffer | string) | null)[];
6
+ doubles?: number[];
7
+ blobs?: ((ArrayBuffer | string) | null)[];
8
+ }
9
+
10
+ const MAX_INDEXES = 1;
11
+ const MAX_INDEX_BYTES = 96;
12
+ const MAX_DOUBLES = 20;
13
+ const MAX_BLOBS = 20;
14
+ const MAX_BLOBS_TOTAL_BYTES = 16 * 1024; // 16 KB
15
+
16
+ function toText(value: ArrayBuffer | string | null | undefined): string | null {
17
+ if (value == null) return null;
18
+ if (typeof value === "string") return value;
19
+ return new TextDecoder().decode(value);
20
+ }
21
+
22
+ function byteLength(value: ArrayBuffer | string | null | undefined): number {
23
+ if (value == null) return 0;
24
+ if (typeof value === "string") return new TextEncoder().encode(value).byteLength;
25
+ return value.byteLength;
26
+ }
27
+
28
+ /**
29
+ * SqliteAnalyticsEngine — local implementation of the Cloudflare Analytics Engine
30
+ * `writeDataPoint()` binding. Stores data points in SQLite.
31
+ */
32
+ export class SqliteAnalyticsEngine {
33
+ private db: Database;
34
+ private dataset: string;
35
+ private insertStmt: ReturnType<Database["query"]>;
36
+
37
+ constructor(db: Database, dataset: string) {
38
+ this.db = db;
39
+ this.dataset = dataset;
40
+
41
+ const blobCols = Array.from({ length: MAX_BLOBS }, (_, i) => `blob${i + 1}`);
42
+ const doubleCols = Array.from({ length: MAX_DOUBLES }, (_, i) => `double${i + 1}`);
43
+ const allCols = ["id", "dataset", "timestamp", "_sample_interval", "index1", ...blobCols, ...doubleCols];
44
+ const placeholders = allCols.map(() => "?").join(", ");
45
+ this.insertStmt = db.query(`INSERT INTO analytics_engine (${allCols.join(", ")}) VALUES (${placeholders})`);
46
+ }
47
+
48
+ writeDataPoint(event?: AnalyticsEngineDataPoint): void {
49
+ const indexes = event?.indexes ?? [];
50
+ const doubles = event?.doubles ?? [];
51
+ const blobs = event?.blobs ?? [];
52
+
53
+ // Validate indexes
54
+ if (indexes.length > MAX_INDEXES) {
55
+ throw new Error(`Analytics Engine: indexes array exceeds maximum length of ${MAX_INDEXES}`);
56
+ }
57
+ for (const idx of indexes) {
58
+ if (idx != null && byteLength(idx) > MAX_INDEX_BYTES) {
59
+ throw new Error(`Analytics Engine: index value exceeds maximum size of ${MAX_INDEX_BYTES} bytes`);
60
+ }
61
+ }
62
+
63
+ // Validate doubles
64
+ if (doubles.length > MAX_DOUBLES) {
65
+ throw new Error(`Analytics Engine: doubles array exceeds maximum length of ${MAX_DOUBLES}`);
66
+ }
67
+
68
+ // Validate blobs
69
+ if (blobs.length > MAX_BLOBS) {
70
+ throw new Error(`Analytics Engine: blobs array exceeds maximum length of ${MAX_BLOBS}`);
71
+ }
72
+ let totalBlobBytes = 0;
73
+ for (const blob of blobs) {
74
+ totalBlobBytes += byteLength(blob);
75
+ }
76
+ if (totalBlobBytes > MAX_BLOBS_TOTAL_BYTES) {
77
+ throw new Error(`Analytics Engine: total blob size exceeds maximum of ${MAX_BLOBS_TOTAL_BYTES} bytes`);
78
+ }
79
+
80
+ const id = randomUUIDv7();
81
+ const timestamp = Date.now();
82
+ const index1 = toText(indexes[0]);
83
+
84
+ const blobValues: (string | null)[] = [];
85
+ for (let i = 0; i < MAX_BLOBS; i++) {
86
+ blobValues.push(i < blobs.length ? toText(blobs[i]) : null);
87
+ }
88
+
89
+ const doubleValues: (number | null)[] = [];
90
+ for (let i = 0; i < MAX_DOUBLES; i++) {
91
+ doubleValues.push(i < doubles.length ? doubles[i]! : null);
92
+ }
93
+
94
+ this.insertStmt.run(id, this.dataset, timestamp, 1, index1, ...blobValues, ...doubleValues);
95
+ }
96
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Local shim for Cloudflare's Browser Rendering binding.
3
+ *
4
+ * On Cloudflare, env.BROWSER is a Fetcher ({ fetch }) that proxies to
5
+ * the Browser Rendering API. @cloudflare/puppeteer calls
6
+ * endpoint.fetch("/v1/acquire") etc. under the hood.
7
+ *
8
+ * For local dev we skip the Fetcher layer entirely — the plugin shim
9
+ * for @cloudflare/puppeteer delegates directly to this class which
10
+ * uses real puppeteer / puppeteer-core.
11
+ */
12
+
13
+ export interface ActiveSession {
14
+ sessionId: string;
15
+ startTime: number;
16
+ connectionId?: string;
17
+ connectionStartTime?: number;
18
+ }
19
+
20
+ export class BrowserBinding {
21
+ /** Managed browser instance (when launched locally, not via wsEndpoint). */
22
+ private _browser: any = null;
23
+
24
+ constructor(private config: { wsEndpoint?: string; executablePath?: string; headless?: boolean }) {}
25
+
26
+ /** Launch a new browser and return a puppeteer Browser instance. */
27
+ async launch(opts?: { keep_alive?: number }): Promise<any> {
28
+ if (this.config.wsEndpoint) {
29
+ // @ts-ignore — puppeteer-core is an optional dependency
30
+ const puppeteer = await import("puppeteer-core");
31
+ this._browser = await puppeteer.default.connect({ browserWSEndpoint: this.config.wsEndpoint });
32
+ return this._browser;
33
+ }
34
+ // @ts-ignore — puppeteer is an optional dependency
35
+ const puppeteer = await import("puppeteer");
36
+ this._browser = await puppeteer.default.launch({
37
+ headless: this.config.headless ?? true,
38
+ executablePath: this.config.executablePath,
39
+ });
40
+ return this._browser;
41
+ }
42
+
43
+ /** Connect to an existing browser session by sessionId. */
44
+ async connect(sessionId: string): Promise<any> {
45
+ if (this.config.wsEndpoint) {
46
+ // @ts-ignore — puppeteer-core is an optional dependency
47
+ const puppeteer = await import("puppeteer-core");
48
+ return puppeteer.default.connect({ browserWSEndpoint: this.config.wsEndpoint });
49
+ }
50
+ // In local dev without wsEndpoint, return the existing managed browser if available
51
+ if (this._browser && this._browser.isConnected()) {
52
+ return this._browser;
53
+ }
54
+ throw new Error("connect() requires wsEndpoint in browser config, or launch() first");
55
+ }
56
+
57
+ /** List active sessions (stub — local dev has at most one). */
58
+ async sessions(): Promise<ActiveSession[]> {
59
+ if (this._browser && this._browser.isConnected()) {
60
+ return [{ sessionId: "local", startTime: Date.now() }];
61
+ }
62
+ return [];
63
+ }
64
+ }
@@ -0,0 +1,179 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ export interface CacheLimits {
4
+ maxBodySize?: number; // default 512 MiB (CF limit)
5
+ maxHeaderSize?: number; // default 32 KiB per header pair
6
+ }
7
+
8
+ const CACHE_DEFAULTS: Required<CacheLimits> = {
9
+ maxBodySize: 512 * 1024 * 1024,
10
+ maxHeaderSize: 32 * 1024,
11
+ };
12
+
13
+ /**
14
+ * Parse Cache-Control header and return the effective max-age in seconds,
15
+ * or null if no cacheable directive found.
16
+ * Returns -1 for no-store (should not be cached at all).
17
+ */
18
+ function parseCacheControlMaxAge(header: string | null): number | null {
19
+ if (!header) return null;
20
+
21
+ const directives = header.toLowerCase().split(",").map((d) => d.trim());
22
+
23
+ for (const d of directives) {
24
+ if (d === "no-store") return -1;
25
+ }
26
+
27
+ // s-maxage takes precedence over max-age for shared caches (CF behavior)
28
+ for (const d of directives) {
29
+ const sMaxAgeMatch = d.match(/^s-maxage\s*=\s*(\d+)$/);
30
+ if (sMaxAgeMatch?.[1]) return parseInt(sMaxAgeMatch[1], 10);
31
+ }
32
+
33
+ for (const d of directives) {
34
+ const maxAgeMatch = d.match(/^max-age\s*=\s*(\d+)$/);
35
+ if (maxAgeMatch?.[1]) return parseInt(maxAgeMatch[1], 10);
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * Compute the expiration timestamp (ms since epoch) from response headers.
43
+ * Returns null if no expiration info is present (cache indefinitely).
44
+ */
45
+ function computeExpiresAt(headers: Headers): number | null {
46
+ const cacheControl = headers.get("cache-control");
47
+ const maxAge = parseCacheControlMaxAge(cacheControl);
48
+
49
+ if (maxAge === -1) return -1; // signal: no-store
50
+ if (maxAge !== null) return Date.now() + maxAge * 1000;
51
+
52
+ // Fallback to Expires header
53
+ const expires = headers.get("expires");
54
+ if (expires) {
55
+ const expiresTime = Date.parse(expires);
56
+ if (!isNaN(expiresTime)) return expiresTime;
57
+ }
58
+
59
+ return null;
60
+ }
61
+
62
+ export class SqliteCache {
63
+ private db: Database;
64
+ private cacheName: string;
65
+ private limits: Required<CacheLimits>;
66
+
67
+ constructor(db: Database, cacheName: string, limits?: CacheLimits) {
68
+ this.db = db;
69
+ this.cacheName = cacheName;
70
+ this.limits = { ...CACHE_DEFAULTS, ...limits };
71
+ }
72
+
73
+ async match(request: Request | string, options?: { ignoreMethod?: boolean }): Promise<Response | undefined> {
74
+ const req = typeof request === "string" ? new Request(request) : request;
75
+
76
+ // Only GET requests are cacheable unless ignoreMethod is true
77
+ if (req.method !== "GET" && !options?.ignoreMethod) {
78
+ return undefined;
79
+ }
80
+
81
+ const url = req.url;
82
+ const row = this.db.query<{ status: number; headers: string; body: Uint8Array; expires_at: number | null }, [string, string]>(
83
+ "SELECT status, headers, body, expires_at FROM cache_entries WHERE cache_name = ? AND url = ?"
84
+ ).get(this.cacheName, url);
85
+
86
+ if (!row) return undefined;
87
+
88
+ // Check expiration — lazily delete expired entries
89
+ if (row.expires_at !== null && row.expires_at <= Date.now()) {
90
+ this.db.query(
91
+ "DELETE FROM cache_entries WHERE cache_name = ? AND url = ?"
92
+ ).run(this.cacheName, url);
93
+ return undefined;
94
+ }
95
+
96
+ const headers = new Headers(JSON.parse(row.headers));
97
+ headers.set("cf-cache-status", "HIT");
98
+ return new Response(row.body, { status: row.status, headers });
99
+ }
100
+
101
+ async put(request: Request | string, response: Response): Promise<void> {
102
+ const req = typeof request === "string" ? new Request(request) : request;
103
+
104
+ // Only GET requests are cacheable
105
+ if (req.method !== "GET") {
106
+ throw new Error("Cache API only supports caching GET requests");
107
+ }
108
+
109
+ // Reject 206 Partial Content
110
+ if (response.status === 206) {
111
+ throw new Error("Cache API does not support caching 206 Partial Content responses");
112
+ }
113
+
114
+ // Reject Vary: *
115
+ const vary = response.headers.get("vary");
116
+ if (vary && vary.trim() === "*") {
117
+ throw new Error("Cache API does not support caching responses with Vary: *");
118
+ }
119
+
120
+ // Responses with Set-Cookie header should not be cached (Cloudflare behavior)
121
+ if (response.headers.has("Set-Cookie")) {
122
+ return;
123
+ }
124
+
125
+ // Parse expiration from Cache-Control / Expires
126
+ const expiresAt = computeExpiresAt(response.headers);
127
+
128
+ // no-store — don't cache at all
129
+ if (expiresAt === -1) {
130
+ return;
131
+ }
132
+
133
+ const url = req.url;
134
+ const status = response.status;
135
+ const headers = JSON.stringify([...response.headers.entries()]);
136
+ const body = new Uint8Array(await response.arrayBuffer());
137
+
138
+ // Validate body size
139
+ if (body.byteLength > this.limits.maxBodySize) {
140
+ throw new Error(`Response body exceeds max size of ${this.limits.maxBodySize} bytes`);
141
+ }
142
+
143
+ this.db.query(
144
+ "INSERT OR REPLACE INTO cache_entries (cache_name, url, status, headers, body, expires_at) VALUES (?, ?, ?, ?, ?, ?)"
145
+ ).run(this.cacheName, url, status, headers, body, expiresAt);
146
+ }
147
+
148
+ async delete(request: Request | string, options?: { ignoreMethod?: boolean }): Promise<boolean> {
149
+ const req = typeof request === "string" ? new Request(request) : request;
150
+
151
+ // Only GET requests are cacheable unless ignoreMethod is true
152
+ if (req.method !== "GET" && !options?.ignoreMethod) {
153
+ return false;
154
+ }
155
+
156
+ const url = req.url;
157
+ const result = this.db.query(
158
+ "DELETE FROM cache_entries WHERE cache_name = ? AND url = ?"
159
+ ).run(this.cacheName, url);
160
+
161
+ return result.changes > 0;
162
+ }
163
+ }
164
+
165
+ export class SqliteCacheStorage {
166
+ private db: Database;
167
+ private limits?: CacheLimits;
168
+ public default: SqliteCache;
169
+
170
+ constructor(db: Database, limits?: CacheLimits) {
171
+ this.db = db;
172
+ this.limits = limits;
173
+ this.default = new SqliteCache(db, "default", limits);
174
+ }
175
+
176
+ async open(cacheName: string): Promise<SqliteCache> {
177
+ return new SqliteCache(this.db, cacheName, this.limits);
178
+ }
179
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Cloudflare-specific stream classes: IdentityTransformStream and FixedLengthStream.
3
+ */
4
+
5
+ /**
6
+ * IdentityTransformStream — passes bytes through unchanged.
7
+ * Functionally equivalent to `new TransformStream()` but semantically indicates byte stream passthrough.
8
+ */
9
+ export class IdentityTransformStream extends TransformStream<Uint8Array, Uint8Array> {
10
+ constructor() {
11
+ super();
12
+ }
13
+ }
14
+
15
+ /**
16
+ * FixedLengthStream — enforces exact byte count.
17
+ * Errors if total bytes written exceed `expectedLength` or if closed before reaching it.
18
+ */
19
+ export class FixedLengthStream extends TransformStream<Uint8Array, Uint8Array> {
20
+ readonly expectedLength: number;
21
+
22
+ constructor(expectedLength: number | bigint) {
23
+ const length = Number(expectedLength);
24
+ if (!Number.isFinite(length) || length < 0) {
25
+ throw new TypeError("FixedLengthStream requires a non-negative length");
26
+ }
27
+
28
+ let bytesWritten = 0;
29
+
30
+ super({
31
+ transform(chunk, controller) {
32
+ bytesWritten += chunk.byteLength;
33
+ if (bytesWritten > length) {
34
+ controller.error(
35
+ new TypeError(
36
+ `FixedLengthStream: exceeded expected length of ${length} bytes (got ${bytesWritten})`
37
+ )
38
+ );
39
+ return;
40
+ }
41
+ controller.enqueue(chunk);
42
+ },
43
+ flush(controller) {
44
+ if (bytesWritten < length) {
45
+ controller.error(
46
+ new TypeError(
47
+ `FixedLengthStream: stream closed with ${bytesWritten} bytes, expected ${length}`
48
+ )
49
+ );
50
+ }
51
+ },
52
+ });
53
+
54
+ this.expectedLength = length;
55
+ }
56
+ }