mintiljs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -0
- package/auth/index.ts +1 -0
- package/i18n/index.ts +1 -0
- package/index.ts +1 -0
- package/package.json +68 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/jwt.ts +101 -0
- package/src/auth/middleware.ts +150 -0
- package/src/auth/session.ts +46 -0
- package/src/auth/store.ts +39 -0
- package/src/auth/types.ts +55 -0
- package/src/cli/cli.ts +142 -0
- package/src/cli/generate.ts +142 -0
- package/src/core/client.ts +157 -0
- package/src/core/config.ts +16 -0
- package/src/core/css.ts +72 -0
- package/src/core/islands.ts +90 -0
- package/src/core/logger.ts +37 -0
- package/src/core/routes.ts +247 -0
- package/src/core/runtime.ts +131 -0
- package/src/core/watcher.ts +43 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/loader.ts +125 -0
- package/src/i18n/translate.ts +89 -0
- package/src/i18n/types.ts +10 -0
- package/src/index.ts +10 -0
- package/src/render/island.tsx +78 -0
- package/src/render/ssr.tsx +198 -0
- package/src/render/stream.tsx +144 -0
- package/src/router/api.ts +111 -0
- package/src/router/index.ts +9 -0
- package/src/router/middleware.ts +16 -0
- package/src/router/pages.ts +97 -0
- package/src/router/shared.ts +3 -0
- package/src/types/api.ts +106 -0
- package/src/types/config.ts +35 -0
- package/src/types/index.ts +4 -0
- package/src/types/middleware.ts +21 -0
- package/src/types/page.ts +51 -0
- package/src/types/plugin.ts +29 -0
- package/src/utils/fs.ts +46 -0
- package/src/utils/logger.ts +21 -0
- package/src/utils/network.ts +14 -0
- package/templates/default/api/hello.ts +9 -0
- package/templates/default/components/card.tsx +10 -0
- package/templates/default/components/layouts/base.tsx +26 -0
- package/templates/default/lib/greeting.ts +3 -0
- package/templates/default/mintil.config.ts +10 -0
- package/templates/default/package.json +17 -0
- package/templates/default/pages/(*).tsx +11 -0
- package/templates/default/pages/about.tsx +13 -0
- package/templates/default/pages/blog/(:slug).tsx +12 -0
- package/templates/default/pages/counter.tsx +36 -0
- package/templates/default/pages/index.tsx +14 -0
- package/templates/default/public/readme.txt +1 -0
- package/templates/default/styles.css +1 -0
- package/templates/default/tsconfig.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# MintilJS
|
|
2
|
+
|
|
3
|
+
> Minimal SSR web framework for Bun — React pages rendered on the server, zero client JavaScript unless you ask for it.
|
|
4
|
+
|
|
5
|
+
MintilJS is a full-stack framework built on **Bun**, **Hono**, and **React 19**. Drop files in `pages/` and they become SSR routes. Add `api/` files for JSON endpoints. Throw in `islands/` for partial hydration. All with zero configuration.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun create mintiljs my-app
|
|
9
|
+
cd my-app
|
|
10
|
+
bun run mintil dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **File-based routing** — `pages/` → SSR routes, `api/` → JSON endpoints
|
|
16
|
+
- **SSR by default** — zero JS in the browser unless you opt in
|
|
17
|
+
- **`useClient`** — per-page hydration bundles via `Bun.build`
|
|
18
|
+
- **Islands** — independent interactive components, no full-page re-render
|
|
19
|
+
- **`getServerSideProps`** — per-request data fetching
|
|
20
|
+
- **Hierarchical middleware** — global, API-wide, directory-scoped
|
|
21
|
+
- **Layouts** — root layout (`components/layouts/base.tsx`) + per-directory overrides
|
|
22
|
+
- **Tailwind v4** — automatic CSS processing via PostCSS
|
|
23
|
+
- **Streaming SSR** — `renderToReadableStream` for pages without client bundles
|
|
24
|
+
- **Auth module** — `mintiljs/auth` (JWT, sessions, middleware)
|
|
25
|
+
- **i18n module** — `mintiljs/i18n` (auto-detected messages, placeholders, CSR fetcher)
|
|
26
|
+
- **Plugin system** — extend the app at startup
|
|
27
|
+
- **Auto-reload** — file watcher in development
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
bun add mintiljs
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
my-app/
|
|
39
|
+
pages/ → SSR routes
|
|
40
|
+
index.tsx /
|
|
41
|
+
blog/
|
|
42
|
+
(:slug).tsx /blog/:slug
|
|
43
|
+
layout.tsx layout scoped to /blog/*
|
|
44
|
+
api/ → JSON endpoints
|
|
45
|
+
hello.ts /api/hello
|
|
46
|
+
middleware.ts applies to all /api/*
|
|
47
|
+
islands/ → auto-registered hydratable components
|
|
48
|
+
Counter.tsx
|
|
49
|
+
components/
|
|
50
|
+
layouts/
|
|
51
|
+
base.tsx root layout
|
|
52
|
+
i18n/ → auto-detected messages
|
|
53
|
+
messages/
|
|
54
|
+
en/0.json
|
|
55
|
+
pt-BR.json
|
|
56
|
+
mintil.config.ts → project config (optional)
|
|
57
|
+
middleware.ts → root middleware (every request)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Create a page
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
// pages/index.tsx → /
|
|
64
|
+
export default function Home() {
|
|
65
|
+
return <h1 className="text-3xl font-bold">Home</h1>;
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Add an API endpoint
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// api/hello.ts → GET /api/hello
|
|
73
|
+
import type { ApiHandler } from "mintiljs";
|
|
74
|
+
|
|
75
|
+
export default (c) => c.json({ message: "Hello" });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Make a page interactive
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
// pages/counter.tsx
|
|
82
|
+
import React from "react";
|
|
83
|
+
|
|
84
|
+
export const useClient = true;
|
|
85
|
+
|
|
86
|
+
export default function Counter() {
|
|
87
|
+
const [count, setCount] = React.useState(0);
|
|
88
|
+
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Fetch data on every request
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
import type { GetServerSideProps } from "mintiljs";
|
|
96
|
+
|
|
97
|
+
export const getServerSideProps: GetServerSideProps = async ({ params, searchParams }) => {
|
|
98
|
+
const data = await db.find(params.id);
|
|
99
|
+
return { props: { data } };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export default function Page({ data }: { data: any }) {
|
|
103
|
+
return <div>{data.name}</div>;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
Export a `MintilConfig` default from `mintil.config.ts`:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import type { MintilConfig } from "mintiljs";
|
|
113
|
+
|
|
114
|
+
export default {
|
|
115
|
+
port: 3456,
|
|
116
|
+
host: false, // true = bind to 0.0.0.0
|
|
117
|
+
mode: "development", // "development" | "production"
|
|
118
|
+
plugins: [],
|
|
119
|
+
} satisfies MintilConfig;
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## CLI
|
|
123
|
+
|
|
124
|
+
| Command | Description |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `mintil init <name>` | Scaffold a new project |
|
|
127
|
+
| `mintil dev` | Dev mode with auto-reload |
|
|
128
|
+
| `mintil start` | Production mode |
|
|
129
|
+
| `mintil g page <n>` | Scaffold a page |
|
|
130
|
+
| `mintil g api <n>` | Scaffold an API route |
|
|
131
|
+
| `mintil g island <n>` | Scaffold an island |
|
|
132
|
+
|
|
133
|
+
## Modules
|
|
134
|
+
|
|
135
|
+
### Auth (`mintiljs/auth`)
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { createAuthMiddleware, InMemorySessionStore } from "mintiljs/auth";
|
|
139
|
+
|
|
140
|
+
const auth = createAuthMiddleware({
|
|
141
|
+
jwt: { secret: process.env.JWT_SECRET!, expiresIn: "1h" },
|
|
142
|
+
session: { store: new InMemorySessionStore(), maxAge: 86400, cookieName: "session", cookiePath: "/" },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const token = await auth.signJWT({ sub: userId });
|
|
146
|
+
const payload = await auth.verifyJWT(token);
|
|
147
|
+
app.get("/api/admin", auth.requireAuth, handler);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### i18n (`mintiljs/i18n`)
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
i18n/messages/en/0.json → { "greeting": "Hello" }
|
|
154
|
+
i18n/messages/en/1.json → { "welcome": "Welcome to {site}!" }
|
|
155
|
+
i18n/messages/pt-BR.json → { "greeting": "Olá" }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { getMessages, t, format } from "mintiljs/i18n";
|
|
160
|
+
|
|
161
|
+
const msgs = await getMessages("en");
|
|
162
|
+
t(msgs, "greeting") // "Hello"
|
|
163
|
+
t(msgs, "welcome", { site: "MintilJS" }) // "Welcome to MintilJS!"
|
|
164
|
+
format("Hello {name}!", { name: "John" }) // "Hello John!"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The framework auto-registers `/_mintil/i18n/:locale` for CSR usage.
|
|
168
|
+
|
|
169
|
+
### Plugin System
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import type { MintilPlugin } from "mintiljs";
|
|
173
|
+
|
|
174
|
+
const health: MintilPlugin = {
|
|
175
|
+
name: "health",
|
|
176
|
+
setup(app) { app.get("/health", (c) => c.json({ ok: true })); },
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export default { plugins: [health] } satisfies MintilConfig;
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## API Reference
|
|
183
|
+
|
|
184
|
+
Full TypeDoc-generated documentation at `docs/api/`:
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
bun run docs
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT
|
package/auth/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../src/auth/index.ts";
|
package/i18n/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../src/i18n/index.ts";
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./src/index.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mintiljs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal SSR web framework for Bun — React pages rendered on the server, zero client JS unless you ask for it",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "index.ts",
|
|
7
|
+
"main": "index.ts",
|
|
8
|
+
"types": "index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./index.ts",
|
|
11
|
+
"./auth": "./src/auth/index.ts",
|
|
12
|
+
"./i18n": "./src/i18n/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"mintil": "./src/cli/cli.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"index.ts",
|
|
19
|
+
"src",
|
|
20
|
+
"templates",
|
|
21
|
+
"i18n",
|
|
22
|
+
"auth"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"dev": "bun run src/cli/cli.ts dev",
|
|
26
|
+
"start": "bun run src/cli/cli.ts start",
|
|
27
|
+
"mintil": "bun run src/cli/cli.ts",
|
|
28
|
+
"docs": "bun x typedoc --options typedoc.json"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"hono": "^4.12.25",
|
|
32
|
+
"react": "^19.2.7",
|
|
33
|
+
"react-dom": "^19.2.7"
|
|
34
|
+
},
|
|
35
|
+
"optionalDependencies": {
|
|
36
|
+
"@tailwindcss/postcss": "^4.3.1",
|
|
37
|
+
"postcss": "^8.5.15"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"typescript": "^5"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/bun": "latest",
|
|
44
|
+
"@types/react": "^19.2.17",
|
|
45
|
+
"@types/react-dom": "^19.2.3"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/mozinova/mintiljs.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/mozinova/mintiljs#readme",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/mozinova/mintiljs/issues"
|
|
54
|
+
},
|
|
55
|
+
"license": "MIT",
|
|
56
|
+
"author": "Daniel Mucamba",
|
|
57
|
+
"keywords": [
|
|
58
|
+
"ssr",
|
|
59
|
+
"react",
|
|
60
|
+
"bun",
|
|
61
|
+
"hono",
|
|
62
|
+
"framework",
|
|
63
|
+
"server-rendering",
|
|
64
|
+
"islands",
|
|
65
|
+
"i18n",
|
|
66
|
+
"authentication"
|
|
67
|
+
]
|
|
68
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { InMemorySessionStore } from "./store.ts";
|
|
2
|
+
export { signJWT, verifyJWT } from "./jwt.ts";
|
|
3
|
+
export { createSession, getSession, updateSession, destroySession } from "./session.ts";
|
|
4
|
+
export { createAuthMiddleware } from "./middleware.ts";
|
|
5
|
+
export type { Session, SessionStore, JWTConfig, SessionConfig, AuthConfig } from "./types.ts";
|
package/src/auth/jwt.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
function base64urlEncode(buffer: ArrayBuffer): string {
|
|
2
|
+
const base64 = Buffer.from(buffer).toString("base64");
|
|
3
|
+
return base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function base64urlDecode(str: string): Uint8Array {
|
|
7
|
+
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
8
|
+
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
|
|
9
|
+
return Buffer.from(padded, "base64");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseExpiresIn(value: string | number): number {
|
|
13
|
+
if (typeof value === "number") return value;
|
|
14
|
+
const match = value.match(/^(\d+)\s*(s|m|h|d|w)$/);
|
|
15
|
+
if (!match) return 3600;
|
|
16
|
+
const num = parseInt(match[1], 10);
|
|
17
|
+
switch (match[2]) {
|
|
18
|
+
case "s": return num;
|
|
19
|
+
case "m": return num * 60;
|
|
20
|
+
case "h": return num * 3600;
|
|
21
|
+
case "d": return num * 86400;
|
|
22
|
+
case "w": return num * 604800;
|
|
23
|
+
default: return 3600;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function signJWT(
|
|
28
|
+
payload: Record<string, any>,
|
|
29
|
+
secret: string,
|
|
30
|
+
options?: { expiresIn?: string | number; issuer?: string; audience?: string },
|
|
31
|
+
): Promise<string> {
|
|
32
|
+
const header = { alg: "HS256", typ: "JWT" };
|
|
33
|
+
const now = Math.floor(Date.now() / 1000);
|
|
34
|
+
const tokenPayload: Record<string, any> = {
|
|
35
|
+
...payload,
|
|
36
|
+
iat: now,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
if (options?.expiresIn) {
|
|
40
|
+
tokenPayload.exp = now + parseExpiresIn(options.expiresIn);
|
|
41
|
+
}
|
|
42
|
+
if (options?.issuer) tokenPayload.iss = options.issuer;
|
|
43
|
+
if (options?.audience) tokenPayload.aud = options.audience;
|
|
44
|
+
|
|
45
|
+
const headerB64 = base64urlEncode(new TextEncoder().encode(JSON.stringify(header)));
|
|
46
|
+
const payloadB64 = base64urlEncode(new TextEncoder().encode(JSON.stringify(tokenPayload)));
|
|
47
|
+
|
|
48
|
+
const key = await crypto.subtle.importKey(
|
|
49
|
+
"raw",
|
|
50
|
+
new TextEncoder().encode(secret),
|
|
51
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
52
|
+
false,
|
|
53
|
+
["sign"],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const signature = await crypto.subtle.sign(
|
|
57
|
+
"HMAC",
|
|
58
|
+
key,
|
|
59
|
+
new TextEncoder().encode(`${headerB64}.${payloadB64}`),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return `${headerB64}.${payloadB64}.${base64urlEncode(signature)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function verifyJWT(
|
|
66
|
+
token: string,
|
|
67
|
+
secret: string,
|
|
68
|
+
options?: { issuer?: string; audience?: string },
|
|
69
|
+
): Promise<Record<string, any> | null> {
|
|
70
|
+
const parts = token.split(".");
|
|
71
|
+
if (parts.length !== 3) return null;
|
|
72
|
+
|
|
73
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
74
|
+
|
|
75
|
+
const key = await crypto.subtle.importKey(
|
|
76
|
+
"raw",
|
|
77
|
+
new TextEncoder().encode(secret),
|
|
78
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
79
|
+
false,
|
|
80
|
+
["verify"],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const valid = await crypto.subtle.verify(
|
|
84
|
+
"HMAC",
|
|
85
|
+
key,
|
|
86
|
+
base64urlDecode(signatureB64),
|
|
87
|
+
new TextEncoder().encode(`${headerB64}.${payloadB64}`),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (!valid) return null;
|
|
91
|
+
|
|
92
|
+
const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
|
|
93
|
+
|
|
94
|
+
const now = Math.floor(Date.now() / 1000);
|
|
95
|
+
if (payload.exp && payload.exp < now) return null;
|
|
96
|
+
|
|
97
|
+
if (options?.issuer && payload.iss !== options.issuer) return null;
|
|
98
|
+
if (options?.audience && payload.aud !== options.audience) return null;
|
|
99
|
+
|
|
100
|
+
return payload;
|
|
101
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
|
3
|
+
import { verifyJWT, signJWT } from "./jwt.ts";
|
|
4
|
+
import { createSession, getSession, destroySession } from "./session.ts";
|
|
5
|
+
import type { Session, SessionStore, JWTConfig } from "./types.ts";
|
|
6
|
+
import type { ApiResponse } from "../types/api.ts";
|
|
7
|
+
|
|
8
|
+
export function createAuthMiddleware(config: {
|
|
9
|
+
jwt: JWTConfig;
|
|
10
|
+
session: {
|
|
11
|
+
store: SessionStore;
|
|
12
|
+
maxAge: number;
|
|
13
|
+
cookieName: string;
|
|
14
|
+
cookiePath: string;
|
|
15
|
+
};
|
|
16
|
+
}) {
|
|
17
|
+
const { jwt: jwtConfig, session: sessionConfig } = config;
|
|
18
|
+
|
|
19
|
+
async function getSessionFromCookie(c: Context): Promise<Session | null> {
|
|
20
|
+
const raw = getCookie(c, sessionConfig.cookieName);
|
|
21
|
+
if (!raw) return null;
|
|
22
|
+
|
|
23
|
+
const payload = await verifyJWT(raw, jwtConfig.secret);
|
|
24
|
+
if (!payload || !payload.sid) return null;
|
|
25
|
+
|
|
26
|
+
return getSession(sessionConfig.store, payload.sid as string);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function setSessionCookie(c: Context, sid: string): Promise<void> {
|
|
30
|
+
const token = await signJWT(
|
|
31
|
+
{ sid },
|
|
32
|
+
jwtConfig.secret,
|
|
33
|
+
{ expiresIn: sessionConfig.maxAge },
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
setCookie(c, sessionConfig.cookieName, token, {
|
|
37
|
+
httpOnly: true,
|
|
38
|
+
secure: true,
|
|
39
|
+
sameSite: "Lax",
|
|
40
|
+
path: sessionConfig.cookiePath,
|
|
41
|
+
maxAge: sessionConfig.maxAge,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clearSessionCookie(c: Context): void {
|
|
46
|
+
deleteCookie(c, sessionConfig.cookieName, { path: sessionConfig.cookiePath });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
signJWT: (payload: Record<string, any>) =>
|
|
51
|
+
signJWT(payload, jwtConfig.secret, {
|
|
52
|
+
expiresIn: jwtConfig.expiresIn,
|
|
53
|
+
issuer: jwtConfig.issuer,
|
|
54
|
+
audience: jwtConfig.audience,
|
|
55
|
+
}),
|
|
56
|
+
verifyJWT: (token: string) =>
|
|
57
|
+
verifyJWT(token, jwtConfig.secret, {
|
|
58
|
+
issuer: jwtConfig.issuer,
|
|
59
|
+
audience: jwtConfig.audience,
|
|
60
|
+
}),
|
|
61
|
+
|
|
62
|
+
sessionMiddleware: async (c: ApiResponse, next: () => Promise<void>) => {
|
|
63
|
+
const ctx = c as unknown as Context;
|
|
64
|
+
const session = await getSessionFromCookie(ctx);
|
|
65
|
+
if (session) {
|
|
66
|
+
c.set("session", session);
|
|
67
|
+
c.set("userId", session.userId);
|
|
68
|
+
}
|
|
69
|
+
await next();
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
requireAuth: async (c: ApiResponse, next: () => Promise<void>) => {
|
|
73
|
+
const authHeader = c.req.header("Authorization");
|
|
74
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
75
|
+
const token = authHeader.slice(7);
|
|
76
|
+
const payload = await verifyJWT(token, jwtConfig.secret, {
|
|
77
|
+
issuer: jwtConfig.issuer,
|
|
78
|
+
audience: jwtConfig.audience,
|
|
79
|
+
});
|
|
80
|
+
if (payload) {
|
|
81
|
+
c.set("user", payload);
|
|
82
|
+
c.set("userId", payload.sub || payload.userId);
|
|
83
|
+
await next();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ctx = c as unknown as Context;
|
|
89
|
+
const session = await getSessionFromCookie(ctx);
|
|
90
|
+
if (session) {
|
|
91
|
+
c.set("session", session);
|
|
92
|
+
c.set("userId", session.userId);
|
|
93
|
+
await next();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
c.status(401);
|
|
98
|
+
return c.json({ error: "Unauthorized" });
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
optionalAuth: async (c: ApiResponse, next: () => Promise<void>) => {
|
|
102
|
+
const authHeader = c.req.header("Authorization");
|
|
103
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
104
|
+
const token = authHeader.slice(7);
|
|
105
|
+
const payload = await verifyJWT(token, jwtConfig.secret, {
|
|
106
|
+
issuer: jwtConfig.issuer,
|
|
107
|
+
audience: jwtConfig.audience,
|
|
108
|
+
});
|
|
109
|
+
if (payload) {
|
|
110
|
+
c.set("user", payload);
|
|
111
|
+
c.set("userId", payload.sub || payload.userId);
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
const ctx = c as unknown as Context;
|
|
115
|
+
const session = await getSessionFromCookie(ctx);
|
|
116
|
+
if (session) {
|
|
117
|
+
c.set("session", session);
|
|
118
|
+
c.set("userId", session.userId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
await next();
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
login: async (
|
|
125
|
+
c: ApiResponse,
|
|
126
|
+
userId: string,
|
|
127
|
+
sessionData?: Record<string, any>,
|
|
128
|
+
) => {
|
|
129
|
+
const sess = await createSession(
|
|
130
|
+
sessionConfig.store,
|
|
131
|
+
userId,
|
|
132
|
+
sessionData,
|
|
133
|
+
sessionConfig.maxAge,
|
|
134
|
+
);
|
|
135
|
+
await setSessionCookie(c as unknown as Context, sess.id);
|
|
136
|
+
return sess;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
logout: async (c: ApiResponse) => {
|
|
140
|
+
const session = c.get("session") as Session | undefined;
|
|
141
|
+
if (session) {
|
|
142
|
+
await destroySession(sessionConfig.store, session.id);
|
|
143
|
+
}
|
|
144
|
+
clearSessionCookie(c as unknown as Context);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
setSessionCookie,
|
|
148
|
+
clearSessionCookie,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import type { Session, SessionStore } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export function createSessionId(): string {
|
|
5
|
+
return crypto.randomUUID();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function createSession(
|
|
9
|
+
store: SessionStore,
|
|
10
|
+
userId: string,
|
|
11
|
+
data: Record<string, any> = {},
|
|
12
|
+
maxAge: number = 7 * 24 * 3600,
|
|
13
|
+
): Promise<Session> {
|
|
14
|
+
const now = new Date();
|
|
15
|
+
const session: Session = {
|
|
16
|
+
id: createSessionId(),
|
|
17
|
+
userId,
|
|
18
|
+
data,
|
|
19
|
+
createdAt: now,
|
|
20
|
+
expiresAt: new Date(now.getTime() + maxAge * 1000),
|
|
21
|
+
};
|
|
22
|
+
await store.create(session);
|
|
23
|
+
return session;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function getSession(
|
|
27
|
+
store: SessionStore,
|
|
28
|
+
sessionId: string,
|
|
29
|
+
): Promise<Session | null> {
|
|
30
|
+
return store.get(sessionId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function updateSession(
|
|
34
|
+
store: SessionStore,
|
|
35
|
+
sessionId: string,
|
|
36
|
+
data: Record<string, any>,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
await store.update(sessionId, data);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function destroySession(
|
|
42
|
+
store: SessionStore,
|
|
43
|
+
sessionId: string,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
await store.destroy(sessionId);
|
|
46
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Session, SessionStore } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export class InMemorySessionStore implements SessionStore {
|
|
4
|
+
private sessions = new Map<string, Session>();
|
|
5
|
+
|
|
6
|
+
async create(session: Session): Promise<void> {
|
|
7
|
+
this.sessions.set(session.id, session);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async get(id: string): Promise<Session | null> {
|
|
11
|
+
const s = this.sessions.get(id);
|
|
12
|
+
if (!s) return null;
|
|
13
|
+
if (s.expiresAt < new Date()) {
|
|
14
|
+
this.sessions.delete(id);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return s;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async update(id: string, data: Record<string, any>): Promise<void> {
|
|
21
|
+
const session = this.sessions.get(id);
|
|
22
|
+
if (session) {
|
|
23
|
+
session.data = data;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async destroy(id: string): Promise<void> {
|
|
28
|
+
this.sessions.delete(id);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async cleanup(): Promise<void> {
|
|
32
|
+
const now = new Date();
|
|
33
|
+
for (const [id, session] of this.sessions) {
|
|
34
|
+
if (session.expiresAt < now) {
|
|
35
|
+
this.sessions.delete(id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Represents an authenticated user session. */
|
|
2
|
+
export interface Session {
|
|
3
|
+
/** Unique session identifier. */
|
|
4
|
+
id: string;
|
|
5
|
+
/** ID of the user this session belongs to. */
|
|
6
|
+
userId: string;
|
|
7
|
+
/** Arbitrary data stored with the session. */
|
|
8
|
+
data: Record<string, any>;
|
|
9
|
+
/** When the session was created. */
|
|
10
|
+
createdAt: Date;
|
|
11
|
+
/** When the session expires and should be considered invalid. */
|
|
12
|
+
expiresAt: Date;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Abstract interface for session persistence.
|
|
17
|
+
* Implement this to store sessions in Redis, SQLite, etc.
|
|
18
|
+
*/
|
|
19
|
+
export interface SessionStore {
|
|
20
|
+
create(session: Session): Promise<void>;
|
|
21
|
+
get(id: string): Promise<Session | null>;
|
|
22
|
+
update(id: string, data: Record<string, any>): Promise<void>;
|
|
23
|
+
destroy(id: string): Promise<void>;
|
|
24
|
+
cleanup?(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Configuration options for JWT signing and verification. */
|
|
28
|
+
export interface JWTConfig {
|
|
29
|
+
/** HMAC-SHA256 secret key. Must be kept private. */
|
|
30
|
+
secret: string;
|
|
31
|
+
/** Token expiration duration (e.g. `"1h"`, `"7d"`, or seconds as number). */
|
|
32
|
+
expiresIn?: string | number;
|
|
33
|
+
/** Expected `iss` claim for verification. */
|
|
34
|
+
issuer?: string;
|
|
35
|
+
/** Expected `aud` claim for verification. */
|
|
36
|
+
audience?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Configuration for cookie-based sessions. */
|
|
40
|
+
export interface SessionConfig {
|
|
41
|
+
/** The session store implementation. */
|
|
42
|
+
store: SessionStore;
|
|
43
|
+
/** Session max age in seconds (default: 7 days). */
|
|
44
|
+
maxAge?: number;
|
|
45
|
+
/** Cookie name (default: `"mintil_session"`). */
|
|
46
|
+
cookieName?: string;
|
|
47
|
+
/** Cookie path (default: `"/"`). */
|
|
48
|
+
cookiePath?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Complete configuration for the auth module. */
|
|
52
|
+
export interface AuthConfig {
|
|
53
|
+
jwt: JWTConfig;
|
|
54
|
+
session?: SessionConfig;
|
|
55
|
+
}
|