@just-be/wildcard 0.4.0 → 0.6.0
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 +5 -5
- package/package.json +1 -1
- package/src/handlers/redirect.test.ts +98 -0
- package/src/handlers/redirect.ts +10 -1
- package/src/handlers/static.ts +19 -9
- package/src/schemas.ts +1 -0
package/README.md
CHANGED
|
@@ -4,14 +4,14 @@ Portable wildcard subdomain routing library with pluggable storage backends. Rou
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
7
|
+
- **Pluggable storage backends** - Use any file storage (R2, S3, filesystem, etc.)
|
|
8
|
+
- **Security built-in** - SSRF protection, path traversal prevention, safe header filtering
|
|
9
|
+
- **Three routing modes**:
|
|
10
10
|
- **Static** - Serve files from any storage backend with SPA mode and custom 404s
|
|
11
11
|
- **Redirect** - 301/302 redirects to external URLs
|
|
12
12
|
- **Rewrite** - Proxy requests to external services
|
|
13
|
-
-
|
|
14
|
-
-
|
|
13
|
+
- **Framework-agnostic** - Works with Cloudflare Workers, Node.js, Deno, Bun, etc.
|
|
14
|
+
- **Type-safe** - Full TypeScript support with Zod validation
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { handleRedirect } from "./redirect";
|
|
3
|
+
import type { RedirectConfig } from "../schemas";
|
|
4
|
+
|
|
5
|
+
function makeRequest(url: string): Request {
|
|
6
|
+
return new Request(url);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("handleRedirect", () => {
|
|
10
|
+
it("should redirect with 302 by default", async () => {
|
|
11
|
+
const config: RedirectConfig = { type: "redirect", url: "https://example.com" };
|
|
12
|
+
const response = await handleRedirect(makeRequest("https://sub.just-be.dev/"), config);
|
|
13
|
+
expect(response.status).toBe(302);
|
|
14
|
+
expect(response.headers.get("location")).toBe("https://example.com");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should redirect with 301 when permanent is true", async () => {
|
|
18
|
+
const config: RedirectConfig = {
|
|
19
|
+
type: "redirect",
|
|
20
|
+
url: "https://example.com",
|
|
21
|
+
permanent: true,
|
|
22
|
+
};
|
|
23
|
+
const response = await handleRedirect(makeRequest("https://sub.just-be.dev/"), config);
|
|
24
|
+
expect(response.status).toBe(301);
|
|
25
|
+
expect(response.headers.get("location")).toBe("https://example.com");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should redirect with 302 when permanent is false", async () => {
|
|
29
|
+
const config: RedirectConfig = {
|
|
30
|
+
type: "redirect",
|
|
31
|
+
url: "https://example.com",
|
|
32
|
+
permanent: false,
|
|
33
|
+
};
|
|
34
|
+
const response = await handleRedirect(makeRequest("https://sub.just-be.dev/"), config);
|
|
35
|
+
expect(response.status).toBe(302);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("preservePath", () => {
|
|
39
|
+
it("should append path to target URL", async () => {
|
|
40
|
+
const config: RedirectConfig = {
|
|
41
|
+
type: "redirect",
|
|
42
|
+
url: "https://github.com/just-be-dev",
|
|
43
|
+
permanent: true,
|
|
44
|
+
preservePath: true,
|
|
45
|
+
};
|
|
46
|
+
const response = await handleRedirect(makeRequest("https://git.just-be.dev/pds"), config);
|
|
47
|
+
expect(response.status).toBe(301);
|
|
48
|
+
expect(response.headers.get("location")).toBe("https://github.com/just-be-dev/pds");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should preserve query string", async () => {
|
|
52
|
+
const config: RedirectConfig = {
|
|
53
|
+
type: "redirect",
|
|
54
|
+
url: "https://github.com/just-be-dev",
|
|
55
|
+
preservePath: true,
|
|
56
|
+
};
|
|
57
|
+
const response = await handleRedirect(
|
|
58
|
+
makeRequest("https://git.just-be.dev/pds?tab=readme"),
|
|
59
|
+
config
|
|
60
|
+
);
|
|
61
|
+
expect(response.headers.get("location")).toBe(
|
|
62
|
+
"https://github.com/just-be-dev/pds?tab=readme"
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should handle root path request", async () => {
|
|
67
|
+
const config: RedirectConfig = {
|
|
68
|
+
type: "redirect",
|
|
69
|
+
url: "https://github.com/just-be-dev",
|
|
70
|
+
preservePath: true,
|
|
71
|
+
};
|
|
72
|
+
const response = await handleRedirect(makeRequest("https://git.just-be.dev/"), config);
|
|
73
|
+
expect(response.headers.get("location")).toBe("https://github.com/just-be-dev/");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should handle target URL with trailing slash", async () => {
|
|
77
|
+
const config: RedirectConfig = {
|
|
78
|
+
type: "redirect",
|
|
79
|
+
url: "https://github.com/just-be-dev/",
|
|
80
|
+
preservePath: true,
|
|
81
|
+
};
|
|
82
|
+
const response = await handleRedirect(makeRequest("https://git.just-be.dev/pds"), config);
|
|
83
|
+
expect(response.headers.get("location")).toBe("https://github.com/just-be-dev/pds");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should not preserve path when preservePath is not set", async () => {
|
|
87
|
+
const config: RedirectConfig = {
|
|
88
|
+
type: "redirect",
|
|
89
|
+
url: "https://example.com",
|
|
90
|
+
};
|
|
91
|
+
const response = await handleRedirect(
|
|
92
|
+
makeRequest("https://sub.just-be.dev/some/path"),
|
|
93
|
+
config
|
|
94
|
+
);
|
|
95
|
+
expect(response.headers.get("location")).toBe("https://example.com");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
package/src/handlers/redirect.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import type { Handler } from "../types";
|
|
2
2
|
import type { RedirectConfig } from "../schemas";
|
|
3
3
|
|
|
4
|
-
export const handleRedirect: Handler<RedirectConfig> = async (
|
|
4
|
+
export const handleRedirect: Handler<RedirectConfig> = async (request, config) => {
|
|
5
5
|
const status = config.permanent ? 301 : 302;
|
|
6
|
+
|
|
7
|
+
if (config.preservePath) {
|
|
8
|
+
const originalUrl = new URL(request.url);
|
|
9
|
+
const targetUrl = new URL(config.url);
|
|
10
|
+
targetUrl.pathname = targetUrl.pathname.replace(/\/$/, "") + originalUrl.pathname;
|
|
11
|
+
targetUrl.search = originalUrl.search;
|
|
12
|
+
return Response.redirect(targetUrl.toString(), status);
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
return Response.redirect(config.url, status);
|
|
7
16
|
};
|
package/src/handlers/static.ts
CHANGED
|
@@ -22,14 +22,15 @@ export const handleStatic: Handler<StaticConfig, { fileLoader: FileLoader }> = a
|
|
|
22
22
|
const basePath = subdomain;
|
|
23
23
|
|
|
24
24
|
return spa
|
|
25
|
-
? handleSpaMode(fileLoader, basePath, url.pathname)
|
|
26
|
-
: handleStaticMode(fileLoader, basePath, url.pathname, fallback);
|
|
25
|
+
? handleSpaMode(fileLoader, basePath, url.pathname, request)
|
|
26
|
+
: handleStaticMode(fileLoader, basePath, url.pathname, request, fallback);
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
async function handleSpaMode(
|
|
30
30
|
fileLoader: FileLoader,
|
|
31
31
|
basePath: string,
|
|
32
|
-
pathname: string
|
|
32
|
+
pathname: string,
|
|
33
|
+
request: Request
|
|
33
34
|
): Promise<Response> {
|
|
34
35
|
// Sanitize paths to prevent directory traversal
|
|
35
36
|
const sanitizedBase = sanitizePath(basePath);
|
|
@@ -53,7 +54,7 @@ async function handleSpaMode(
|
|
|
53
54
|
const fileObject = await fileLoader.loadFile(key);
|
|
54
55
|
|
|
55
56
|
if (fileObject) {
|
|
56
|
-
return buildFileResponse(fileObject, key);
|
|
57
|
+
return buildFileResponse(fileObject, key, request);
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -70,13 +71,14 @@ async function handleSpaMode(
|
|
|
70
71
|
return new Response("File not found", { status: 404 });
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
return buildFileResponse(indexFile, indexKey);
|
|
74
|
+
return buildFileResponse(indexFile, indexKey, request);
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
async function handleStaticMode(
|
|
77
78
|
fileLoader: FileLoader,
|
|
78
79
|
basePath: string,
|
|
79
80
|
pathname: string,
|
|
81
|
+
request: Request,
|
|
80
82
|
fallback?: string
|
|
81
83
|
): Promise<Response> {
|
|
82
84
|
// Sanitize paths to prevent directory traversal
|
|
@@ -99,7 +101,7 @@ async function handleStaticMode(
|
|
|
99
101
|
const fileObject = await fileLoader.loadFile(key);
|
|
100
102
|
|
|
101
103
|
if (fileObject) {
|
|
102
|
-
return buildFileResponse(fileObject, key);
|
|
104
|
+
return buildFileResponse(fileObject, key, request);
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
// Try fallback if configured
|
|
@@ -113,14 +115,14 @@ async function handleStaticMode(
|
|
|
113
115
|
const fallbackObject = await fileLoader.loadFile(fallbackKey);
|
|
114
116
|
|
|
115
117
|
if (fallbackObject) {
|
|
116
|
-
return buildFileResponse(fallbackObject, fallbackKey);
|
|
118
|
+
return buildFileResponse(fallbackObject, fallbackKey, request);
|
|
117
119
|
}
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
return new Response("File not found", { status: 404 });
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
function buildFileResponse(fileObject: FileObject, key: string): Response {
|
|
125
|
+
function buildFileResponse(fileObject: FileObject, key: string, request: Request): Response {
|
|
124
126
|
const headers = new Headers(fileObject.headers);
|
|
125
127
|
|
|
126
128
|
if (fileObject.etag) {
|
|
@@ -148,8 +150,16 @@ function buildFileResponse(fileObject: FileObject, key: string): Response {
|
|
|
148
150
|
}
|
|
149
151
|
|
|
150
152
|
// Cache control with Vary header for cache poisoning prevention
|
|
151
|
-
headers.set("Cache-Control", "public, max-age=3600");
|
|
153
|
+
headers.set("Cache-Control", "public, max-age=3600"); // 1 hour browser cache
|
|
154
|
+
headers.set("CDN-Cache-Control", "public, max-age=604800"); // 7 days edge cache
|
|
152
155
|
headers.set("Vary", "Accept-Encoding");
|
|
153
156
|
|
|
157
|
+
// Handle conditional requests (If-None-Match)
|
|
158
|
+
const ifNoneMatch = request.headers.get("If-None-Match");
|
|
159
|
+
if (ifNoneMatch && fileObject.etag && ifNoneMatch === fileObject.etag) {
|
|
160
|
+
// Return 304 Not Modified with headers but no body
|
|
161
|
+
return new Response(null, { status: 304, headers });
|
|
162
|
+
}
|
|
163
|
+
|
|
154
164
|
return new Response(fileObject.body, { headers });
|
|
155
165
|
}
|
package/src/schemas.ts
CHANGED