@draftlab/auth 0.13.1 → 0.15.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/dist/core.d.mts CHANGED
@@ -2,11 +2,11 @@ import { AllowCheckInput } from "./allow.mjs";
2
2
  import { UnknownStateError } from "./error.mjs";
3
3
  import { Prettify } from "./util.mjs";
4
4
  import { SubjectPayload, SubjectSchema } from "./subject.mjs";
5
+ import { Router } from "./router/index.mjs";
5
6
  import { StorageAdapter } from "./storage/storage.mjs";
6
7
  import { Provider } from "./provider/provider.mjs";
7
8
  import { Theme } from "./themes/theme.mjs";
8
9
  import { AuthorizationState } from "./types.mjs";
9
- import { Router } from "@draftlab/auth-router";
10
10
 
11
11
  //#region src/core.d.ts
12
12
 
package/dist/core.mjs CHANGED
@@ -6,12 +6,12 @@ import { generateSecureToken } from "./random.mjs";
6
6
  import { Storage } from "./storage/storage.mjs";
7
7
  import { encryptionKeys, signingKeys } from "./keys.mjs";
8
8
  import { Revocation } from "./revocation.mjs";
9
+ import { Router } from "./router/index.mjs";
10
+ import { deleteCookie, getCookie, setCookie } from "./router/cookies.mjs";
11
+ import { cors } from "./router/middleware/cors.mjs";
9
12
  import { setTheme } from "./themes/theme.mjs";
10
13
  import { Select } from "./ui/select.mjs";
11
14
  import { CompactEncrypt, SignJWT, compactDecrypt } from "jose";
12
- import { Router } from "@draftlab/auth-router";
13
- import { deleteCookie, getCookie, setCookie } from "@draftlab/auth-router/cookies";
14
- import { cors } from "@draftlab/auth-router/middleware/cors";
15
15
 
16
16
  //#region src/core.ts
17
17
  /**
@@ -2,6 +2,89 @@ import { generateUnbiasedDigits, timingSafeCompare } from "../random.mjs";
2
2
 
3
3
  //#region src/provider/code.ts
4
4
  /**
5
+ * PIN code authentication provider for Draft Auth.
6
+ * Supports flexible claim-based authentication via email, phone, or custom identifiers.
7
+ *
8
+ * ## Quick Setup
9
+ *
10
+ * ```ts
11
+ * import { CodeUI } from "@draftlab/auth/ui/code"
12
+ * import { CodeProvider } from "@draftlab/auth/provider/code"
13
+ *
14
+ * export default issuer({
15
+ * providers: {
16
+ * code: CodeProvider(
17
+ * CodeUI({
18
+ * copy: {
19
+ * code_info: "We'll send a PIN code to your email"
20
+ * },
21
+ * sendCode: async (claims, code) => {
22
+ * try {
23
+ * await sendEmail(claims.email, `Your code: ${code}`)
24
+ * } catch {
25
+ * return { type: "invalid_claim", key: "delivery", value: "Failed to send code" }
26
+ * }
27
+ * }
28
+ * })
29
+ * )
30
+ * }
31
+ * })
32
+ * ```
33
+ *
34
+ * ## Custom Configuration
35
+ *
36
+ * ```ts
37
+ * const customCodeProvider = CodeProvider({
38
+ * length: 4, // 4-digit PIN instead of default 6
39
+ * request: async (req, state, form, error) => {
40
+ * return new Response(renderCodePage(state, form, error))
41
+ * },
42
+ * sendCode: async (claims, code) => {
43
+ * try {
44
+ * if (claims.email) {
45
+ * await emailService.send(claims.email, code)
46
+ * } else if (claims.phone) {
47
+ * await smsService.send(claims.phone, code)
48
+ * } else {
49
+ * return { type: "invalid_claim", key: "email", value: "Email or phone number is required" }
50
+ * }
51
+ * } catch {
52
+ * return { type: "invalid_claim", key: "delivery", value: "Failed to send code" }
53
+ * }
54
+ * }
55
+ * })
56
+ * ```
57
+ *
58
+ * ## Features
59
+ *
60
+ * - **Flexible claims**: Support any claim type (email, phone, username, etc.)
61
+ * - **Configurable PIN length**: 4-6 digit codes typically
62
+ * - **Resend functionality**: Built-in code resend capability
63
+ * - **Custom UI**: Full control over the authentication interface
64
+ * - **Error handling**: Comprehensive error states for different failure modes
65
+ *
66
+ * ## Flow States
67
+ *
68
+ * The provider manages a two-step authentication flow:
69
+ *
70
+ * 1. **Start**: User enters their claim (email, phone, etc.)
71
+ * 2. **Code**: User enters the PIN code sent to their claim
72
+ *
73
+ * ## User Data
74
+ *
75
+ * ```ts
76
+ * success: async (ctx, value) => {
77
+ * if (value.provider === "code") {
78
+ * // User's email: value.claims.email
79
+ * // User's phone (if provided): value.claims.phone
80
+ * // Any other claims collected during the flow
81
+ * }
82
+ * }
83
+ * ```
84
+ *
85
+ * @packageDocumentation
86
+ */
87
+ /**
5
88
  * Creates a PIN code authentication provider.
6
89
  * Implements a flexible claim-based authentication flow with PIN verification.
7
90
  *
@@ -145,6 +228,11 @@ const CodeProvider = (config) => {
145
228
  ...currentState,
146
229
  resend: false
147
230
  }, formData, { type: "invalid_code" });
231
+ if (!await ctx.get(c, "authorization")) return transition(c, { type: "start" }, formData, {
232
+ type: "invalid_claim",
233
+ key: "session",
234
+ value: "Authentication session expired"
235
+ });
148
236
  await ctx.unset(c, "provider");
149
237
  return await ctx.success(c, { claims: currentState.claims });
150
238
  }
@@ -2,6 +2,63 @@ import { generateUnbiasedDigits, timingSafeCompare } from "../random.mjs";
2
2
 
3
3
  //#region src/provider/magiclink.ts
4
4
  /**
5
+ * Magic Link authentication provider for Draft Auth.
6
+ * Sends clickable links that authenticate users in one click.
7
+ *
8
+ * ## Quick Setup
9
+ *
10
+ * ```ts
11
+ * import { MagicLinkUI } from "@draftlab/auth/ui/magiclink"
12
+ * import { MagicLinkProvider } from "@draftlab/auth/provider/magiclink"
13
+ *
14
+ * export default issuer({
15
+ * providers: {
16
+ * magiclink: MagicLinkProvider(
17
+ * MagicLinkUI({
18
+ * sendLink: async (claims, magicUrl) => {
19
+ * await emailService.send({
20
+ * to: claims.email,
21
+ * subject: "Sign in to your account",
22
+ * html: `<a href="${magicUrl}">Sign In</a>`
23
+ * })
24
+ * }
25
+ * })
26
+ * )
27
+ * }
28
+ * })
29
+ * ```
30
+ *
31
+ * ## Custom Configuration
32
+ *
33
+ * ```ts
34
+ * const customMagicLink = MagicLinkProvider({
35
+ * expiry: 600, // 10 minutes instead of default 15
36
+ *
37
+ * request: async (req, state, form, error) => {
38
+ * return new Response(renderMagicLinkForm(state, form, error))
39
+ * },
40
+ *
41
+ * sendLink: async (claims, magicUrl) => {
42
+ * try {
43
+ * if (claims.email) {
44
+ * await emailService.send(claims.email, {
45
+ * subject: "Your secure sign-in link",
46
+ * template: "magic-link",
47
+ * data: { magicUrl, userEmail: claims.email }
48
+ * })
49
+ * } else {
50
+ * return { type: "invalid_claim", key: "email", value: "Email is required" }
51
+ * }
52
+ * } catch {
53
+ * return { type: "invalid_claim", key: "delivery", value: "Failed to send magic link" }
54
+ * }
55
+ * }
56
+ * })
57
+ * ```
58
+ *
59
+ * @packageDocumentation
60
+ */
61
+ /**
5
62
  * Creates a Magic Link authentication provider.
6
63
  * Implements a flexible claim-based authentication flow with magic link verification.
7
64
  *
@@ -74,6 +131,7 @@ const MagicLinkProvider = (config) => {
74
131
  if (!urlValue || !storedValue) return false;
75
132
  return timingSafeCompare(storedValue, urlValue);
76
133
  })) return transition(c, { type: "start" }, void 0, { type: "invalid_link" });
134
+ if (!await ctx.get(c, "authorization")) return transition(c, { type: "start" }, void 0, { type: "invalid_link" });
77
135
  await ctx.unset(c, "provider");
78
136
  return await ctx.success(c, { claims: storedState.claims });
79
137
  });
@@ -6,6 +6,63 @@ import { createRemoteJWKSet, jwtVerify } from "jose";
6
6
 
7
7
  //#region src/provider/oauth2.ts
8
8
  /**
9
+ * OAuth 2.0 authentication provider for Draft Auth.
10
+ * Implements the Authorization Code Grant flow with optional PKCE support.
11
+ *
12
+ * ## Quick Setup
13
+ *
14
+ * ```ts
15
+ * import { Oauth2Provider } from "@draftlab/auth/provider/oauth2"
16
+ *
17
+ * export default issuer({
18
+ * providers: {
19
+ * github: Oauth2Provider({
20
+ * clientID: process.env.GITHUB_CLIENT_ID,
21
+ * clientSecret: process.env.GITHUB_CLIENT_SECRET,
22
+ * endpoint: {
23
+ * authorization: "https://github.com/login/oauth/authorize",
24
+ * token: "https://github.com/login/oauth/access_token"
25
+ * },
26
+ * scopes: ["user:email", "read:user"]
27
+ * }),
28
+ * discord: Oauth2Provider({
29
+ * clientID: process.env.DISCORD_CLIENT_ID,
30
+ * clientSecret: process.env.DISCORD_CLIENT_SECRET,
31
+ * endpoint: {
32
+ * authorization: "https://discord.com/api/oauth2/authorize",
33
+ * token: "https://discord.com/api/oauth2/token"
34
+ * },
35
+ * scopes: ["identify", "email"],
36
+ * pkce: true // Required by some providers
37
+ * })
38
+ * }
39
+ * })
40
+ * ```
41
+ *
42
+ * ## Features
43
+ *
44
+ * - **Authorization Code Grant**: Secure server-side OAuth 2.0 flow
45
+ * - **PKCE Support**: Optional Proof Key for Code Exchange for enhanced security
46
+ * - **Flexible Endpoints**: Configure custom authorization and token endpoints
47
+ * - **Custom Parameters**: Support for provider-specific authorization parameters
48
+ *
49
+ * ## User Data
50
+ *
51
+ * The provider returns access tokens:
52
+ *
53
+ * ```ts
54
+ * success: async (ctx, value) => {
55
+ * if (value.provider === "oauth2") {
56
+ * // Access token for API calls: value.tokenset.access
57
+ * // Refresh token (if provided): value.tokenset.refresh
58
+ * // Client ID used: value.clientID
59
+ * }
60
+ * }
61
+ * ```
62
+ *
63
+ * @packageDocumentation
64
+ */
65
+ /**
9
66
  * Creates an OAuth 2.0 authentication provider.
10
67
  * Implements the Authorization Code Grant flow with optional PKCE support.
11
68
  *
@@ -171,7 +171,7 @@ const PasskeyProvider = (config) => {
171
171
  authenticatorSelection: authenticatorSelection ?? {
172
172
  residentKey: "preferred",
173
173
  userVerification: "preferred",
174
- authenticatorAttachment: otherDevice ? "cross-platform" : "platform"
174
+ authenticatorAttachment: otherDevice ? void 0 : "platform"
175
175
  },
176
176
  timeout
177
177
  });
@@ -193,6 +193,14 @@ const PasswordProvider = (config) => {
193
193
  type: "start",
194
194
  redirect: provider.redirect
195
195
  }, { type: "invalid_email" });
196
+ if (!await Storage.get(ctx.storage, [
197
+ "email",
198
+ email,
199
+ "password"
200
+ ])) return transition({
201
+ type: "start",
202
+ redirect: provider.redirect
203
+ }, { type: "invalid_email" });
196
204
  const code = generateCode();
197
205
  const context = provider.type === "code" && provider.email === email ? "reset:resend" : "reset";
198
206
  await config.sendCode(email, code, context);
@@ -221,6 +229,7 @@ const PasswordProvider = (config) => {
221
229
  const password = formData.get("password")?.toString();
222
230
  const repeat = formData.get("repeat")?.toString();
223
231
  if (!password) return transition(provider, { type: "invalid_password" });
232
+ if (!repeat) return transition(provider, { type: "invalid_password" });
224
233
  if (password !== repeat) return transition(provider, { type: "password_mismatch" });
225
234
  if (config.validatePassword) {
226
235
  let validationError;
@@ -1,6 +1,6 @@
1
+ import { RouterContext } from "../router/types.mjs";
2
+ import { Router } from "../router/index.mjs";
1
3
  import { StorageAdapter } from "../storage/storage.mjs";
2
- import { Router } from "@draftlab/auth-router";
3
- import { RouterContext } from "@draftlab/auth-router/types";
4
4
 
5
5
  //#region src/provider/provider.d.ts
6
6
 
@@ -0,0 +1,21 @@
1
+ import { RouterContext, VariableMap } from "./types.mjs";
2
+
3
+ //#region src/router/context.d.ts
4
+ declare class ContextBuilder<TVariables extends VariableMap = VariableMap> {
5
+ private readonly request;
6
+ private readonly matchedParams;
7
+ private readonly searchParams;
8
+ private readonly cookies;
9
+ private readonly variableManager;
10
+ private readonly responseHeaders;
11
+ private status;
12
+ private finalized;
13
+ private cachedFormData;
14
+ private formDataPromise;
15
+ private bodyText;
16
+ constructor(request: Request, matchedParams: Record<string, string>, initialVariables?: Partial<TVariables>);
17
+ build<TParams extends Record<string, string>>(): RouterContext<TParams, TVariables>;
18
+ newResponse(body?: BodyInit, init?: ResponseInit): Response;
19
+ }
20
+ //#endregion
21
+ export { ContextBuilder };
@@ -0,0 +1,193 @@
1
+ import { ContextVariableManager } from "./variables.mjs";
2
+
3
+ //#region src/router/context.ts
4
+ const parseCookies = (request) => {
5
+ const cookies = /* @__PURE__ */ new Map();
6
+ const cookieHeader = request.headers.get("cookie");
7
+ if (!cookieHeader) return cookies;
8
+ try {
9
+ for (const cookie of cookieHeader.split(";")) {
10
+ const trimmedCookie = cookie.trim();
11
+ if (!trimmedCookie) continue;
12
+ const [name, ...valueParts] = trimmedCookie.split("=");
13
+ if (!name || name.trim() === "") continue;
14
+ const trimmedName = name.trim();
15
+ if (!/^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/.test(trimmedName)) {
16
+ console.warn(`Invalid cookie name: ${trimmedName}`);
17
+ continue;
18
+ }
19
+ const value = valueParts.join("=");
20
+ try {
21
+ cookies.set(trimmedName, decodeURIComponent(value));
22
+ } catch {
23
+ cookies.set(trimmedName, value);
24
+ }
25
+ }
26
+ } catch (error) {
27
+ console.error("Failed to parse cookies:", error);
28
+ }
29
+ return cookies;
30
+ };
31
+ const serializeCookie = (name, value, options = {}) => {
32
+ if (!/^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/.test(name)) throw new Error(`Invalid cookie name: ${name}`);
33
+ const parts = [`${name}=${encodeURIComponent(value)}`];
34
+ if (options.domain) parts.push(`Domain=${options.domain}`);
35
+ if (options.path) parts.push(`Path=${options.path}`);
36
+ if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
37
+ if (options.maxAge) parts.push(`Max-Age=${options.maxAge}`);
38
+ if (options.httpOnly) parts.push("HttpOnly");
39
+ if (options.secure) parts.push("Secure");
40
+ if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);
41
+ return parts.join("; ");
42
+ };
43
+ const validateRedirectStatus = (status) => {
44
+ if (![
45
+ 300,
46
+ 301,
47
+ 302,
48
+ 303,
49
+ 307,
50
+ 308
51
+ ].includes(status)) throw new Error(`Invalid redirect status code: ${status}.`);
52
+ return true;
53
+ };
54
+ var ContextBuilder = class {
55
+ request;
56
+ matchedParams;
57
+ searchParams;
58
+ cookies;
59
+ variableManager;
60
+ responseHeaders = new Headers();
61
+ status = 200;
62
+ finalized = false;
63
+ cachedFormData = null;
64
+ formDataPromise = null;
65
+ bodyText = null;
66
+ constructor(request, matchedParams, initialVariables) {
67
+ if (!request || typeof request !== "object" || !request.url || !request.method) throw new Error("Invalid request object provided to ContextBuilder.");
68
+ this.request = request;
69
+ this.matchedParams = matchedParams;
70
+ this.searchParams = new URL(request.url).searchParams;
71
+ this.cookies = parseCookies(request);
72
+ this.variableManager = new ContextVariableManager(initialVariables);
73
+ Object.freeze(this.matchedParams);
74
+ }
75
+ build() {
76
+ return {
77
+ request: this.request,
78
+ params: this.matchedParams,
79
+ searchParams: this.searchParams,
80
+ query: (key) => this.searchParams.get(key) ?? void 0,
81
+ header: (key) => this.request.headers.get(key) ?? void 0,
82
+ cookie: (key) => this.cookies.get(key),
83
+ formData: async () => {
84
+ if (this.cachedFormData) return this.cachedFormData;
85
+ if (this.formDataPromise) try {
86
+ return await this.formDataPromise;
87
+ } catch {
88
+ this.formDataPromise = null;
89
+ }
90
+ this.formDataPromise = (async () => {
91
+ try {
92
+ const formData = await this.request.formData();
93
+ this.cachedFormData = formData;
94
+ return formData;
95
+ } catch (error) {
96
+ this.formDataPromise = null;
97
+ throw new Error(`Failed to read form data: ${error instanceof Error ? error.message : "Unknown error"}`);
98
+ }
99
+ })();
100
+ return this.formDataPromise;
101
+ },
102
+ parseJson: async () => {
103
+ try {
104
+ if (this.bodyText !== null) {
105
+ if (!this.bodyText) throw new Error("Request body is empty.");
106
+ return JSON.parse(this.bodyText);
107
+ }
108
+ const text = await this.request.text();
109
+ this.bodyText = text;
110
+ if (!text) throw new Error("Request body is empty.");
111
+ return JSON.parse(text);
112
+ } catch (error) {
113
+ throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
114
+ }
115
+ },
116
+ redirect: (url, status = 302) => {
117
+ validateRedirectStatus(status);
118
+ const location = url instanceof URL ? url.toString() : url;
119
+ return this.newResponse(void 0, {
120
+ status,
121
+ headers: { location }
122
+ });
123
+ },
124
+ json: (data, init) => {
125
+ const finalInit = {
126
+ ...init,
127
+ headers: {
128
+ "content-type": "application/json; charset=utf-8",
129
+ ...init?.headers
130
+ }
131
+ };
132
+ return this.newResponse(JSON.stringify(data), finalInit);
133
+ },
134
+ text: (data, init) => {
135
+ const finalInit = {
136
+ ...init,
137
+ headers: {
138
+ "content-type": "text/plain; charset=utf-8",
139
+ ...init?.headers
140
+ }
141
+ };
142
+ return this.newResponse(data, finalInit);
143
+ },
144
+ setCookie: (name, value, options) => {
145
+ try {
146
+ const cookie = serializeCookie(name, value, options);
147
+ this.responseHeaders.append("Set-Cookie", cookie);
148
+ } catch (error) {
149
+ console.error(`Failed to set cookie "${name}":`, error);
150
+ }
151
+ },
152
+ deleteCookie: (name, options) => {
153
+ try {
154
+ const cookie = serializeCookie(name, "", {
155
+ ...options,
156
+ expires: /* @__PURE__ */ new Date(0)
157
+ });
158
+ this.responseHeaders.append("Set-Cookie", cookie);
159
+ } catch (error) {
160
+ console.error(`Failed to delete cookie "${name}":`, error);
161
+ }
162
+ },
163
+ newResponse: (body, init) => this.newResponse(body, init),
164
+ set: (key, value) => {
165
+ this.variableManager.set(key, value);
166
+ },
167
+ get: (key) => {
168
+ return this.variableManager.get(key);
169
+ },
170
+ has: (key) => {
171
+ return this.variableManager.has(key);
172
+ }
173
+ };
174
+ }
175
+ newResponse(body, init) {
176
+ if (this.finalized) throw new Error("Response already finalized");
177
+ const finalHeaders = new Headers(this.responseHeaders);
178
+ if (init?.headers) new Headers(init.headers).forEach((value, key) => {
179
+ if (key.toLowerCase() === "set-cookie") finalHeaders.append(key, value);
180
+ else finalHeaders.set(key, value);
181
+ });
182
+ const response = new Response(body, {
183
+ status: init?.status || this.status,
184
+ statusText: init?.statusText,
185
+ headers: finalHeaders
186
+ });
187
+ this.finalized = true;
188
+ return response;
189
+ }
190
+ };
191
+
192
+ //#endregion
193
+ export { ContextBuilder };
@@ -0,0 +1,8 @@
1
+ import { CookieOptions, RouterContext, VariableMap } from "./types.mjs";
2
+
3
+ //#region src/router/cookies.d.ts
4
+ declare const getCookie: <TVariables extends VariableMap = VariableMap>(ctx: RouterContext<Record<string, string>, TVariables>, name: string) => string | undefined;
5
+ declare const setCookie: <TVariables extends VariableMap = VariableMap>(ctx: RouterContext<Record<string, string>, TVariables>, name: string, value: string, options?: CookieOptions) => void;
6
+ declare const deleteCookie: <TVariables extends VariableMap = VariableMap>(ctx: RouterContext<Record<string, string>, TVariables>, name: string, options?: Pick<CookieOptions, "domain" | "path">) => void;
7
+ //#endregion
8
+ export { deleteCookie, getCookie, setCookie };
@@ -0,0 +1,13 @@
1
+ //#region src/router/cookies.ts
2
+ const getCookie = (ctx, name) => {
3
+ return ctx.cookie(name);
4
+ };
5
+ const setCookie = (ctx, name, value, options) => {
6
+ ctx.setCookie(name, value, options);
7
+ };
8
+ const deleteCookie = (ctx, name, options) => {
9
+ ctx.deleteCookie(name, options);
10
+ };
11
+
12
+ //#endregion
13
+ export { deleteCookie, getCookie, setCookie };
@@ -0,0 +1,21 @@
1
+ import { AnyHandler, ErrorHandler, ExtractParams, GlobalMiddleware, RouterEnvironment, RouterOptions } from "./types.mjs";
2
+
3
+ //#region src/router/index.d.ts
4
+ declare class Router<TEnvironment extends RouterEnvironment = RouterEnvironment> {
5
+ private readonly routes;
6
+ private readonly matcher;
7
+ private readonly globalMiddleware;
8
+ private errorHandler?;
9
+ constructor(routerOptions?: RouterOptions);
10
+ private addRoute;
11
+ get<TPath extends string>(path: TPath, handler: AnyHandler<ExtractParams<TPath>, TEnvironment["Variables"]>): this;
12
+ post<TPath extends string>(path: TPath, handler: AnyHandler<ExtractParams<TPath>, TEnvironment["Variables"]>): this;
13
+ use(middleware: GlobalMiddleware<TEnvironment["Variables"]>): this;
14
+ onError(handler: ErrorHandler<TEnvironment["Variables"]>): this;
15
+ mount<TMountedEnvironment extends RouterEnvironment>(path: string, router: Router<TMountedEnvironment>): this;
16
+ handle(request: Request, initialVariables?: Partial<TEnvironment["Variables"]>): Promise<Response>;
17
+ get fetch(): (request: Request) => Promise<Response>;
18
+ private createErrorResponse;
19
+ }
20
+ //#endregion
21
+ export { Router };
@@ -0,0 +1,107 @@
1
+ import { ContextBuilder } from "./context.mjs";
2
+ import { RouteMatcher } from "./matcher.mjs";
3
+ import { makeSafeRequest } from "./safe-request.mjs";
4
+
5
+ //#region src/router/index.ts
6
+ var Router = class {
7
+ routes = /* @__PURE__ */ new Map();
8
+ matcher;
9
+ globalMiddleware = [];
10
+ errorHandler;
11
+ constructor(routerOptions = {}) {
12
+ this.matcher = new RouteMatcher(routerOptions);
13
+ }
14
+ addRoute(method, path, handler) {
15
+ const { handler: mainHandler, middleware = [] } = typeof handler === "function" ? { handler } : handler;
16
+ if (typeof mainHandler !== "function") throw new Error(`Handler for ${method} ${path} must be a function.`);
17
+ const route = {
18
+ method,
19
+ pattern: path,
20
+ handler: mainHandler,
21
+ middleware: [...this.globalMiddleware, ...middleware],
22
+ compiled: this.matcher.compile(path)
23
+ };
24
+ const methodRoutes = this.routes.get(method) ?? [];
25
+ if (methodRoutes.some((r) => r.pattern === path)) console.warn(`Route already exists: ${method} ${path}. Overwriting.`);
26
+ const newRoutes = [...methodRoutes.filter((r) => r.pattern !== path), route];
27
+ const sortedPatterns = this.matcher.sortRoutesBySpecificity(newRoutes.map((r) => r.pattern));
28
+ newRoutes.sort((a, b) => sortedPatterns.indexOf(a.pattern) - sortedPatterns.indexOf(b.pattern));
29
+ this.routes.set(method, newRoutes);
30
+ return this;
31
+ }
32
+ get(path, handler) {
33
+ return this.addRoute("GET", path, handler);
34
+ }
35
+ post(path, handler) {
36
+ return this.addRoute("POST", path, handler);
37
+ }
38
+ use(middleware) {
39
+ this.globalMiddleware.push(middleware);
40
+ return this;
41
+ }
42
+ onError(handler) {
43
+ this.errorHandler = handler;
44
+ return this;
45
+ }
46
+ mount(path, router) {
47
+ const normalizedPath = path.endsWith("/") ? path.slice(0, -1) : path;
48
+ for (const [method, routes] of router.routes) for (const route of routes) this.addRoute(method, `${normalizedPath}${route.pattern}`, {
49
+ handler: route.handler,
50
+ middleware: route.middleware
51
+ });
52
+ return this;
53
+ }
54
+ async handle(request, initialVariables) {
55
+ const safeRequest = await makeSafeRequest(request);
56
+ try {
57
+ const url = new URL(safeRequest.url);
58
+ const method = safeRequest.method.toUpperCase();
59
+ const pathname = this.matcher.normalizePath(url.pathname);
60
+ const methodRoutes = this.routes.get(method);
61
+ if (!methodRoutes) return this.createErrorResponse("Not Found", 404);
62
+ for (const route of methodRoutes) {
63
+ const match = this.matcher.match(route.pattern, pathname);
64
+ if (match) {
65
+ const context = new ContextBuilder(safeRequest, match.params, initialVariables).build();
66
+ const allMiddleware = [...this.globalMiddleware, ...route.middleware];
67
+ const run = async (index, ctx) => {
68
+ if (index < allMiddleware.length) {
69
+ const middleware = allMiddleware[index];
70
+ if (middleware) return middleware(ctx, () => run(index + 1, ctx));
71
+ }
72
+ return route.handler(ctx);
73
+ };
74
+ return await run(0, context);
75
+ }
76
+ }
77
+ return this.createErrorResponse(`Route not found: ${method} ${pathname}`, 404);
78
+ } catch (error) {
79
+ console.error("Router handle error:", error);
80
+ if (this.errorHandler) try {
81
+ const errorContext = new ContextBuilder(safeRequest, {}, initialVariables).build();
82
+ return await this.errorHandler(error, errorContext);
83
+ } catch (handlerError) {
84
+ console.error("Error handler failed:", handlerError);
85
+ }
86
+ const message = error instanceof Error ? error.message : "Internal Server Error";
87
+ return this.createErrorResponse(message, 500);
88
+ }
89
+ }
90
+ get fetch() {
91
+ return (request) => {
92
+ return this.handle(request);
93
+ };
94
+ }
95
+ createErrorResponse(message, status) {
96
+ return new Response(JSON.stringify({
97
+ error: message,
98
+ status
99
+ }), {
100
+ status,
101
+ headers: { "Content-Type": "application/json" }
102
+ });
103
+ }
104
+ };
105
+
106
+ //#endregion
107
+ export { Router };