@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 +1 -1
- package/dist/core.mjs +3 -3
- package/dist/provider/code.mjs +88 -0
- package/dist/provider/magiclink.mjs +58 -0
- package/dist/provider/oauth2.mjs +57 -0
- package/dist/provider/passkey.mjs +1 -1
- package/dist/provider/password.mjs +9 -0
- package/dist/provider/provider.d.mts +2 -2
- package/dist/router/context.d.mts +21 -0
- package/dist/router/context.mjs +193 -0
- package/dist/router/cookies.d.mts +8 -0
- package/dist/router/cookies.mjs +13 -0
- package/dist/router/index.d.mts +21 -0
- package/dist/router/index.mjs +107 -0
- package/dist/router/matcher.d.mts +15 -0
- package/dist/router/matcher.mjs +76 -0
- package/dist/router/middleware/cors.d.mts +15 -0
- package/dist/router/middleware/cors.mjs +114 -0
- package/dist/router/safe-request.d.mts +52 -0
- package/dist/router/safe-request.mjs +160 -0
- package/dist/router/types.d.mts +67 -0
- package/dist/router/types.mjs +1 -0
- package/dist/router/variables.d.mts +12 -0
- package/dist/router/variables.mjs +20 -0
- package/dist/ui/code.mjs +15 -8
- package/dist/ui/magiclink.mjs +15 -8
- package/dist/ui/passkey.d.mts +5 -3
- package/dist/ui/passkey.mjs +34 -8
- package/dist/ui/password.d.mts +0 -4
- package/dist/ui/password.mjs +259 -243
- package/dist/util.d.mts +25 -2
- package/dist/util.mjs +24 -1
- package/package.json +5 -6
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
|
/**
|
package/dist/provider/code.mjs
CHANGED
|
@@ -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
|
});
|
package/dist/provider/oauth2.mjs
CHANGED
|
@@ -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 ?
|
|
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 };
|