@draftlab/auth 0.14.0 → 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 +83 -0
- package/dist/provider/magiclink.mjs +57 -0
- package/dist/provider/oauth2.mjs +57 -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/util.d.mts +1 -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
|
*
|
|
@@ -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
|
*
|
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
|
*
|
|
@@ -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 };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { CompiledRoute, MatchResult, RouterOptions } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/router/matcher.d.ts
|
|
4
|
+
declare class RouteMatcher {
|
|
5
|
+
private readonly compiledRoutes;
|
|
6
|
+
private readonly options;
|
|
7
|
+
constructor(options?: RouterOptions);
|
|
8
|
+
compile(pattern: string): CompiledRoute;
|
|
9
|
+
match(pattern: string, pathname: string): MatchResult | null;
|
|
10
|
+
sortRoutesBySpecificity(patterns: string[]): string[];
|
|
11
|
+
private calculateSpecificity;
|
|
12
|
+
normalizePath(pathname: string): string;
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
export { RouteMatcher };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//#region src/router/matcher.ts
|
|
2
|
+
var RouteMatcher = class {
|
|
3
|
+
compiledRoutes = /* @__PURE__ */ new Map();
|
|
4
|
+
options;
|
|
5
|
+
constructor(options = {}) {
|
|
6
|
+
this.options = {
|
|
7
|
+
caseSensitive: false,
|
|
8
|
+
strict: false,
|
|
9
|
+
basePath: "",
|
|
10
|
+
...options
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
compile(pattern) {
|
|
14
|
+
const cacheKey = `${pattern}:${this.options.caseSensitive}:${this.options.strict}`;
|
|
15
|
+
const cached = this.compiledRoutes.get(cacheKey);
|
|
16
|
+
if (cached) return cached;
|
|
17
|
+
const paramNames = [];
|
|
18
|
+
let regexPattern = pattern.replace(/:([^/]+)/g, (_, name) => {
|
|
19
|
+
paramNames.push(name);
|
|
20
|
+
return "([^/]+)";
|
|
21
|
+
});
|
|
22
|
+
regexPattern = regexPattern.replace(/\*/g, "(.*)");
|
|
23
|
+
const finalPattern = this.options.strict || pattern === "/" ? regexPattern : `${regexPattern.replace(/\/$/, "")}/?`;
|
|
24
|
+
const compiled = {
|
|
25
|
+
regex: new RegExp(`^${finalPattern}$`, this.options.caseSensitive ? "" : "i"),
|
|
26
|
+
paramNames,
|
|
27
|
+
pattern
|
|
28
|
+
};
|
|
29
|
+
this.compiledRoutes.set(cacheKey, compiled);
|
|
30
|
+
return compiled;
|
|
31
|
+
}
|
|
32
|
+
match(pattern, pathname) {
|
|
33
|
+
const compiled = this.compile(pattern);
|
|
34
|
+
const match = pathname.match(compiled.regex);
|
|
35
|
+
if (!match) return null;
|
|
36
|
+
const params = {};
|
|
37
|
+
for (let i = 0; i < compiled.paramNames.length; i++) {
|
|
38
|
+
const name = compiled.paramNames[i];
|
|
39
|
+
const value = match[i + 1];
|
|
40
|
+
if (name && value !== void 0) try {
|
|
41
|
+
params[name] = decodeURIComponent(value);
|
|
42
|
+
} catch {
|
|
43
|
+
params[name] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
params: Object.freeze(params),
|
|
48
|
+
pattern
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
sortRoutesBySpecificity(patterns) {
|
|
52
|
+
return [...patterns].sort((a, b) => {
|
|
53
|
+
const aScore = this.calculateSpecificity(a);
|
|
54
|
+
return this.calculateSpecificity(b) - aScore;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
calculateSpecificity(pattern) {
|
|
58
|
+
const segments = pattern.split("/").filter(Boolean);
|
|
59
|
+
let score = 0;
|
|
60
|
+
for (const segment of segments) if (segment.startsWith(":")) score += 2;
|
|
61
|
+
else if (segment === "*") score += 1;
|
|
62
|
+
else score += 4;
|
|
63
|
+
return score;
|
|
64
|
+
}
|
|
65
|
+
normalizePath(pathname) {
|
|
66
|
+
let normalized = pathname;
|
|
67
|
+
const { basePath, strict } = this.options;
|
|
68
|
+
if (basePath && normalized.startsWith(basePath)) normalized = normalized.slice(basePath.length) || "/";
|
|
69
|
+
if (!normalized.startsWith("/")) normalized = `/${normalized}`;
|
|
70
|
+
if (!strict && normalized.length > 1 && normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
|
71
|
+
return normalized;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
//#endregion
|
|
76
|
+
export { RouteMatcher };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { MiddlewareHandler, RouterContext, VariableMap } from "../types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/router/middleware/cors.d.ts
|
|
4
|
+
interface CORSOptions {
|
|
5
|
+
origin?: "*" | string | readonly string[] | ((origin: string, ctx: RouterContext<Record<string, string>, VariableMap>) => string | null);
|
|
6
|
+
allowMethods?: readonly string[] | ((origin: string, ctx: RouterContext<Record<string, string>, VariableMap>) => readonly string[]);
|
|
7
|
+
allowHeaders?: readonly string[];
|
|
8
|
+
maxAge?: number;
|
|
9
|
+
credentials?: boolean;
|
|
10
|
+
exposeHeaders?: readonly string[];
|
|
11
|
+
preflightHandler?: <TVariables extends VariableMap>(ctx: RouterContext<Record<string, string>, TVariables>) => Promise<Response> | Response;
|
|
12
|
+
}
|
|
13
|
+
declare const cors: <TVariables extends VariableMap = VariableMap>(options?: CORSOptions) => MiddlewareHandler<Record<string, string>, TVariables>;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { CORSOptions, cors };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
//#region src/router/middleware/cors.ts
|
|
2
|
+
const DEFAULT_CORS_OPTIONS = {
|
|
3
|
+
origin: "*",
|
|
4
|
+
allowMethods: [
|
|
5
|
+
"GET",
|
|
6
|
+
"HEAD",
|
|
7
|
+
"PUT",
|
|
8
|
+
"POST",
|
|
9
|
+
"DELETE",
|
|
10
|
+
"PATCH",
|
|
11
|
+
"OPTIONS"
|
|
12
|
+
],
|
|
13
|
+
allowHeaders: [],
|
|
14
|
+
maxAge: 86400,
|
|
15
|
+
credentials: false,
|
|
16
|
+
exposeHeaders: []
|
|
17
|
+
};
|
|
18
|
+
const createOriginResolver = (origin) => {
|
|
19
|
+
if (!origin || origin === "*") return () => "*";
|
|
20
|
+
if (typeof origin === "string") return (requestOrigin) => origin === requestOrigin ? origin : null;
|
|
21
|
+
if (typeof origin === "function") return origin;
|
|
22
|
+
if (Array.isArray(origin)) {
|
|
23
|
+
const allowedOrigins = new Set(origin);
|
|
24
|
+
return (requestOrigin) => allowedOrigins.has(requestOrigin) ? requestOrigin : null;
|
|
25
|
+
}
|
|
26
|
+
return () => null;
|
|
27
|
+
};
|
|
28
|
+
const createMethodsResolver = (methods) => {
|
|
29
|
+
if (typeof methods === "function") return methods;
|
|
30
|
+
if (Array.isArray(methods)) return () => methods;
|
|
31
|
+
return () => DEFAULT_CORS_OPTIONS.allowMethods;
|
|
32
|
+
};
|
|
33
|
+
const normalizeCORSOptions = (options = {}) => {
|
|
34
|
+
const opts = {
|
|
35
|
+
...DEFAULT_CORS_OPTIONS,
|
|
36
|
+
...options
|
|
37
|
+
};
|
|
38
|
+
if (opts.maxAge < 0) throw new Error("CORS maxAge must be non-negative");
|
|
39
|
+
if (Array.isArray(opts.allowMethods)) {
|
|
40
|
+
const validMethods = new Set([
|
|
41
|
+
"GET",
|
|
42
|
+
"HEAD",
|
|
43
|
+
"PUT",
|
|
44
|
+
"POST",
|
|
45
|
+
"DELETE",
|
|
46
|
+
"PATCH",
|
|
47
|
+
"OPTIONS"
|
|
48
|
+
]);
|
|
49
|
+
const invalidMethods = opts.allowMethods.filter((method) => !validMethods.has(method.toUpperCase()));
|
|
50
|
+
if (invalidMethods.length > 0) console.warn(`CORS: Invalid HTTP methods detected: ${invalidMethods.join(", ")}`);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
...opts,
|
|
54
|
+
_originResolver: createOriginResolver(options.origin),
|
|
55
|
+
_methodsResolver: createMethodsResolver(options.allowMethods)
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
const cors = (options = {}) => {
|
|
59
|
+
const opts = normalizeCORSOptions(options);
|
|
60
|
+
return async (ctx, next) => {
|
|
61
|
+
const requestOrigin = ctx.header("origin") || "";
|
|
62
|
+
const requestMethod = ctx.request.method.toUpperCase();
|
|
63
|
+
const allowedOrigin = opts._originResolver(requestOrigin, ctx);
|
|
64
|
+
const corsHeaders = {};
|
|
65
|
+
if (allowedOrigin) corsHeaders["Access-Control-Allow-Origin"] = allowedOrigin;
|
|
66
|
+
if (opts.origin !== "*" && allowedOrigin) {
|
|
67
|
+
const existingVary = ctx.header("vary");
|
|
68
|
+
corsHeaders.Vary = existingVary ? `${existingVary}, Origin` : "Origin";
|
|
69
|
+
}
|
|
70
|
+
if (opts.credentials) corsHeaders["Access-Control-Allow-Credentials"] = "true";
|
|
71
|
+
if (opts.exposeHeaders && opts.exposeHeaders.length > 0) corsHeaders["Access-Control-Expose-Headers"] = opts.exposeHeaders.join(",");
|
|
72
|
+
if (requestMethod === "OPTIONS") {
|
|
73
|
+
if (opts.preflightHandler) return await opts.preflightHandler(ctx);
|
|
74
|
+
if (opts.maxAge != null) corsHeaders["Access-Control-Max-Age"] = opts.maxAge.toString();
|
|
75
|
+
const allowedMethods = opts._methodsResolver(requestOrigin, ctx);
|
|
76
|
+
if (allowedMethods.length > 0) corsHeaders["Access-Control-Allow-Methods"] = allowedMethods.join(",");
|
|
77
|
+
let allowedHeaders = opts.allowHeaders;
|
|
78
|
+
if (!allowedHeaders || allowedHeaders.length === 0) {
|
|
79
|
+
const requestHeaders = ctx.header("access-control-request-headers");
|
|
80
|
+
if (requestHeaders) allowedHeaders = requestHeaders.split(/\s*,\s*/);
|
|
81
|
+
}
|
|
82
|
+
if (allowedHeaders && allowedHeaders.length > 0) {
|
|
83
|
+
corsHeaders["Access-Control-Allow-Headers"] = allowedHeaders.join(",");
|
|
84
|
+
const existingVary = corsHeaders.Vary || ctx.header("vary");
|
|
85
|
+
corsHeaders.Vary = existingVary ? `${existingVary}, Access-Control-Request-Headers` : "Access-Control-Request-Headers";
|
|
86
|
+
}
|
|
87
|
+
const headers = new Headers();
|
|
88
|
+
Object.entries(corsHeaders).forEach(([key, value]) => {
|
|
89
|
+
headers.set(key, value);
|
|
90
|
+
});
|
|
91
|
+
return new Response(null, {
|
|
92
|
+
status: 204,
|
|
93
|
+
statusText: "No Content",
|
|
94
|
+
headers
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return applyCORSHeaders(await next(), corsHeaders);
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
const applyCORSHeaders = (response, corsHeaders) => {
|
|
101
|
+
if (Object.keys(corsHeaders).length === 0) return response;
|
|
102
|
+
const newHeaders = new Headers(response.headers);
|
|
103
|
+
Object.entries(corsHeaders).forEach(([key, value]) => {
|
|
104
|
+
newHeaders.set(key, value);
|
|
105
|
+
});
|
|
106
|
+
return new Response(response.body, {
|
|
107
|
+
status: response.status,
|
|
108
|
+
statusText: response.statusText,
|
|
109
|
+
headers: newHeaders
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
//#endregion
|
|
114
|
+
export { cors };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
//#region src/router/safe-request.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* SafeRequest wraps a Request to cache the body and allow multiple reads.
|
|
4
|
+
* This solves issues with Request implementations that don't support multiple body reads.
|
|
5
|
+
*/
|
|
6
|
+
interface SafeRequestOptions {
|
|
7
|
+
cache?: RequestCache;
|
|
8
|
+
credentials?: RequestCredentials;
|
|
9
|
+
destination?: RequestDestination;
|
|
10
|
+
integrity?: string;
|
|
11
|
+
keepalive?: boolean;
|
|
12
|
+
mode?: RequestMode;
|
|
13
|
+
redirect?: RequestRedirect;
|
|
14
|
+
referrer?: string;
|
|
15
|
+
referrerPolicy?: ReferrerPolicy;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
declare class SafeRequest implements Request {
|
|
19
|
+
private cachedBody;
|
|
20
|
+
private bodyBuffer;
|
|
21
|
+
private readonly textDecoder;
|
|
22
|
+
readonly cache: RequestCache;
|
|
23
|
+
readonly credentials: RequestCredentials;
|
|
24
|
+
readonly destination: RequestDestination;
|
|
25
|
+
readonly headers: Headers;
|
|
26
|
+
readonly integrity: string;
|
|
27
|
+
readonly keepalive: boolean;
|
|
28
|
+
readonly method: string;
|
|
29
|
+
readonly mode: RequestMode;
|
|
30
|
+
readonly redirect: RequestRedirect;
|
|
31
|
+
readonly referrer: string;
|
|
32
|
+
readonly referrerPolicy: ReferrerPolicy;
|
|
33
|
+
readonly signal: AbortSignal;
|
|
34
|
+
readonly url: string;
|
|
35
|
+
constructor(url: string, method: string, headers: Headers, body: ArrayBuffer | null, options?: SafeRequestOptions);
|
|
36
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
37
|
+
blob(): Promise<Blob>;
|
|
38
|
+
formData(): Promise<FormData>;
|
|
39
|
+
json<T = unknown>(): Promise<T>;
|
|
40
|
+
text(): Promise<string>;
|
|
41
|
+
bytes(): Promise<Uint8Array<ArrayBuffer>>;
|
|
42
|
+
get body(): ReadableStream<Uint8Array<ArrayBuffer>> | null;
|
|
43
|
+
get bodyUsed(): boolean;
|
|
44
|
+
clone(): Request;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Extracts data from a Request and creates a SafeRequest.
|
|
48
|
+
* Uses ReadableStream to safely read the body and avoid issues with certain Request implementations.
|
|
49
|
+
*/
|
|
50
|
+
declare function makeSafeRequest(request: Request): Promise<Request>;
|
|
51
|
+
//#endregion
|
|
52
|
+
export { SafeRequest, makeSafeRequest };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
//#region src/router/safe-request.ts
|
|
2
|
+
var SafeRequest = class SafeRequest {
|
|
3
|
+
cachedBody = null;
|
|
4
|
+
bodyBuffer = null;
|
|
5
|
+
textDecoder = new TextDecoder();
|
|
6
|
+
cache;
|
|
7
|
+
credentials;
|
|
8
|
+
destination;
|
|
9
|
+
headers;
|
|
10
|
+
integrity;
|
|
11
|
+
keepalive;
|
|
12
|
+
method;
|
|
13
|
+
mode;
|
|
14
|
+
redirect;
|
|
15
|
+
referrer;
|
|
16
|
+
referrerPolicy;
|
|
17
|
+
signal;
|
|
18
|
+
url;
|
|
19
|
+
constructor(url, method, headers, body, options) {
|
|
20
|
+
this.url = url;
|
|
21
|
+
this.method = method;
|
|
22
|
+
this.headers = headers;
|
|
23
|
+
this.bodyBuffer = body;
|
|
24
|
+
this.cache = options?.cache ?? "default";
|
|
25
|
+
this.credentials = options?.credentials ?? "same-origin";
|
|
26
|
+
this.destination = options?.destination ?? "";
|
|
27
|
+
this.integrity = options?.integrity ?? "";
|
|
28
|
+
this.keepalive = options?.keepalive ?? false;
|
|
29
|
+
this.mode = options?.mode ?? "cors";
|
|
30
|
+
this.redirect = options?.redirect ?? "follow";
|
|
31
|
+
this.referrer = options?.referrer ?? "";
|
|
32
|
+
this.referrerPolicy = options?.referrerPolicy ?? "";
|
|
33
|
+
this.signal = options?.signal ?? new AbortController().signal;
|
|
34
|
+
}
|
|
35
|
+
async arrayBuffer() {
|
|
36
|
+
return this.bodyBuffer ?? /* @__PURE__ */ new ArrayBuffer(0);
|
|
37
|
+
}
|
|
38
|
+
async blob() {
|
|
39
|
+
const buffer = await this.arrayBuffer();
|
|
40
|
+
return new Blob([buffer]);
|
|
41
|
+
}
|
|
42
|
+
async formData() {
|
|
43
|
+
const buffer = await this.arrayBuffer();
|
|
44
|
+
const blob = new Blob([buffer], { type: this.headers.get("content-type") || "application/x-www-form-urlencoded" });
|
|
45
|
+
return new Request(this.url, {
|
|
46
|
+
method: this.method,
|
|
47
|
+
headers: this.headers,
|
|
48
|
+
body: blob
|
|
49
|
+
}).formData();
|
|
50
|
+
}
|
|
51
|
+
async json() {
|
|
52
|
+
const text = await this.text();
|
|
53
|
+
return JSON.parse(text);
|
|
54
|
+
}
|
|
55
|
+
async text() {
|
|
56
|
+
const buffer = await this.arrayBuffer();
|
|
57
|
+
return this.textDecoder.decode(buffer);
|
|
58
|
+
}
|
|
59
|
+
async bytes() {
|
|
60
|
+
return this.arrayBuffer().then((buffer) => new Uint8Array(buffer));
|
|
61
|
+
}
|
|
62
|
+
get body() {
|
|
63
|
+
if (this.cachedBody) return this.cachedBody;
|
|
64
|
+
if (this.bodyBuffer) {
|
|
65
|
+
const buffer = this.bodyBuffer;
|
|
66
|
+
this.cachedBody = new ReadableStream({ start(controller) {
|
|
67
|
+
controller.enqueue(new Uint8Array(buffer));
|
|
68
|
+
controller.close();
|
|
69
|
+
} });
|
|
70
|
+
return this.cachedBody;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
get bodyUsed() {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
clone() {
|
|
78
|
+
return new SafeRequest(this.url, this.method, this.headers, this.bodyBuffer, {
|
|
79
|
+
cache: this.cache,
|
|
80
|
+
credentials: this.credentials,
|
|
81
|
+
destination: this.destination,
|
|
82
|
+
integrity: this.integrity,
|
|
83
|
+
keepalive: this.keepalive,
|
|
84
|
+
mode: this.mode,
|
|
85
|
+
redirect: this.redirect,
|
|
86
|
+
referrer: this.referrer,
|
|
87
|
+
referrerPolicy: this.referrerPolicy,
|
|
88
|
+
signal: this.signal
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Extracts data from a Request and creates a SafeRequest.
|
|
94
|
+
* Uses ReadableStream to safely read the body and avoid issues with certain Request implementations.
|
|
95
|
+
*/
|
|
96
|
+
async function makeSafeRequest(request) {
|
|
97
|
+
if (request instanceof SafeRequest) return request;
|
|
98
|
+
const url = request.url;
|
|
99
|
+
const method = request.method;
|
|
100
|
+
const headers = new Headers(request.headers);
|
|
101
|
+
let bodyBuffer = null;
|
|
102
|
+
const requestMethod = method.toUpperCase();
|
|
103
|
+
if (requestMethod === "POST" || requestMethod === "PUT" || requestMethod === "PATCH") try {
|
|
104
|
+
const body = request.body;
|
|
105
|
+
if (body) {
|
|
106
|
+
const reader = body.getReader();
|
|
107
|
+
const chunks = [];
|
|
108
|
+
while (true) {
|
|
109
|
+
const { done, value } = await reader.read();
|
|
110
|
+
if (done) break;
|
|
111
|
+
if (value) chunks.push(value);
|
|
112
|
+
}
|
|
113
|
+
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
|
114
|
+
const combined = new Uint8Array(totalLength);
|
|
115
|
+
let offset = 0;
|
|
116
|
+
for (const chunk of chunks) {
|
|
117
|
+
combined.set(chunk, offset);
|
|
118
|
+
offset += chunk.length;
|
|
119
|
+
}
|
|
120
|
+
bodyBuffer = combined.buffer;
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.warn("Failed to read request body via stream:", error);
|
|
124
|
+
}
|
|
125
|
+
const options = {};
|
|
126
|
+
try {
|
|
127
|
+
options.cache = request.cache;
|
|
128
|
+
} catch {}
|
|
129
|
+
try {
|
|
130
|
+
options.credentials = request.credentials;
|
|
131
|
+
} catch {}
|
|
132
|
+
try {
|
|
133
|
+
options.destination = request.destination;
|
|
134
|
+
} catch {}
|
|
135
|
+
try {
|
|
136
|
+
options.integrity = request.integrity;
|
|
137
|
+
} catch {}
|
|
138
|
+
try {
|
|
139
|
+
options.keepalive = request.keepalive;
|
|
140
|
+
} catch {}
|
|
141
|
+
try {
|
|
142
|
+
options.mode = request.mode;
|
|
143
|
+
} catch {}
|
|
144
|
+
try {
|
|
145
|
+
options.redirect = request.redirect;
|
|
146
|
+
} catch {}
|
|
147
|
+
try {
|
|
148
|
+
options.referrer = request.referrer;
|
|
149
|
+
} catch {}
|
|
150
|
+
try {
|
|
151
|
+
options.referrerPolicy = request.referrerPolicy;
|
|
152
|
+
} catch {}
|
|
153
|
+
try {
|
|
154
|
+
options.signal = request.signal;
|
|
155
|
+
} catch {}
|
|
156
|
+
return new SafeRequest(url, method, headers, bodyBuffer, options);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
//#endregion
|
|
160
|
+
export { SafeRequest, makeSafeRequest };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//#region src/router/types.d.ts
|
|
2
|
+
type ExtractParams<T extends string> = string extends T ? Record<string, string> : T extends `${string}:${infer Param}/${infer Rest}` ? { readonly [K in Param]: string } & ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}` ? { readonly [K in Param]: string } : Record<string, never>;
|
|
3
|
+
type VariableMap = Record<string, unknown>;
|
|
4
|
+
interface RouterEnvironment<TVariables extends VariableMap = VariableMap> {
|
|
5
|
+
Variables: TVariables;
|
|
6
|
+
}
|
|
7
|
+
interface CookieOptions {
|
|
8
|
+
domain?: string;
|
|
9
|
+
path?: string;
|
|
10
|
+
expires?: Date;
|
|
11
|
+
maxAge?: number;
|
|
12
|
+
httpOnly?: boolean;
|
|
13
|
+
secure?: boolean;
|
|
14
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
15
|
+
}
|
|
16
|
+
interface RouterContext<TParams extends Record<string, string> = Record<string, string>, TVariables extends VariableMap = VariableMap> {
|
|
17
|
+
readonly request: Request;
|
|
18
|
+
readonly params: Readonly<TParams>;
|
|
19
|
+
readonly searchParams: Readonly<URLSearchParams>;
|
|
20
|
+
query<K extends string>(key: K): string | undefined;
|
|
21
|
+
header<K extends string>(key: K): string | undefined;
|
|
22
|
+
cookie<K extends string>(key: K): string | undefined;
|
|
23
|
+
formData(): Promise<FormData>;
|
|
24
|
+
parseJson<T = unknown>(): Promise<T>;
|
|
25
|
+
json<T>(data: T, init?: ResponseInit): Response;
|
|
26
|
+
redirect(url: string | URL, status?: 300 | 301 | 302 | 303 | 307 | 308): Response;
|
|
27
|
+
text(data: string, init?: ResponseInit): Response;
|
|
28
|
+
setCookie(name: string, value: string, options?: CookieOptions): void;
|
|
29
|
+
deleteCookie(name: string, options?: Pick<CookieOptions, "domain" | "path">): void;
|
|
30
|
+
newResponse(body?: BodyInit, init?: ResponseInit): Response;
|
|
31
|
+
set<K extends keyof TVariables>(key: K, value: TVariables[K]): void;
|
|
32
|
+
get<K extends keyof TVariables>(key: K): TVariables[K];
|
|
33
|
+
has<K extends keyof TVariables>(key: K): key is K;
|
|
34
|
+
}
|
|
35
|
+
type RouteHandler<TParams extends Record<string, string> = Record<string, string>, TVariables extends VariableMap = VariableMap> = (ctx: RouterContext<TParams, TVariables>) => Promise<Response> | Response;
|
|
36
|
+
type MiddlewareHandler<TParams extends Record<string, string> = Record<string, string>, TVariables extends VariableMap = VariableMap> = (ctx: RouterContext<TParams, TVariables>, next: () => Promise<Response> | Response) => Promise<Response> | Response;
|
|
37
|
+
type EnhancedRouteHandler<TParams extends Record<string, string> = Record<string, string>, TVariables extends VariableMap = VariableMap> = {
|
|
38
|
+
handler: RouteHandler<TParams, TVariables>;
|
|
39
|
+
middleware?: MiddlewareHandler<TParams, TVariables>[];
|
|
40
|
+
};
|
|
41
|
+
type AnyHandler<TParams extends Record<string, string>, TVariables extends VariableMap = VariableMap> = RouteHandler<TParams, TVariables> | EnhancedRouteHandler<TParams, TVariables>;
|
|
42
|
+
type HttpMethod = "GET" | "POST";
|
|
43
|
+
interface CompiledRoute {
|
|
44
|
+
regex: RegExp;
|
|
45
|
+
paramNames: string[];
|
|
46
|
+
pattern: string;
|
|
47
|
+
}
|
|
48
|
+
interface MatchResult {
|
|
49
|
+
params: Record<string, string>;
|
|
50
|
+
pattern: string;
|
|
51
|
+
}
|
|
52
|
+
interface RouteDefinition<TVariables extends VariableMap = VariableMap> {
|
|
53
|
+
method: HttpMethod;
|
|
54
|
+
pattern: string;
|
|
55
|
+
handler: RouteHandler<Record<string, string>, TVariables>;
|
|
56
|
+
middleware: MiddlewareHandler<Record<string, string>, TVariables>[];
|
|
57
|
+
compiled: CompiledRoute;
|
|
58
|
+
}
|
|
59
|
+
interface RouterOptions {
|
|
60
|
+
caseSensitive?: boolean;
|
|
61
|
+
strict?: boolean;
|
|
62
|
+
basePath?: string;
|
|
63
|
+
}
|
|
64
|
+
type ErrorHandler<TVariables extends VariableMap = VariableMap> = (error: Error, ctx: RouterContext<Record<string, string>, TVariables>) => Promise<Response> | Response;
|
|
65
|
+
type GlobalMiddleware<TVariables extends VariableMap = VariableMap> = (ctx: RouterContext<Record<string, string>, TVariables>, next: () => Promise<Response> | Response) => Promise<Response> | Response;
|
|
66
|
+
//#endregion
|
|
67
|
+
export { AnyHandler, CompiledRoute, CookieOptions, EnhancedRouteHandler, ErrorHandler, ExtractParams, GlobalMiddleware, HttpMethod, MatchResult, MiddlewareHandler, RouteDefinition, RouteHandler, RouterContext, RouterEnvironment, RouterOptions, VariableMap };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { VariableMap } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/router/variables.d.ts
|
|
4
|
+
declare class ContextVariableManager<TVariables extends VariableMap = VariableMap> {
|
|
5
|
+
private readonly state;
|
|
6
|
+
constructor(initialVariables?: Partial<TVariables>);
|
|
7
|
+
set<K extends keyof TVariables>(key: K, value: TVariables[K]): void;
|
|
8
|
+
get<K extends keyof TVariables>(key: K): TVariables[K];
|
|
9
|
+
has<K extends keyof TVariables>(key: K): key is K;
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
export { ContextVariableManager };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/router/variables.ts
|
|
2
|
+
var ContextVariableManager = class {
|
|
3
|
+
state = /* @__PURE__ */ new Map();
|
|
4
|
+
constructor(initialVariables) {
|
|
5
|
+
if (initialVariables) for (const [key, value] of Object.entries(initialVariables)) this.state.set(key, value);
|
|
6
|
+
}
|
|
7
|
+
set(key, value) {
|
|
8
|
+
if (typeof key !== "string" || key.length === 0) throw new Error("Variable key must be a non-empty string");
|
|
9
|
+
this.state.set(key, value);
|
|
10
|
+
}
|
|
11
|
+
get(key) {
|
|
12
|
+
return this.state.get(key);
|
|
13
|
+
}
|
|
14
|
+
has(key) {
|
|
15
|
+
return this.state.has(key);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
//#endregion
|
|
20
|
+
export { ContextVariableManager };
|
package/dist/util.d.mts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@draftlab/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Core implementation for @draftlab/auth",
|
|
6
6
|
"author": "Matheus Pergoli",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/node": "^25.0.3",
|
|
41
41
|
"@types/qrcode": "^1.5.6",
|
|
42
|
-
"tsdown": "
|
|
42
|
+
"tsdown": "0.19.0-beta.5",
|
|
43
43
|
"typescript": "^5.9.3",
|
|
44
44
|
"@draftlab/tsconfig": "0.1.0"
|
|
45
45
|
},
|
|
@@ -60,10 +60,9 @@
|
|
|
60
60
|
"@standard-schema/spec": "^1.1.0",
|
|
61
61
|
"jose": "^6.1.3",
|
|
62
62
|
"otpauth": "^9.4.1",
|
|
63
|
-
"preact": "^10.28.
|
|
64
|
-
"preact-render-to-string": "^6.6.
|
|
65
|
-
"qrcode": "^1.5.4"
|
|
66
|
-
"@draftlab/auth-router": "0.5.0"
|
|
63
|
+
"preact": "^10.28.2",
|
|
64
|
+
"preact-render-to-string": "^6.6.5",
|
|
65
|
+
"qrcode": "^1.5.4"
|
|
67
66
|
},
|
|
68
67
|
"engines": {
|
|
69
68
|
"node": ">=18"
|