@just-be/wildcard 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@just-be/wildcard",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Portable wildcard subdomain routing with pluggable storage backends",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,3 +1,4 @@
1
1
  export { handleStatic } from "./static";
2
2
  export { handleRedirect } from "./redirect";
3
3
  export { handleRewrite } from "./rewrite";
4
+ export { handlePathRules } from "./path-rules";
@@ -0,0 +1,66 @@
1
+ import type { StaticConfig } from "../schemas";
2
+ import { matchPath, proxyRequest } from "../utils";
3
+
4
+ /**
5
+ * Handles path-level redirects and rewrites for static handlers
6
+ * Returns a Response if a rule matches, or null to continue to static file serving
7
+ */
8
+ export async function handlePathRules(
9
+ request: Request,
10
+ config: StaticConfig
11
+ ): Promise<Response | null> {
12
+ const url = new URL(request.url);
13
+ const pathname = url.pathname;
14
+
15
+ // Check redirects first (in array order)
16
+ if (config.redirects?.length) {
17
+ for (const rule of config.redirects) {
18
+ const match = matchPath(rule.path, pathname);
19
+ if (!match.matched) continue;
20
+
21
+ // Build target URL, appending suffix for wildcard patterns
22
+ let targetUrl = rule.url;
23
+ if (rule.path.endsWith("/*") && match.suffix && match.suffix !== "/") {
24
+ targetUrl = rule.url.replace(/\/$/, "") + match.suffix;
25
+ }
26
+
27
+ return Response.redirect(targetUrl, rule.permanent ? 301 : 302);
28
+ }
29
+ }
30
+
31
+ // Check rewrites (in array order)
32
+ if (config.rewrites?.length) {
33
+ for (const rule of config.rewrites) {
34
+ const match = matchPath(rule.path, pathname);
35
+ if (!match.matched) continue;
36
+
37
+ // Check HTTP method
38
+ if (!(rule.allowedMethods as string[]).includes(request.method)) {
39
+ return new Response("Method not allowed", {
40
+ status: 405,
41
+ headers: {
42
+ Allow: rule.allowedMethods.join(", "),
43
+ },
44
+ });
45
+ }
46
+
47
+ // Build target URL for rewrite
48
+ const targetUrl = new URL(rule.url);
49
+
50
+ // For wildcard patterns, use the suffix as the path
51
+ // For exact patterns, use the original pathname
52
+ if (rule.path.endsWith("/*") && match.suffix) {
53
+ targetUrl.pathname = match.suffix;
54
+ } else {
55
+ targetUrl.pathname = url.pathname;
56
+ }
57
+
58
+ targetUrl.search = url.search;
59
+
60
+ return proxyRequest(request, targetUrl);
61
+ }
62
+ }
63
+
64
+ // No path rule matched, continue to static file serving
65
+ return null;
66
+ }
@@ -1,8 +1,6 @@
1
1
  import type { Handler } from "../types";
2
2
  import type { RewriteConfig } from "../schemas";
3
- import { filterSafeHeaders } from "../utils";
4
-
5
- const FETCH_TIMEOUT_MS = 5_000;
3
+ import { proxyRequest } from "../utils";
6
4
 
7
5
  export const handleRewrite: Handler<RewriteConfig> = async (request, config) => {
8
6
  const originalUrl = new URL(request.url);
@@ -22,43 +20,5 @@ export const handleRewrite: Handler<RewriteConfig> = async (request, config) =>
22
20
  url.pathname = originalUrl.pathname;
23
21
  url.search = originalUrl.search;
24
22
 
25
- // Filter headers to only include safe ones (prevents header injection)
26
- const safeHeaders = filterSafeHeaders(request.headers);
27
-
28
- // Create a new request with the target URL and filtered headers
29
- const modifiedRequest = new Request(url.toString(), {
30
- method: request.method,
31
- headers: safeHeaders,
32
- body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined,
33
- });
34
-
35
- // Create abort controller for timeout
36
- const controller = new AbortController();
37
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
38
-
39
- try {
40
- const response = await fetch(modifiedRequest, {
41
- signal: controller.signal,
42
- });
43
-
44
- clearTimeout(timeoutId);
45
-
46
- // Create a new response with the same body but potentially modified headers
47
- const newResponse = new Response(response.body, response);
48
-
49
- // Remove content-encoding header because the response body is already decoded
50
- // by the fetch API, and if we forward this header, the browser will try to
51
- // decode it again, causing corruption
52
- newResponse.headers.delete("content-encoding");
53
-
54
- return newResponse;
55
- } catch (error) {
56
- clearTimeout(timeoutId);
57
-
58
- if (error instanceof Error && error.name === "AbortError") {
59
- return new Response("Request timeout", { status: 504 });
60
- }
61
-
62
- return new Response("Bad gateway", { status: 502 });
63
- }
23
+ return proxyRequest(request, url);
64
24
  };
@@ -1,12 +1,17 @@
1
1
  import type { Handler, FileLoader, FileObject } from "../types";
2
2
  import type { StaticConfig } from "../schemas";
3
3
  import { getContentType, sanitizePath } from "../utils";
4
+ import { handlePathRules } from "./path-rules";
4
5
 
5
6
  export const handleStatic: Handler<StaticConfig, { fileLoader: FileLoader }> = async (
6
7
  request,
7
8
  config,
8
9
  { fileLoader }
9
10
  ) => {
11
+ // Check path-level redirects and rewrites first
12
+ const pathResponse = await handlePathRules(request, config);
13
+ if (pathResponse) return pathResponse;
14
+
10
15
  const url = new URL(request.url);
11
16
  const { spa, fallback } = config;
12
17
 
package/src/schemas.ts CHANGED
@@ -13,12 +13,30 @@ export const subdomain = () =>
13
13
  message: "Invalid subdomain format. Must be alphanumeric with hyphens, 1-63 characters.",
14
14
  });
15
15
 
16
+ const HttpMethod = z.enum(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]);
17
+
18
+ /** Path-level redirect schema */
19
+ export const PathRedirectSchema = z.object({
20
+ path: z.string().min(1).startsWith("/"),
21
+ url: safeUrl(),
22
+ permanent: z.boolean().optional(),
23
+ });
24
+
25
+ /** Path-level rewrite schema */
26
+ export const PathRewriteSchema = z.object({
27
+ path: z.string().min(1).startsWith("/"),
28
+ url: safeUrl(),
29
+ allowedMethods: z.array(HttpMethod).optional().default(["GET", "HEAD", "OPTIONS"]),
30
+ });
31
+
16
32
  // Zod schemas for validation
17
33
  export const StaticConfigSchema = z
18
34
  .object({
19
35
  type: z.literal("static"),
20
36
  spa: z.boolean().optional(),
21
37
  fallback: z.string().optional(), // Fallback file path for non-SPA mode (e.g., "404.html")
38
+ redirects: z.array(PathRedirectSchema).optional(),
39
+ rewrites: z.array(PathRewriteSchema).optional(),
22
40
  })
23
41
  .refine((data) => (data.spa && data.fallback ? false : true), {
24
42
  message: "fallback cannot be used with spa mode (spa: true)",
@@ -30,8 +48,6 @@ export const RedirectConfigSchema = z.object({
30
48
  permanent: z.boolean().optional(),
31
49
  });
32
50
 
33
- const HttpMethod = z.enum(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]);
34
-
35
51
  export const RewriteConfigSchema = z.object({
36
52
  type: z.literal("rewrite"),
37
53
  url: safeUrl(),
@@ -70,6 +86,8 @@ const json = <T extends z.ZodType>(schema: T) =>
70
86
  */
71
87
  export const RouteConfigCodec = json(RouteConfigSchema);
72
88
 
89
+ export type PathRedirect = z.infer<typeof PathRedirectSchema>;
90
+ export type PathRewrite = z.infer<typeof PathRewriteSchema>;
73
91
  export type StaticConfig = z.infer<typeof StaticConfigSchema>;
74
92
  export type RedirectConfig = z.infer<typeof RedirectConfigSchema>;
75
93
  export type RewriteConfig = z.infer<typeof RewriteConfigSchema>;
package/src/utils.test.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  sanitizePath,
6
6
  filterSafeHeaders,
7
7
  isValidSubdomain,
8
+ matchPath,
8
9
  SAFE_REQUEST_HEADERS,
9
10
  } from "./utils";
10
11
 
@@ -341,3 +342,82 @@ describe("isValidSubdomain", () => {
341
342
  });
342
343
  });
343
344
  });
345
+
346
+ describe("matchPath", () => {
347
+ describe("exact match patterns", () => {
348
+ it("should match exact paths", () => {
349
+ expect(matchPath("/github", "/github")).toEqual({ matched: true });
350
+ expect(matchPath("/about", "/about")).toEqual({ matched: true });
351
+ expect(matchPath("/foo/bar", "/foo/bar")).toEqual({ matched: true });
352
+ });
353
+
354
+ it("should not match different paths", () => {
355
+ expect(matchPath("/github", "/gitlab")).toEqual({ matched: false });
356
+ expect(matchPath("/about", "/about/team")).toEqual({ matched: false });
357
+ expect(matchPath("/foo", "/foo/bar")).toEqual({ matched: false });
358
+ });
359
+
360
+ it("should handle trailing slash normalization", () => {
361
+ expect(matchPath("/github", "/github/")).toEqual({ matched: true });
362
+ expect(matchPath("/github/", "/github")).toEqual({ matched: true });
363
+ expect(matchPath("/foo/bar/", "/foo/bar")).toEqual({ matched: true });
364
+ });
365
+
366
+ it("should handle root path", () => {
367
+ expect(matchPath("/", "/")).toEqual({ matched: true });
368
+ });
369
+ });
370
+
371
+ describe("wildcard patterns", () => {
372
+ it("should match prefix with wildcard", () => {
373
+ expect(matchPath("/api/*", "/api/users")).toEqual({ matched: true, suffix: "/users" });
374
+ expect(matchPath("/api/*", "/api/users/123")).toEqual({
375
+ matched: true,
376
+ suffix: "/users/123",
377
+ });
378
+ expect(matchPath("/v1/api/*", "/v1/api/test")).toEqual({ matched: true, suffix: "/test" });
379
+ });
380
+
381
+ it("should match just the prefix without trailing content", () => {
382
+ expect(matchPath("/api/*", "/api")).toEqual({ matched: true, suffix: "/" });
383
+ expect(matchPath("/api/*", "/api/")).toEqual({ matched: true, suffix: "/" });
384
+ });
385
+
386
+ it("should not match paths that do not start with prefix", () => {
387
+ expect(matchPath("/api/*", "/apiv2/users")).toEqual({ matched: false });
388
+ expect(matchPath("/api/*", "/other/api/users")).toEqual({ matched: false });
389
+ expect(matchPath("/foo/*", "/foobar")).toEqual({ matched: false });
390
+ });
391
+
392
+ it("should return correct suffix for nested paths", () => {
393
+ expect(matchPath("/old/*", "/old/page")).toEqual({ matched: true, suffix: "/page" });
394
+ expect(matchPath("/old/*", "/old/deep/nested/path")).toEqual({
395
+ matched: true,
396
+ suffix: "/deep/nested/path",
397
+ });
398
+ });
399
+
400
+ it("should handle wildcard at root", () => {
401
+ expect(matchPath("/*", "/anything")).toEqual({ matched: true, suffix: "/anything" });
402
+ expect(matchPath("/*", "/")).toEqual({ matched: true, suffix: "/" });
403
+ });
404
+ });
405
+
406
+ describe("edge cases", () => {
407
+ it("should handle paths with query strings (pathname only)", () => {
408
+ // matchPath only receives pathname, not query strings
409
+ expect(matchPath("/api/*", "/api/users")).toEqual({ matched: true, suffix: "/users" });
410
+ });
411
+
412
+ it("should handle patterns with multiple segments", () => {
413
+ expect(matchPath("/a/b/c/*", "/a/b/c/d")).toEqual({ matched: true, suffix: "/d" });
414
+ expect(matchPath("/a/b/c/*", "/a/b/c")).toEqual({ matched: true, suffix: "/" });
415
+ });
416
+
417
+ it("should distinguish between similar paths", () => {
418
+ expect(matchPath("/api", "/api")).toEqual({ matched: true });
419
+ expect(matchPath("/api", "/api/")).toEqual({ matched: true });
420
+ expect(matchPath("/api", "/api/users")).toEqual({ matched: false });
421
+ });
422
+ });
423
+ });
package/src/utils.ts CHANGED
@@ -163,6 +163,45 @@ export function filterSafeHeaders(headers: Headers): Headers {
163
163
  return filtered;
164
164
  }
165
165
 
166
+ /**
167
+ * Match result from matchPath function
168
+ */
169
+ export interface PathMatchResult {
170
+ matched: boolean;
171
+ suffix?: string;
172
+ }
173
+
174
+ /**
175
+ * Matches a path pattern against a pathname
176
+ * Supports exact matches and wildcard suffix patterns (e.g., /api/*)
177
+ *
178
+ * @param pattern - The pattern to match (e.g., "/github" or "/api/*")
179
+ * @param pathname - The actual pathname to match against
180
+ * @returns Object with matched boolean and optional suffix for wildcard matches
181
+ */
182
+ export function matchPath(pattern: string, pathname: string): PathMatchResult {
183
+ // Normalize paths by removing trailing slashes (except for root "/")
184
+ const normPattern =
185
+ pattern === "/" ? "/" : pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
186
+ const normPathname =
187
+ pathname === "/" ? "/" : pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
188
+
189
+ // Handle wildcard patterns: /api/*
190
+ if (normPattern.endsWith("/*")) {
191
+ const prefix = normPattern.slice(0, -2);
192
+
193
+ // Match prefix exactly or prefix + "/"
194
+ if (normPathname === prefix || normPathname.startsWith(prefix + "/")) {
195
+ const suffix = normPathname.slice(prefix.length) || "/";
196
+ return { matched: true, suffix };
197
+ }
198
+ return { matched: false };
199
+ }
200
+
201
+ // Exact match
202
+ return { matched: normPathname === normPattern };
203
+ }
204
+
166
205
  /**
167
206
  * Validates subdomain name format and length
168
207
  * @param subdomain - The subdomain to validate
@@ -196,3 +235,60 @@ export function isValidSubdomain(subdomain: string): boolean {
196
235
 
197
236
  return true;
198
237
  }
238
+
239
+ /**
240
+ * Timeout for proxy requests in milliseconds
241
+ */
242
+ const FETCH_TIMEOUT_MS = 5_000;
243
+
244
+ /**
245
+ * Proxies a request to a target URL with timeout and safe header filtering
246
+ *
247
+ * @param request - The original request
248
+ * @param targetUrl - The target URL to proxy to
249
+ * @returns Response from the proxied request
250
+ */
251
+ export async function proxyRequest(
252
+ request: Request,
253
+ targetUrl: URL
254
+ ): Promise<Response> {
255
+ // Filter headers to only include safe ones (prevents header injection)
256
+ const safeHeaders = filterSafeHeaders(request.headers);
257
+
258
+ // Create a new request with the target URL and filtered headers
259
+ const modifiedRequest = new Request(targetUrl.toString(), {
260
+ method: request.method,
261
+ headers: safeHeaders,
262
+ body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined,
263
+ });
264
+
265
+ // Create abort controller for timeout
266
+ const controller = new AbortController();
267
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
268
+
269
+ try {
270
+ const response = await fetch(modifiedRequest, {
271
+ signal: controller.signal,
272
+ });
273
+
274
+ clearTimeout(timeoutId);
275
+
276
+ // Create a new response with the same body but potentially modified headers
277
+ const newResponse = new Response(response.body, response);
278
+
279
+ // Remove content-encoding header because the response body is already decoded
280
+ // by the fetch API, and if we forward this header, the browser will try to
281
+ // decode it again, causing corruption
282
+ newResponse.headers.delete("content-encoding");
283
+
284
+ return newResponse;
285
+ } catch (error) {
286
+ clearTimeout(timeoutId);
287
+
288
+ if (error instanceof Error && error.name === "AbortError") {
289
+ return new Response("Request timeout", { status: 504 });
290
+ }
291
+
292
+ return new Response("Bad gateway", { status: 502 });
293
+ }
294
+ }