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.
- package/README.md +15 -0
- package/package.json +51 -0
- package/runtime/bindings/ai.ts +132 -0
- package/runtime/bindings/analytics-engine.ts +96 -0
- package/runtime/bindings/browser.ts +64 -0
- package/runtime/bindings/cache.ts +179 -0
- package/runtime/bindings/cf-streams.ts +56 -0
- package/runtime/bindings/container-docker.ts +225 -0
- package/runtime/bindings/container.ts +662 -0
- package/runtime/bindings/crypto-extras.ts +89 -0
- package/runtime/bindings/d1.ts +315 -0
- package/runtime/bindings/do-executor-inprocess.ts +140 -0
- package/runtime/bindings/do-executor-worker.ts +368 -0
- package/runtime/bindings/do-executor.ts +45 -0
- package/runtime/bindings/do-websocket-bridge.ts +70 -0
- package/runtime/bindings/do-worker-entry.ts +220 -0
- package/runtime/bindings/do-worker-env.ts +74 -0
- package/runtime/bindings/durable-object.ts +992 -0
- package/runtime/bindings/email.ts +180 -0
- package/runtime/bindings/html-rewriter.ts +84 -0
- package/runtime/bindings/hyperdrive.ts +130 -0
- package/runtime/bindings/images.ts +381 -0
- package/runtime/bindings/kv.ts +359 -0
- package/runtime/bindings/queue.ts +507 -0
- package/runtime/bindings/r2.ts +759 -0
- package/runtime/bindings/rpc-stub.ts +267 -0
- package/runtime/bindings/scheduled.ts +172 -0
- package/runtime/bindings/service-binding.ts +217 -0
- package/runtime/bindings/static-assets.ts +481 -0
- package/runtime/bindings/websocket-pair.ts +182 -0
- package/runtime/bindings/workflow.ts +858 -0
- package/runtime/bunflare-config.ts +56 -0
- package/runtime/cli/cache.ts +39 -0
- package/runtime/cli/context.ts +105 -0
- package/runtime/cli/d1.ts +163 -0
- package/runtime/cli/dev.ts +392 -0
- package/runtime/cli/kv.ts +84 -0
- package/runtime/cli/queues.ts +109 -0
- package/runtime/cli/r2.ts +140 -0
- package/runtime/cli/traces.ts +251 -0
- package/runtime/cli.ts +102 -0
- package/runtime/config.ts +148 -0
- package/runtime/d1-migrate.ts +37 -0
- package/runtime/dashboard/api.ts +174 -0
- package/runtime/dashboard/app.tsx +220 -0
- package/runtime/dashboard/components/breadcrumb.tsx +16 -0
- package/runtime/dashboard/components/buttons.tsx +13 -0
- package/runtime/dashboard/components/code-block.tsx +5 -0
- package/runtime/dashboard/components/detail-field.tsx +8 -0
- package/runtime/dashboard/components/empty-state.tsx +8 -0
- package/runtime/dashboard/components/filter-input.tsx +11 -0
- package/runtime/dashboard/components/index.ts +16 -0
- package/runtime/dashboard/components/key-value-table.tsx +23 -0
- package/runtime/dashboard/components/modal.tsx +23 -0
- package/runtime/dashboard/components/page-header.tsx +11 -0
- package/runtime/dashboard/components/pill-button.tsx +14 -0
- package/runtime/dashboard/components/refresh-button.tsx +7 -0
- package/runtime/dashboard/components/service-info.tsx +45 -0
- package/runtime/dashboard/components/status-badge.tsx +7 -0
- package/runtime/dashboard/components/table-link.tsx +5 -0
- package/runtime/dashboard/components/table.tsx +26 -0
- package/runtime/dashboard/components.tsx +19 -0
- package/runtime/dashboard/index.html +23 -0
- package/runtime/dashboard/lib.ts +45 -0
- package/runtime/dashboard/rpc/client.ts +20 -0
- package/runtime/dashboard/rpc/handlers/ai.ts +71 -0
- package/runtime/dashboard/rpc/handlers/analytics-engine.ts +53 -0
- package/runtime/dashboard/rpc/handlers/cache.ts +24 -0
- package/runtime/dashboard/rpc/handlers/config.ts +137 -0
- package/runtime/dashboard/rpc/handlers/containers.ts +194 -0
- package/runtime/dashboard/rpc/handlers/d1.ts +84 -0
- package/runtime/dashboard/rpc/handlers/do.ts +117 -0
- package/runtime/dashboard/rpc/handlers/email.ts +82 -0
- package/runtime/dashboard/rpc/handlers/errors.ts +32 -0
- package/runtime/dashboard/rpc/handlers/generations.ts +60 -0
- package/runtime/dashboard/rpc/handlers/kv.ts +76 -0
- package/runtime/dashboard/rpc/handlers/overview.ts +94 -0
- package/runtime/dashboard/rpc/handlers/queue.ts +79 -0
- package/runtime/dashboard/rpc/handlers/r2.ts +72 -0
- package/runtime/dashboard/rpc/handlers/scheduled.ts +91 -0
- package/runtime/dashboard/rpc/handlers/traces.ts +64 -0
- package/runtime/dashboard/rpc/handlers/workers.ts +65 -0
- package/runtime/dashboard/rpc/handlers/workflows.ts +171 -0
- package/runtime/dashboard/rpc/hooks.ts +132 -0
- package/runtime/dashboard/rpc/server.ts +70 -0
- package/runtime/dashboard/rpc/types.ts +396 -0
- package/runtime/dashboard/sql-browser/data-browser-tab.tsx +122 -0
- package/runtime/dashboard/sql-browser/editable-cell.tsx +117 -0
- package/runtime/dashboard/sql-browser/filter-row.tsx +99 -0
- package/runtime/dashboard/sql-browser/history-panels.tsx +110 -0
- package/runtime/dashboard/sql-browser/hooks.ts +137 -0
- package/runtime/dashboard/sql-browser/index.ts +4 -0
- package/runtime/dashboard/sql-browser/insert-row-form.tsx +85 -0
- package/runtime/dashboard/sql-browser/modals.tsx +116 -0
- package/runtime/dashboard/sql-browser/schema-browser-tab.tsx +67 -0
- package/runtime/dashboard/sql-browser/sql-browser.tsx +52 -0
- package/runtime/dashboard/sql-browser/sql-console-tab.tsx +124 -0
- package/runtime/dashboard/sql-browser/table-data-view.tsx +566 -0
- package/runtime/dashboard/sql-browser/table-sidebar.tsx +38 -0
- package/runtime/dashboard/sql-browser/types.ts +61 -0
- package/runtime/dashboard/sql-browser/utils.ts +167 -0
- package/runtime/dashboard/style.css +177 -0
- package/runtime/dashboard/views/ai.tsx +152 -0
- package/runtime/dashboard/views/analytics-engine.tsx +169 -0
- package/runtime/dashboard/views/cache.tsx +93 -0
- package/runtime/dashboard/views/containers.tsx +197 -0
- package/runtime/dashboard/views/d1.tsx +81 -0
- package/runtime/dashboard/views/do.tsx +168 -0
- package/runtime/dashboard/views/email.tsx +235 -0
- package/runtime/dashboard/views/errors.tsx +558 -0
- package/runtime/dashboard/views/home.tsx +287 -0
- package/runtime/dashboard/views/kv.tsx +273 -0
- package/runtime/dashboard/views/queue.tsx +193 -0
- package/runtime/dashboard/views/r2.tsx +202 -0
- package/runtime/dashboard/views/scheduled.tsx +89 -0
- package/runtime/dashboard/views/trace-waterfall.tsx +410 -0
- package/runtime/dashboard/views/traces.tsx +768 -0
- package/runtime/dashboard/views/workers.tsx +55 -0
- package/runtime/dashboard/views/workflows.tsx +473 -0
- package/runtime/db.ts +258 -0
- package/runtime/env.ts +362 -0
- package/runtime/error-page/app.tsx +394 -0
- package/runtime/error-page/build.ts +269 -0
- package/runtime/error-page/index.html +16 -0
- package/runtime/error-page/style.css +31 -0
- package/runtime/execution-context.ts +18 -0
- package/runtime/file-watcher.ts +57 -0
- package/runtime/generation-manager.ts +230 -0
- package/runtime/generation.ts +411 -0
- package/runtime/plugin.ts +292 -0
- package/runtime/request-cf.ts +28 -0
- package/runtime/rpc-validate.ts +154 -0
- package/runtime/tracing/context.ts +40 -0
- package/runtime/tracing/db.ts +73 -0
- package/runtime/tracing/frames.ts +75 -0
- package/runtime/tracing/instrument.ts +186 -0
- package/runtime/tracing/span.ts +138 -0
- package/runtime/tracing/store.ts +499 -0
- package/runtime/tracing/types.ts +47 -0
- package/runtime/vite-plugin/config-plugin.ts +68 -0
- package/runtime/vite-plugin/dev-server-plugin.ts +493 -0
- package/runtime/vite-plugin/dist/index.mjs +52333 -0
- package/runtime/vite-plugin/globals-plugin.ts +94 -0
- package/runtime/vite-plugin/index.ts +43 -0
- package/runtime/vite-plugin/modules-plugin.ts +88 -0
- package/runtime/vite-plugin/react-router-plugin.ts +95 -0
- 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
|
+
}
|