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
@@ -0,0 +1,481 @@
1
+ import path from "node:path";
2
+ import { statSync, readFileSync, existsSync } from "node:fs";
3
+
4
+ export interface StaticAssetsConfig {
5
+ directory: string;
6
+ binding?: string;
7
+ html_handling?: "none" | "auto-trailing-slash" | "force-trailing-slash" | "drop-trailing-slash";
8
+ not_found_handling?: "none" | "404-page" | "single-page-application";
9
+ run_worker_first?: boolean | string[];
10
+ }
11
+
12
+ export interface StaticAssetsLimits {
13
+ maxHeaderRules?: number; // default 100
14
+ maxHeaderLineLength?: number; // default 2000
15
+ maxStaticRedirects?: number; // default 2000
16
+ maxDynamicRedirects?: number; // default 100
17
+ }
18
+
19
+ const STATIC_ASSETS_LIMITS_DEFAULTS: Required<StaticAssetsLimits> = {
20
+ maxHeaderRules: 100,
21
+ maxHeaderLineLength: 2000,
22
+ maxStaticRedirects: 2000,
23
+ maxDynamicRedirects: 100,
24
+ };
25
+
26
+ interface HeaderRule {
27
+ pattern: string;
28
+ headers: Record<string, string>;
29
+ }
30
+
31
+ interface RedirectRule {
32
+ from: string;
33
+ to: string;
34
+ status: number;
35
+ isDynamic: boolean;
36
+ }
37
+
38
+ const VALID_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308, 200]);
39
+
40
+ export class StaticAssets {
41
+ private directory: string;
42
+ private htmlHandling: string;
43
+ private notFoundHandling: string;
44
+ private limits: Required<StaticAssetsLimits>;
45
+ private headerRules: HeaderRule[] | null = null;
46
+ private redirectRules: RedirectRule[] | null = null;
47
+
48
+ constructor(
49
+ directory: string,
50
+ htmlHandling = "auto-trailing-slash",
51
+ notFoundHandling = "none",
52
+ limits?: StaticAssetsLimits,
53
+ ) {
54
+ this.directory = directory;
55
+ this.htmlHandling = htmlHandling;
56
+ this.notFoundHandling = notFoundHandling;
57
+ this.limits = { ...STATIC_ASSETS_LIMITS_DEFAULTS, ...limits };
58
+ }
59
+
60
+ async fetch(request: Request): Promise<Response> {
61
+ const url = new URL(request.url);
62
+ let pathname = decodeURIComponent(url.pathname);
63
+
64
+ // Check _redirects rules first (highest precedence)
65
+ const redirectResult = this.applyRedirects(pathname, url);
66
+ if (redirectResult instanceof Response) {
67
+ return redirectResult;
68
+ }
69
+ // If redirectResult is a string, it's a rewritten path (200 status)
70
+ if (typeof redirectResult === "string") {
71
+ pathname = redirectResult;
72
+ }
73
+
74
+ // Prevent path traversal — check both raw and resolved
75
+ if (pathname.includes("..")) {
76
+ return new Response("Bad Request", { status: 400 });
77
+ }
78
+ const resolvedPath = path.resolve(this.directory, "." + pathname);
79
+ if (!resolvedPath.startsWith(this.directory)) {
80
+ return new Response("Bad Request", { status: 400 });
81
+ }
82
+
83
+ // Handle trailing slash redirects (307 matches CF behavior)
84
+ if (this.htmlHandling === "force-trailing-slash" && !pathname.endsWith("/") && pathname !== "/") {
85
+ const ext = path.extname(pathname);
86
+ if (!ext) {
87
+ return Response.redirect(new URL(pathname + "/" + url.search, url.origin).toString(), 307);
88
+ }
89
+ }
90
+ if (this.htmlHandling === "drop-trailing-slash" && pathname.endsWith("/") && pathname !== "/") {
91
+ return Response.redirect(new URL(pathname.slice(0, -1) + url.search, url.origin).toString(), 307);
92
+ }
93
+
94
+ // Try to resolve the file
95
+ const resolved = await this.resolveFile(pathname);
96
+ if (resolved) {
97
+ return this.serveFile(resolved, 200, request, pathname);
98
+ }
99
+
100
+ // Not found handling
101
+ if (this.notFoundHandling === "single-page-application") {
102
+ const indexPath = path.join(this.directory, "index.html");
103
+ const indexFile = Bun.file(indexPath);
104
+ if (await indexFile.exists()) {
105
+ return this.serveFile(indexPath, 200, request, pathname);
106
+ }
107
+ }
108
+
109
+ if (this.notFoundHandling === "404-page") {
110
+ // Hierarchical 404.html: search up from the requested path
111
+ const notFoundPath = await this.findNearest404(pathname);
112
+ if (notFoundPath) {
113
+ return this.serveFile(notFoundPath, 404, request, pathname);
114
+ }
115
+ }
116
+
117
+ return new Response("Not Found", { status: 404 });
118
+ }
119
+
120
+ private getRedirectRules(): RedirectRule[] {
121
+ if (this.redirectRules !== null) {
122
+ return this.redirectRules;
123
+ }
124
+
125
+ const redirectsPath = path.join(this.directory, "_redirects");
126
+ if (!existsSync(redirectsPath)) {
127
+ this.redirectRules = [];
128
+ return this.redirectRules;
129
+ }
130
+
131
+ const content = readFileSync(redirectsPath, "utf-8");
132
+ this.redirectRules = parseRedirects(content, this.limits);
133
+ return this.redirectRules;
134
+ }
135
+
136
+ /**
137
+ * Returns Response for 3xx redirects, string for 200 rewrites, or null for no match.
138
+ */
139
+ private applyRedirects(pathname: string, url: URL): Response | string | null {
140
+ const rules = this.getRedirectRules();
141
+ for (const rule of rules) {
142
+ const match = matchRedirectPattern(rule.from, pathname);
143
+ if (!match) continue;
144
+
145
+ // Substitute :splat and :placeholder in the target
146
+ let target = rule.to;
147
+ for (const [key, value] of Object.entries(match)) {
148
+ target = target.replaceAll(`:${key}`, value);
149
+ }
150
+
151
+ // For status 200, rewrite internally (transparent proxy)
152
+ if (rule.status === 200) {
153
+ if (target.startsWith("http://") || target.startsWith("https://")) {
154
+ // External URL rewrites not supported locally — redirect instead
155
+ return Response.redirect(target, 302);
156
+ }
157
+ return target;
158
+ }
159
+
160
+ // Resolve relative targets against the request origin
161
+ let location: string;
162
+ if (target.startsWith("http://") || target.startsWith("https://")) {
163
+ location = target;
164
+ } else {
165
+ location = new URL(target + url.search, url.origin).toString();
166
+ }
167
+
168
+ return new Response(null, {
169
+ status: rule.status,
170
+ headers: { Location: location },
171
+ });
172
+ }
173
+ return null;
174
+ }
175
+
176
+ private async findNearest404(pathname: string): Promise<string | null> {
177
+ // Strip file portion to get directory path
178
+ let dir = pathname;
179
+ if (!dir.endsWith("/")) {
180
+ dir = path.posix.dirname(dir);
181
+ }
182
+
183
+ // Walk up directory tree looking for 404.html
184
+ while (true) {
185
+ const candidate = path.join(this.directory, dir, "404.html");
186
+ if (await Bun.file(candidate).exists()) {
187
+ return candidate;
188
+ }
189
+ if (dir === "/" || dir === "" || dir === ".") {
190
+ break;
191
+ }
192
+ dir = path.posix.dirname(dir);
193
+ }
194
+ return null;
195
+ }
196
+
197
+ private async resolveFile(pathname: string): Promise<string | null> {
198
+ // Normalize: strip trailing slash for resolution (except root)
199
+ if (pathname !== "/" && pathname.endsWith("/")) {
200
+ pathname = pathname.slice(0, -1);
201
+ }
202
+
203
+ // Direct file match
204
+ const directPath = path.join(this.directory, pathname);
205
+ const directFile = Bun.file(directPath);
206
+ if (await directFile.exists() && !(await this.isDirectory(directPath))) {
207
+ return directPath;
208
+ }
209
+
210
+ // For root path or paths ending in /, try index.html
211
+ if (pathname === "/" || pathname === "") {
212
+ const indexPath = path.join(this.directory, "index.html");
213
+ if (await Bun.file(indexPath).exists()) {
214
+ return indexPath;
215
+ }
216
+ return null;
217
+ }
218
+
219
+ if (this.htmlHandling === "none") {
220
+ return null;
221
+ }
222
+
223
+ // auto-trailing-slash: try /about/index.html, then /about.html
224
+ // force-trailing-slash: same resolution (redirect already happened above)
225
+ // drop-trailing-slash: same resolution (redirect already happened above)
226
+ const indexPath = path.join(this.directory, pathname, "index.html");
227
+ if (await Bun.file(indexPath).exists()) {
228
+ return indexPath;
229
+ }
230
+
231
+ const htmlPath = directPath + ".html";
232
+ if (await Bun.file(htmlPath).exists()) {
233
+ return htmlPath;
234
+ }
235
+
236
+ return null;
237
+ }
238
+
239
+ private async isDirectory(filePath: string): Promise<boolean> {
240
+ try {
241
+ return statSync(filePath).isDirectory();
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+
247
+ private computeETag(filePath: string): string {
248
+ try {
249
+ const stat = statSync(filePath);
250
+ // Use mtime + size for ETag (fast, no need to hash content)
251
+ return `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`;
252
+ } catch {
253
+ return `"unknown"`;
254
+ }
255
+ }
256
+
257
+ private getHeaderRules(): HeaderRule[] {
258
+ if (this.headerRules !== null) {
259
+ return this.headerRules;
260
+ }
261
+
262
+ const headersPath = path.join(this.directory, "_headers");
263
+ if (!existsSync(headersPath)) {
264
+ this.headerRules = [];
265
+ return this.headerRules;
266
+ }
267
+
268
+ const content = readFileSync(headersPath, "utf-8");
269
+ this.headerRules = parseHeadersFile(content, this.limits);
270
+ return this.headerRules;
271
+ }
272
+
273
+ private applyHeaderRules(pathname: string, headers: Headers): void {
274
+ const rules = this.getHeaderRules();
275
+ for (const rule of rules) {
276
+ if (matchPattern(rule.pattern, pathname)) {
277
+ for (const [key, value] of Object.entries(rule.headers)) {
278
+ headers.set(key, value);
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ private serveFile(filePath: string, status: number, request?: Request, pathname?: string): Response {
285
+ const file = Bun.file(filePath);
286
+ const etag = this.computeETag(filePath);
287
+
288
+ // Check If-None-Match for conditional requests
289
+ if (request) {
290
+ const ifNoneMatch = request.headers.get("If-None-Match");
291
+ if (ifNoneMatch && ifNoneMatch === etag) {
292
+ return new Response(null, {
293
+ status: 304,
294
+ headers: {
295
+ "ETag": etag,
296
+ "Cache-Control": "public, max-age=0, must-revalidate",
297
+ },
298
+ });
299
+ }
300
+ }
301
+
302
+ const headers = new Headers({
303
+ "Content-Type": file.type,
304
+ "ETag": etag,
305
+ "Cache-Control": "public, max-age=0, must-revalidate",
306
+ });
307
+
308
+ // Apply _headers rules
309
+ if (pathname) {
310
+ this.applyHeaderRules(pathname, headers);
311
+ }
312
+
313
+ return new Response(file, { status, headers });
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Parse a _headers file into rules.
319
+ * Format:
320
+ * /pattern
321
+ * Header-Name: value
322
+ * Another-Header: value
323
+ */
324
+ export function parseHeadersFile(content: string, limits: Required<StaticAssetsLimits>): HeaderRule[] {
325
+ const rules: HeaderRule[] = [];
326
+ let currentRule: HeaderRule | null = null;
327
+
328
+ const lines = content.split("\n");
329
+ for (const rawLine of lines) {
330
+ const line = rawLine.trimEnd();
331
+ if (line.length > limits.maxHeaderLineLength) {
332
+ continue; // skip lines exceeding limit
333
+ }
334
+ if (line === "" || line.startsWith("#")) {
335
+ continue; // skip empty lines and comments
336
+ }
337
+
338
+ // Header line (indented with space or tab)
339
+ if (line.startsWith(" ") || line.startsWith("\t")) {
340
+ if (!currentRule) continue;
341
+ const trimmed = line.trim();
342
+ const colonIdx = trimmed.indexOf(":");
343
+ if (colonIdx === -1) continue;
344
+ const key = trimmed.slice(0, colonIdx).trim();
345
+ const value = trimmed.slice(colonIdx + 1).trim();
346
+ if (key) {
347
+ currentRule.headers[key] = value;
348
+ }
349
+ } else {
350
+ // URL pattern line
351
+ if (rules.length >= limits.maxHeaderRules) {
352
+ break; // reached rule limit
353
+ }
354
+ currentRule = { pattern: line.trim(), headers: {} };
355
+ rules.push(currentRule);
356
+ }
357
+ }
358
+
359
+ return rules;
360
+ }
361
+
362
+ /**
363
+ * Match a URL path against a _headers pattern.
364
+ * Supports:
365
+ * - Exact match: /about
366
+ * - Splats: /images/* matches /images/anything/here
367
+ * - Placeholders: /user/:name matches /user/bob
368
+ */
369
+ export function matchPattern(pattern: string, pathname: string): boolean {
370
+ // Convert pattern to regex
371
+ let regex = "^";
372
+ for (let i = 0; i < pattern.length; i++) {
373
+ const ch = pattern[i]!;
374
+ if (ch === "*") {
375
+ regex += ".*";
376
+ } else if (ch === ":") {
377
+ // Placeholder — match until next /
378
+ const rest = pattern.slice(i + 1);
379
+ const nameEnd = rest.search(/[^a-zA-Z0-9_]/);
380
+ if (nameEnd === -1) {
381
+ i += rest.length;
382
+ } else {
383
+ i += nameEnd;
384
+ }
385
+ regex += "[^/]+";
386
+ } else if (".+?^${}()|[]\\".includes(ch)) {
387
+ regex += "\\" + ch;
388
+ } else {
389
+ regex += ch;
390
+ }
391
+ }
392
+ regex += "$";
393
+ return new RegExp(regex).test(pathname);
394
+ }
395
+
396
+ /**
397
+ * Parse a _redirects file into redirect rules.
398
+ * Format: <from> <to> [status]
399
+ * Lines starting with # are comments. Empty lines ignored.
400
+ */
401
+ export function parseRedirects(content: string, limits: Required<StaticAssetsLimits>): RedirectRule[] {
402
+ const rules: RedirectRule[] = [];
403
+ let staticCount = 0;
404
+ let dynamicCount = 0;
405
+
406
+ const lines = content.split("\n");
407
+ for (const rawLine of lines) {
408
+ const line = rawLine.trim();
409
+ if (line === "" || line.startsWith("#")) {
410
+ continue;
411
+ }
412
+
413
+ const parts = line.split(/\s+/);
414
+ if (parts.length < 2) continue;
415
+
416
+ const from = parts[0]!;
417
+ const to = parts[1]!;
418
+ const statusStr = parts[2];
419
+ const status = statusStr ? parseInt(statusStr, 10) : 302;
420
+
421
+ if (!VALID_REDIRECT_STATUSES.has(status)) {
422
+ continue; // skip invalid status codes
423
+ }
424
+
425
+ const isDynamic = from.includes("*") || from.includes(":");
426
+ if (isDynamic) {
427
+ if (dynamicCount >= limits.maxDynamicRedirects) continue;
428
+ dynamicCount++;
429
+ } else {
430
+ if (staticCount >= limits.maxStaticRedirects) continue;
431
+ staticCount++;
432
+ }
433
+
434
+ rules.push({ from, to, status, isDynamic });
435
+ }
436
+
437
+ return rules;
438
+ }
439
+
440
+ /**
441
+ * Match a pathname against a redirect pattern and extract captured values.
442
+ * Returns an object with captured groups (e.g. { splat: "...", id: "..." }) or null.
443
+ */
444
+ export function matchRedirectPattern(pattern: string, pathname: string): Record<string, string> | null {
445
+ // Build regex with named capture groups
446
+ let regex = "^";
447
+ const names: string[] = [];
448
+
449
+ for (let i = 0; i < pattern.length; i++) {
450
+ const ch = pattern[i]!;
451
+ if (ch === "*") {
452
+ regex += "(.*)";
453
+ names.push("splat");
454
+ } else if (ch === ":") {
455
+ const rest = pattern.slice(i + 1);
456
+ const nameMatch = rest.match(/^[a-zA-Z0-9_]+/);
457
+ if (nameMatch) {
458
+ const name = nameMatch[0]!;
459
+ i += name.length;
460
+ regex += "([^/]+)";
461
+ names.push(name);
462
+ } else {
463
+ regex += ":";
464
+ }
465
+ } else if (".+?^${}()|[]\\".includes(ch)) {
466
+ regex += "\\" + ch;
467
+ } else {
468
+ regex += ch;
469
+ }
470
+ }
471
+ regex += "$";
472
+
473
+ const match = new RegExp(regex).exec(pathname);
474
+ if (!match) return null;
475
+
476
+ const result: Record<string, string> = {};
477
+ for (let i = 0; i < names.length; i++) {
478
+ result[names[i]!] = match[i + 1] || "";
479
+ }
480
+ return result;
481
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Cloudflare-compatible WebSocketPair for regular Workers.
3
+ *
4
+ * Creates two linked in-memory WebSocket-like objects.
5
+ * Messages sent on one side appear on the other.
6
+ * Events are buffered until accept() is called.
7
+ */
8
+
9
+ type WSEventType = "message" | "close" | "error" | "open";
10
+
11
+ interface WSEvent {
12
+ type: WSEventType;
13
+ data?: string | ArrayBuffer;
14
+ code?: number;
15
+ reason?: string;
16
+ wasClean?: boolean;
17
+ }
18
+
19
+ const CONNECTING = 0;
20
+ const OPEN = 1;
21
+ const CLOSING = 2;
22
+ const CLOSED = 3;
23
+
24
+ /**
25
+ * A single side of a WebSocketPair. Implements the CF WebSocket interface
26
+ * with accept() gating — events are queued until accept() is called.
27
+ */
28
+ export class CFWebSocket extends EventTarget {
29
+ static readonly CONNECTING = CONNECTING;
30
+ static readonly OPEN = OPEN;
31
+ static readonly CLOSING = CLOSING;
32
+ static readonly CLOSED = CLOSED;
33
+
34
+ readonly CONNECTING = CONNECTING;
35
+ readonly OPEN = OPEN;
36
+ readonly CLOSING = CLOSING;
37
+ readonly CLOSED = CLOSED;
38
+
39
+ readyState: number = CONNECTING;
40
+
41
+ /** @internal */ _peer: CFWebSocket | null = null;
42
+ /** @internal */ _accepted = false;
43
+ /** @internal */ _eventQueue: WSEvent[] = [];
44
+
45
+ // Callback-style handlers (standard WebSocket compat)
46
+ onopen: ((ev: Event) => void) | null = null;
47
+ onmessage: ((ev: MessageEvent) => void) | null = null;
48
+ onclose: ((ev: CloseEvent) => void) | null = null;
49
+ onerror: ((ev: Event) => void) | null = null;
50
+
51
+ constructor() {
52
+ super();
53
+ }
54
+
55
+ /**
56
+ * CF-specific: begin dispatching events.
57
+ * Must be called before messages can be sent or received.
58
+ */
59
+ accept(): void {
60
+ if (this._accepted) return;
61
+ this._accepted = true;
62
+ this.readyState = OPEN;
63
+
64
+ // Flush queued events
65
+ const queue = this._eventQueue;
66
+ this._eventQueue = [];
67
+ for (const evt of queue) {
68
+ this._dispatchWSEvent(evt);
69
+ }
70
+ }
71
+
72
+ send(message: string | ArrayBuffer | ArrayBufferView): void {
73
+ if (this.readyState !== OPEN) {
74
+ throw new Error("WebSocket is not open");
75
+ }
76
+
77
+ const peer = this._peer;
78
+ if (!peer || peer.readyState === CLOSED) return;
79
+
80
+ // Normalize ArrayBufferView to ArrayBuffer
81
+ let data: string | ArrayBuffer;
82
+ if (ArrayBuffer.isView(message)) {
83
+ data = (message.buffer as ArrayBuffer).slice(message.byteOffset, message.byteOffset + message.byteLength);
84
+ } else {
85
+ data = message;
86
+ }
87
+
88
+ const evt: WSEvent = { type: "message", data };
89
+ if (peer._accepted) {
90
+ peer._dispatchWSEvent(evt);
91
+ } else {
92
+ peer._eventQueue.push(evt);
93
+ }
94
+ }
95
+
96
+ close(code?: number, reason?: string): void {
97
+ if (this.readyState === CLOSED || this.readyState === CLOSING) return;
98
+
99
+ this.readyState = CLOSING;
100
+
101
+ const peer = this._peer;
102
+ const closeEvt: WSEvent = {
103
+ type: "close",
104
+ code: code ?? 1000,
105
+ reason: reason ?? "",
106
+ wasClean: true,
107
+ };
108
+
109
+ // Notify peer about closure
110
+ if (peer && peer.readyState !== CLOSED && peer.readyState !== CLOSING) {
111
+ if (peer._accepted) {
112
+ peer._dispatchWSEvent(closeEvt);
113
+ } else {
114
+ peer._eventQueue.push(closeEvt);
115
+ }
116
+ peer.readyState = CLOSED;
117
+ }
118
+
119
+ // Notify self
120
+ this.readyState = CLOSED;
121
+ if (this._accepted) {
122
+ this._dispatchWSEvent(closeEvt);
123
+ } else {
124
+ this._eventQueue.push(closeEvt);
125
+ }
126
+ }
127
+
128
+ /** @internal */
129
+ _dispatchWSEvent(evt: WSEvent): void {
130
+ switch (evt.type) {
131
+ case "message": {
132
+ const me = new MessageEvent("message", { data: evt.data });
133
+ this.dispatchEvent(me);
134
+ this.onmessage?.(me);
135
+ break;
136
+ }
137
+ case "close": {
138
+ const ce = new CloseEvent("close", {
139
+ code: evt.code,
140
+ reason: evt.reason,
141
+ wasClean: evt.wasClean,
142
+ });
143
+ this.dispatchEvent(ce);
144
+ this.onclose?.(ce);
145
+ break;
146
+ }
147
+ case "error": {
148
+ const ee = new Event("error");
149
+ this.dispatchEvent(ee);
150
+ this.onerror?.(ee);
151
+ break;
152
+ }
153
+ case "open": {
154
+ const oe = new Event("open");
155
+ this.dispatchEvent(oe);
156
+ this.onopen?.(oe);
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Cloudflare WebSocketPair — creates two linked CFWebSocket instances.
165
+ *
166
+ * Usage:
167
+ * const pair = new WebSocketPair();
168
+ * const [client, server] = Object.values(pair);
169
+ */
170
+ export class WebSocketPair {
171
+ readonly 0: CFWebSocket;
172
+ readonly 1: CFWebSocket;
173
+
174
+ constructor() {
175
+ const a = new CFWebSocket();
176
+ const b = new CFWebSocket();
177
+ a._peer = b;
178
+ b._peer = a;
179
+ this[0] = a;
180
+ this[1] = b;
181
+ }
182
+ }