create-restack-pattern 0.1.0 → 0.1.4

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 CHANGED
@@ -1,8 +1,12 @@
1
1
  # create-restack-pattern
2
2
 
3
- Scaffold a new Next.js + Hono + Drizzle ORM monorepo using the Restack Pattern
4
- (Semi-DDD / Feature-Driven) architecture — the same template built out in
5
- [the main repo](../../README.md), bundled as a copy-and-go generator.
3
+ [![npm version](https://img.shields.io/npm/v/create-restack-pattern.svg)](https://www.npmjs.com/package/create-restack-pattern)
4
+ [![npm license](https://img.shields.io/npm/l/create-restack-pattern.svg)](https://www.npmjs.com/package/create-restack-pattern)
5
+ [![node version](https://img.shields.io/node/v/create-restack-pattern.svg)](https://nodejs.org)
6
+
7
+ Skip the boilerplate — scaffold a full-stack Next.js + Hono + PostgreSQL monorepo with secure auth cookies, structured logging, and a type-safe Hono RPC client end-to-end, all wired up and ready to deploy.
8
+
9
+ This is the official initializer for the [Restack Pattern](https://github.com/NicholasChandraa/restack-pattern).
6
10
 
7
11
  ## Usage
8
12
 
@@ -10,63 +14,86 @@ Scaffold a new Next.js + Hono + Drizzle ORM monorepo using the Restack Pattern
10
14
  npx create-restack-pattern my-app
11
15
  ```
12
16
 
13
- Or without an argument, it'll prompt for a project name:
14
-
17
+ Or run it without arguments to be prompted for a project name:
15
18
  ```bash
16
19
  npx create-restack-pattern
17
20
  ```
18
21
 
19
- This will:
22
+ ### Prerequisites
23
+ - **Node.js**: version 20 or higher
24
+ - **pnpm**: package manager installed (run `npm install -g pnpm` if needed)
25
+ - **PostgreSQL**: a running database (either a local instance or a free hosted instance on [Neon](https://neon.tech))
20
26
 
21
- 1. Copy the template into `./my-app`
22
- 2. Set `my-app` as the generated project's root `package.json` name
23
- 3. Run `pnpm install`
24
- 4. Build `@restack/shared` (required once before `pnpm dev` works — see the
25
- generated project's gotcha notes)
26
- 5. Run `git init`
27
+ ---
27
28
 
28
- ## Prerequisites
29
+ ## Quick Start (Next Steps)
29
30
 
30
- - Node.js 20+
31
- - [pnpm](https://pnpm.io) — the generated project is a pnpm workspace monorepo
32
-
33
- ## After scaffolding
31
+ Once the CLI finishes scaffolding your project, follow these three simple steps to get started:
34
32
 
33
+ ### 1. Configure Environment Variables
35
34
  ```bash
36
35
  cd my-app
37
- cp backend/.env.example backend/.env # fill in DATABASE_URL and JWT_SECRET
38
- pnpm --filter backend dev # http://localhost:3001
39
- pnpm --filter frontend dev # http://localhost:3000
36
+ cp backend/.env.example backend/.env
37
+ cp frontend/.env.example frontend/.env
40
38
  ```
39
+ - Open `backend/.env` and set:
40
+ - `DATABASE_URL` (format: `postgresql://user:password@localhost:5432/dbname`; you can use a local Postgres database or a free hosted instance on [Neon](https://neon.tech))
41
+ - `JWT_SECRET` (generate a secure 32-character key by running: `openssl rand -base64 32`)
42
+ - *(Optional)* Open `frontend/.env` to override `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:3001` for local Hono API).
41
43
 
42
- ## What you get
43
-
44
- - `frontend/` — Next.js 16 (App Router)
45
- - `backend/` — Hono + Drizzle ORM, with a working JWT + refresh-token auth
46
- flow, rate limiting, and an example `user` domain already wired up
47
- - `packages/shared/` — Zod schemas shared between frontend and backend
48
-
49
- ## Current scope (MVP)
50
-
51
- This is the first, deliberately minimal version of the CLI. It does not yet
52
- ask about package manager, database, or auth-provider choices — it always
53
- scaffolds the exact pnpm + Postgres + Drizzle stack from the template as-is.
54
- Those choices are tracked as follow-up work in the main repo's
55
- `docs/concerns.md`.
56
-
57
- ## For maintainers: keeping the template in sync
58
-
59
- The `template/` directory in this package is a **copy** of `frontend/`,
60
- `backend/`, and `packages/shared/` from the repo root, not a live reference —
61
- and it's gitignored, same as `dist/`. It's regenerated automatically before
62
- publish (`prepublishOnly` runs `sync-template` then `build`), so it never
63
- needs to be committed. For local testing, run it manually first:
64
-
44
+ ### 2. Push Database Tables
45
+ This creates the required `users` and `auth_sessions` tables in your database:
65
46
  ```bash
66
- pnpm --filter create-restack-pattern sync-template
47
+ pnpm --filter backend db:push
67
48
  ```
68
49
 
69
- This overwrites `template/` from the current state of the root template
70
- (excluding `node_modules`, build output, `.env*` except `.env.example`, and
71
- this repo's own internal planning docs like `docs/` and root `CLAUDE.md`,
72
- which aren't relevant to a generated project).
50
+ ### 3. Start Development Servers
51
+ ```bash
52
+ pnpm dev
53
+ ```
54
+ - **Backend API**: runs at [http://localhost:3001](http://localhost:3001)
55
+ - **Frontend App**: runs at [http://localhost:3000](http://localhost:3000)
56
+
57
+ ---
58
+
59
+ ## What You Get
60
+
61
+ - **`frontend/`** — Next.js 16 (App Router)
62
+ - **`backend/`** — Hono + Drizzle ORM, with a working JWT + refresh-token auth flow, production-ready structured correlation logging (Pino + Hono request tracing), and an example `user` domain already wired up.
63
+ - **`packages/shared/`** — Zod schemas shared between frontend and backend.
64
+
65
+ For a full breakdown of the folder structure, architecture rules (Semi-DDD), dual-cookie auth implementation, and deployment verification checklists, please visit the [GitHub repository](https://github.com/NicholasChandraa/restack-pattern).
66
+
67
+ ---
68
+
69
+ ## For Maintainers (Local Dev & Publishing)
70
+
71
+ The `template/` directory in this package is a **copy** of `frontend/`, `backend/`, and `packages/shared/` from the repo root, not a live reference — and it's gitignored.
72
+
73
+ Before testing your CLI changes locally or publishing, follow the standard workflow:
74
+
75
+ 1. **Sync template**:
76
+ ```bash
77
+ pnpm --filter create-restack-pattern sync-template
78
+ ```
79
+ 2. **Compile the CLI script**:
80
+ ```bash
81
+ pnpm --filter create-restack-pattern build
82
+ ```
83
+ 3. **Verify the published files**:
84
+ ```bash
85
+ npm pack --dry-run
86
+ ```
87
+ 4. **Scaffold locally to test**:
88
+ ```bash
89
+ node dist/index.js test-app
90
+ ```
91
+ 5. **Publish to npm**:
92
+ Bump the `"version"` in `package.json`, then:
93
+ ```bash
94
+ npm publish
95
+ ```
96
+
97
+ For detailed guides, workflow explanations, and lessons learned:
98
+ - Check out the [Development Guide](../../docs/en/development-guide.md) (Indonesian: [Panduan Kontribusi](../../docs/id/development-guide.md))
99
+ - Check out the [Building a Scaffolding CLI Tutorial](../../docs/tutorial.md)
package/dist/index.js CHANGED
@@ -26,7 +26,24 @@ function renameGitignoreFilesBack(dir) {
26
26
  renameSync(full, path.join(dir, ".gitignore"));
27
27
  }
28
28
  }
29
+ function isPnpmInstalled() {
30
+ try {
31
+ execSync("pnpm --version", { stdio: "ignore" });
32
+ return true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
29
38
  async function main() {
39
+ if (!isPnpmInstalled()) {
40
+ console.error("\n\x1b[31mError: pnpm is not installed.\x1b[0m");
41
+ console.error("This template is configured as a pnpm workspace monorepo and requires pnpm to run.");
42
+ console.error("Please install pnpm before continuing:");
43
+ console.error(" npm install -g pnpm");
44
+ console.error("Or visit https://pnpm.io for more installation options.\n");
45
+ process.exit(1);
46
+ }
30
47
  const argName = process.argv[2];
31
48
  const projectName = argName ?? (await promptProjectName());
32
49
  if (!projectName || !isValidProjectName(projectName)) {
@@ -59,12 +76,32 @@ async function main() {
59
76
  // git not available — non-fatal, scaffolding still succeeded
60
77
  }
61
78
  console.log(`
62
- Done! Next steps:
79
+ \x1b[32m✔ Scaffolded project successfully!\x1b[0m
80
+
81
+ Next steps to get started:
82
+
83
+ 1. Go to your project directory:
84
+ \x1b[36mcd ${projectName}\x1b[0m
85
+
86
+ 2. Configure environment variables:
87
+ • For Backend:
88
+ \x1b[36mcp backend/.env.example backend/.env\x1b[0m
89
+ (Open backend/.env and set \x1b[33mDATABASE_URL\x1b[0m and a min-32-chars \x1b[33mJWT_SECRET\x1b[0m)
90
+
91
+ • For Frontend:
92
+ \x1b[36mcp frontend/.env.example frontend/.env\x1b[0m
93
+
94
+ 3. Push database tables (creates users & auth_sessions tables):
95
+ \x1b[36mpnpm --filter backend db:push\x1b[0m
96
+
97
+ 4. Start development servers:
98
+ • Backend (running at http://localhost:3001):
99
+ \x1b[36mpnpm --filter backend dev\x1b[0m
100
+
101
+ • Frontend (running at http://localhost:3000):
102
+ \x1b[36mpnpm --filter frontend dev\x1b[0m
63
103
 
64
- cd ${projectName}
65
- cp backend/.env.example backend/.env # fill in DATABASE_URL and JWT_SECRET
66
- pnpm --filter backend dev
67
- pnpm --filter frontend dev
104
+ Enjoy building your app with the Restack Pattern!
68
105
  `);
69
106
  }
70
107
  main().catch((err) => {
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "create-restack-pattern",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
4
4
  "description": "Scaffold a new Next.js + Hono + Drizzle monorepo using the Restack Pattern (Semi-DDD) template.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "private": false,
8
8
  "bin": {
9
- "create-restack-pattern": "./dist/index.js"
9
+ "create-restack-pattern": "dist/index.js"
10
10
  },
11
11
  "files": [
12
12
  "dist",
@@ -22,5 +22,8 @@
22
22
  "@types/node": "^20.11.17",
23
23
  "tsx": "^4.7.1",
24
24
  "typescript": "^5.8.3"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
25
28
  }
26
29
  }
@@ -0,0 +1,62 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides architecture, build, and styling guidance to Claude Code (claude.ai/code) and other AI assistants working in this repository.
4
+
5
+ ## Project Stack
6
+ - **Frontend**: Next.js 16 (App Router)
7
+ - **Backend**: Hono
8
+ - **ORM**: Drizzle ORM
9
+ - **Database**: PostgreSQL (Postgres-js driver)
10
+ - **Validation**: Zod (shared schemas)
11
+ - **Package Manager**: pnpm workspaces
12
+
13
+ ## Monorepo Layout
14
+ - `frontend/` — Next.js 16 App Router
15
+ - `backend/` — Hono API
16
+ - `packages/shared/` — Shared Zod schemas (the single source of truth)
17
+
18
+ ## Build and Dev Commands
19
+ All commands should be run from the repository root:
20
+ - `pnpm install` — Install all dependencies
21
+ - `pnpm --filter @restack/shared build` — Build shared Zod schemas (run first!)
22
+ - `pnpm --filter backend dev` — Start Hono dev server on port 3001
23
+ - `pnpm --filter frontend dev` — Start Next.js dev server on port 3000
24
+ - `pnpm --filter backend db:push` — Push Drizzle schema to PostgreSQL database
25
+ - `pnpm --filter backend db:studio` — Open Drizzle Studio database viewer
26
+ - `pnpm --filter backend test` — Run Vitest tests
27
+
28
+ ## Architecture Rules: Semi-DDD (Feature-Driven)
29
+ Code is organized by business domain/feature, not by file type.
30
+
31
+ ### Backend Layout (`backend/src/domains/[feature]/`)
32
+ Each domain has 4 primary files:
33
+ - `[feature].routes.ts` — HTTP endpoints & input validation (imports Zod schema from `@restack/shared`)
34
+ - `[feature].service.ts` — Pure business logic only (no direct DB access)
35
+ - `[feature].repo.ts` — Drizzle queries only (no business logic)
36
+ - `[feature].model.ts` — Drizzle table definitions
37
+
38
+ **Cross-Domain Dependency Rule**: The `auth` domain may import from `user` (e.g. `usersTable` from `user.model.ts`). The reverse (user importing from auth) is STRICTLY FORBIDDEN.
39
+
40
+ ### Frontend Layout (`frontend/domains/[feature]/`)
41
+ - `components/` — UI components (no direct data fetching)
42
+ - `hooks/` — Reactive state & data fetching logic
43
+ - `services/` — Thin HTTP wrappers around `apiClient`
44
+ - `types.ts` — Local TS type declarations
45
+ - `frontend/app/` is strictly for layout, routing config, metadata, or entry gates. Do not implement custom JSX layouts, forms, state, or complex components here. Always delegate rendering to domain feature components.
46
+
47
+ ## Critical Gotcha: Shared Package Build Requirement
48
+ `@restack/shared`'s `package.json` resolves to `dist/`, which is gitignored. When editing Zod schemas in `packages/shared/src/schemas/`, you MUST compile them by running:
49
+ `pnpm --filter @restack/shared build`
50
+ (or keep `pnpm --filter @restack/shared dev` watch mode running in a terminal) or the frontend and backend will fail to resolve the updated types or throw compilation errors.
51
+
52
+ ## Auth & Cookie Strategy
53
+ - Short-lived JWT `access_token` (15m) and opaque `refresh_token` (30d) are both set as `httpOnly` cookies at `Path=/`.
54
+ - Access tokens are automatically refreshed by the client-side `apiClient` on a 401 response.
55
+ - Cookie configuration: `SameSite=None` in production (requires `Secure=true`), and `SameSite=Lax` in development (`Secure=false` since local dev runs on HTTP).
56
+ - There is no server-side auth gate (`middleware.ts`/`proxy.ts`) because cookies cannot be read server-side when frontend and backend deploy to unrelated domains (Topology B). Authenticated routes are protected via backend `requireAuth` middleware, and frontend pages are protected via `<RequireAuth>` or client-side checks in hooks.
57
+
58
+ ## Structured & Correlation Logging
59
+ - Every incoming HTTP request is tagged with a unique `requestId` via Hono's `requestId` middleware.
60
+ - A request-scoped child logger (`logger.child({ requestId })`) is bound to Hono's context.
61
+ - In route handlers, always extract the request-specific child logger from Hono's context (`c.get("logger") || logger`) to log events. This ensures all logs generated during a request carry the correct `requestId` and `userId` context (which is automatically enriched by the `requireAuth` middleware).
62
+
@@ -5,4 +5,5 @@ PORT=3001
5
5
  FRONTEND_URL=http://localhost:3000,https://myapp.vercel.app
6
6
  # COOKIE_SAME_SITE=Lax # override default (None in production, Lax in development)
7
7
  # COOKIE_DOMAIN=.example.com # only set if FE/BE share a domain, e.g. app.example.com + api.example.com
8
- # # see docs/deployment-verification.md before deploying cross-domain
8
+ # # see docs/deployment-verification.md before deploying cross-domain
9
+ # LOG_FILE_PATH=logs/app.log # Optional log file path (outputs to stdout and writes to this file)
@@ -1,8 +1,49 @@
1
+ # RestackPattern - Backend API (Hono)
2
+
3
+ This is the Hono backend API for the RestackPattern monorepo. It features structured, context-scoped correlation logging (Pino), database interaction via Drizzle ORM, and a secure dual-cookie JWT refresh token authentication flow.
4
+
5
+ ## Directory Structure (Semi-DDD)
6
+
7
+ Code is structured by business domain/feature:
8
+
1
9
  ```
2
- npm install
3
- npm run dev
10
+ src/
11
+ ├── config/ # Environment variables validation (env.ts)
12
+ ├── shared/ # Reusable utilities across domains
13
+ │ ├── db/ # Drizzle connection client
14
+ │ ├── logger/ # Pino logger instance and type definitions
15
+ │ └── middlewares/ # requireAuth, etc.
16
+ └── domains/
17
+ ├── auth/ # Auth routes, service, repo, and model
18
+ └── user/ # User routes, service, repo, and model
4
19
  ```
5
20
 
21
+ ### Domain Architecture Layer Rules:
22
+ - `*.routes.ts` - Handles HTTP requests, validates input schemas (from `@restack/shared`), calls service logic, and returns JSON.
23
+ - `*.service.ts` - Houses core business workflows, password hashing, and token issuance. Does not write SQL or run DB queries directly.
24
+ - `*.repo.ts` - Houses Drizzle DB query executions. No business logic.
25
+ - `*.model.ts` - Defines Drizzle database tables.
26
+
27
+ ---
28
+
29
+ ## Development
30
+
31
+ Prerequisites: A running PostgreSQL database (e.g. [Neon](https://neon.tech) or a local instance).
32
+
33
+ ```bash
34
+ # 1. Create env file and configure DATABASE_URL and JWT_SECRET
35
+ cp .env.example .env
36
+
37
+ # 2. Push schema to database
38
+ pnpm db:push
39
+
40
+ # 3. Start development watcher
41
+ pnpm dev
6
42
  ```
7
- open http://localhost:3000
8
- ```
43
+
44
+ ### Core Commands (Run from backend folder)
45
+ - `pnpm dev` - Starts development server at `http://localhost:3001` (uses tsx watch)
46
+ - `pnpm db:push` - Pushes Drizzle schemas to PostgreSQL database
47
+ - `pnpm db:generate` - Generates Drizzle SQL migrations
48
+ - `pnpm db:studio` - Starts Drizzle Studio GUI database viewer
49
+ - `pnpm test` - Runs unit and integration test suites using Vitest
@@ -26,6 +26,7 @@ lerna-debug.log*
26
26
 
27
27
  # misc
28
28
  .DS_Store
29
-
30
- .vercel
31
- .env*
29
+
30
+ .vercel
31
+ .env*
32
+ !.env.example
@@ -30,5 +30,8 @@
30
30
  "tsx": "^4.7.1",
31
31
  "typescript": "^5.8.3",
32
32
  "vitest": "^3.0.0"
33
+ },
34
+ "engines": {
35
+ "node": ">=20"
33
36
  }
34
37
  }
@@ -17,6 +17,7 @@ const envSchema = z.object({
17
17
  // the frontend's own middleware read the session cookies too.
18
18
  COOKIE_DOMAIN: z.string().optional(),
19
19
  FRONTEND_URL: z.string().default("http://localhost:3000"),
20
+ LOG_FILE_PATH: z.string().optional(),
20
21
  })
21
22
 
22
23
  export const env = envSchema.parse(process.env)
@@ -5,8 +5,7 @@ import { zValidator } from "@hono/zod-validator"
5
5
  import { LoginSchema, RegisterSchema } from "@restack/shared"
6
6
  import { authService, ACCESS_TOKEN_TTL_SECONDS, REFRESH_TOKEN_TTL_SECONDS } from "./auth.service.js"
7
7
  import { env } from "../../config/env.js"
8
- import { logger } from "../../shared/logger/logger.js"
9
- import { rateLimit } from "../../shared/middlewares/rate-limit.js"
8
+ import { logger, type AppVariables } from "../../shared/logger/logger.js"
10
9
 
11
10
  /**
12
11
  * Routing layer for the Auth domain.
@@ -37,57 +36,59 @@ const clearAuthCookies = (c: Context) => {
37
36
  deleteCookie(c, "refresh_token", deleteCookieOpts("/"))
38
37
  }
39
38
 
40
- export const authRoutes = new Hono()
39
+ export const authRoutes = new Hono<{ Variables: AppVariables }>()
40
+ .post("/register", zValidator("json", RegisterSchema), async (c) => {
41
+ const reqLogger = c.get("logger") || logger
42
+ try {
43
+ reqLogger.info("Starting user registration")
44
+ const body = c.req.valid("json")
45
+ const user = await authService.register(body)
46
+ const tokens = await authService.issueTokenPair(user)
47
+
48
+ setAuthCookies(c, tokens)
49
+
50
+ return c.json({ user }, 201)
51
+ } catch (e: any) {
52
+ reqLogger.error({ err: e.message }, "Register failed")
53
+ return c.json({ error: e.message }, 400)
54
+ }
55
+ })
56
+ .post("/login", zValidator("json", LoginSchema), async (c) => {
57
+ const reqLogger = c.get("logger") || logger
58
+ try {
59
+ reqLogger.info("Attempting user login")
60
+ const body = c.req.valid("json")
61
+ const user = await authService.login(body)
62
+ const tokens = await authService.issueTokenPair(user)
63
+
64
+ setAuthCookies(c, tokens)
65
+
66
+ return c.json({ user })
67
+ } catch (e: any) {
68
+ reqLogger.error({ err: e.message }, "Login failed")
69
+ return c.json({ error: e.message }, 401)
70
+ }
71
+ })
72
+ .post("/refresh", async (c) => {
73
+ const reqLogger = c.get("logger") || logger
74
+ const refreshToken = getCookie(c, "refresh_token")
75
+ if (!refreshToken) return c.json({ error: "Missing refresh token" }, 401)
76
+
77
+ try {
78
+ reqLogger.info("Attempting token rotation")
79
+ const tokens = await authService.refresh(refreshToken)
80
+ setAuthCookies(c, tokens)
81
+ return c.json({ user: tokens.user })
82
+ } catch (e: any) {
83
+ clearAuthCookies(c)
84
+ reqLogger.error({ err: e.message }, "Refresh failed")
85
+ return c.json({ error: "Invalid refresh token" }, 401)
86
+ }
87
+ })
88
+ .post("/logout", async (c) => {
89
+ const refreshToken = getCookie(c, "refresh_token")
90
+ if (refreshToken) await authService.revokeSession(refreshToken)
41
91
 
42
- authRoutes.post("/register", rateLimit, zValidator("json", RegisterSchema), async (c) => {
43
- try {
44
- const body = c.req.valid("json")
45
- const user = await authService.register(body)
46
- const tokens = await authService.issueTokenPair(user)
47
-
48
- setAuthCookies(c, tokens)
49
-
50
- return c.json({ user }, 201)
51
- } catch (e: any) {
52
- logger.error({ err: e.message }, "Register failed")
53
- return c.json({ error: e.message }, 400)
54
- }
55
- })
56
-
57
- authRoutes.post("/login", rateLimit, zValidator("json", LoginSchema), async (c) => {
58
- try {
59
- const body = c.req.valid("json")
60
- const user = await authService.login(body)
61
- const tokens = await authService.issueTokenPair(user)
62
-
63
- setAuthCookies(c, tokens)
64
-
65
- return c.json({ user })
66
- } catch (e: any) {
67
- logger.error({ err: e.message }, "Login failed")
68
- return c.json({ error: e.message }, 401)
69
- }
70
- })
71
-
72
- authRoutes.post("/refresh", rateLimit, async (c) => {
73
- const refreshToken = getCookie(c, "refresh_token")
74
- if (!refreshToken) return c.json({ error: "Missing refresh token" }, 401)
75
-
76
- try {
77
- const tokens = await authService.refresh(refreshToken)
78
- setAuthCookies(c, tokens)
79
- return c.json({ user: tokens.user })
80
- } catch (e: any) {
81
92
  clearAuthCookies(c)
82
- logger.error({ err: e.message }, "Refresh failed")
83
- return c.json({ error: "Invalid refresh token" }, 401)
84
- }
85
- })
86
-
87
- authRoutes.post("/logout", async (c) => {
88
- const refreshToken = getCookie(c, "refresh_token")
89
- if (refreshToken) await authService.revokeSession(refreshToken)
90
-
91
- clearAuthCookies(c)
92
- return c.json({ success: true })
93
- })
93
+ return c.json({ success: true })
94
+ })
@@ -2,11 +2,9 @@ import { Hono } from "hono"
2
2
  import type { Context } from "hono"
3
3
  import { zValidator } from "@hono/zod-validator"
4
4
  import { UpdateProfileSchema } from "@restack/shared"
5
- import type { AuthVariables } from "../../shared/middlewares/require-auth.js"
6
5
  import { requireAuth } from "../../shared/middlewares/require-auth.js"
7
6
  import { userService, UserNotFoundError } from "./user.service.js"
8
- import { rateLimit } from "../../shared/middlewares/rate-limit.js"
9
- import { logger } from "../../shared/logger/logger.js"
7
+ import { logger, type AppVariables } from "../../shared/logger/logger.js"
10
8
 
11
9
  /**
12
10
  * Routing layer for the User domain.
@@ -14,35 +12,39 @@ import { logger } from "../../shared/logger/logger.js"
14
12
  *
15
13
  * Semi-DDD rules:
16
14
  * - `.routes.ts` files define HTTP endpoints, parse inputs using schema validators (zValidator),
17
- * apply guards (requireAuth, rateLimit), and serialize JSON responses.
15
+ * apply guards (requireAuth), and serialize JSON responses.
18
16
  * - Routing code should never implement business logic directly; it must delegate to the service layer.
19
17
  */
20
- export const userRoutes = new Hono<{ Variables: AuthVariables }>()
21
-
22
- // Only UserNotFoundError is an expected, safe-to-expose outcome. Anything else (DB/driver
23
- // errors, etc.) gets logged with detail but returned to the caller as a generic 500 —
24
- // leaking raw error messages/mislabeling unexpected failures as 404 would be misleading.
25
- const handleProfileError = (c: Context, e: unknown, action: string) => {
18
+ const handleProfileError = (c: Context<{ Variables: AppVariables }>, e: unknown, action: string) => {
26
19
  if (e instanceof UserNotFoundError) return c.json({ error: e.message }, 404)
27
- logger.error({ err: e instanceof Error ? e.message : String(e) }, action)
20
+ const reqLogger = c.get("logger") || logger
21
+ reqLogger.error({ err: e instanceof Error ? e.message : String(e) }, action)
28
22
  return c.json({ error: "Internal server error" }, 500)
29
23
  }
30
24
 
31
- userRoutes.get("/me", requireAuth, async (c) => {
32
- try {
33
- const profile = await userService.getProfile(Number(c.get("user").sub))
34
- return c.json({ user: profile })
35
- } catch (e) {
36
- return handleProfileError(c, e, "Get profile failed")
37
- }
38
- })
39
-
40
- userRoutes.put("/profile", requireAuth, rateLimit, zValidator("json", UpdateProfileSchema), async (c) => {
41
- try {
42
- const body = c.req.valid("json")
43
- const profile = await userService.updateProfile(Number(c.get("user").sub), body)
44
- return c.json({ user: profile })
45
- } catch (e) {
46
- return handleProfileError(c, e, "Update profile failed")
47
- }
48
- })
25
+ export const userRoutes = new Hono<{ Variables: AppVariables }>()
26
+ .get("/me", requireAuth, async (c) => {
27
+ const reqLogger = c.get("logger") || logger
28
+ try {
29
+ reqLogger.info("Retrieving authenticated user profile")
30
+ const user = c.get("user")
31
+ if (!user) return c.json({ error: "Unauthorized" }, 401)
32
+ const profile = await userService.getProfile(Number(user.sub))
33
+ return c.json({ user: profile })
34
+ } catch (e) {
35
+ return handleProfileError(c, e, "Get profile failed")
36
+ }
37
+ })
38
+ .put("/profile", requireAuth, zValidator("json", UpdateProfileSchema), async (c) => {
39
+ const reqLogger = c.get("logger") || logger
40
+ try {
41
+ const body = c.req.valid("json")
42
+ reqLogger.info("Updating user profile")
43
+ const user = c.get("user")
44
+ if (!user) return c.json({ error: "Unauthorized" }, 401)
45
+ const profile = await userService.updateProfile(Number(user.sub), body)
46
+ return c.json({ user: profile })
47
+ } catch (e) {
48
+ return handleProfileError(c, e, "Update profile failed")
49
+ }
50
+ })
@@ -2,8 +2,9 @@ import { Hono } from "hono"
2
2
  import { cors } from "hono/cors"
3
3
  import { csrf } from "hono/csrf"
4
4
  import { logger as honoLogger } from "hono/logger"
5
+ import { requestId } from "hono/request-id"
5
6
  import { env } from "./config/env.js"
6
- import { logger } from "./shared/logger/logger.js"
7
+ import { logger, type AppVariables } from "./shared/logger/logger.js"
7
8
  import { authRoutes } from "./domains/auth/auth.routes.js"
8
9
  import { userRoutes } from "./domains/user/user.routes.js"
9
10
 
@@ -13,13 +14,37 @@ import { userRoutes } from "./domains/user/user.routes.js"
13
14
  * server (local dev, Railway/Render/VPS) or as a Vercel serverless function
14
15
  * (backend/api/index.ts), which cannot call @hono/node-server's serve().
15
16
  */
16
- export const app = new Hono()
17
+ export const app = new Hono<{ Variables: AppVariables }>()
17
18
 
18
19
  const allowedOrigins = env.FRONTEND_URL
19
20
  .split(",")
20
21
  .map((url) => url.trim())
21
22
 
22
- app.use(honoLogger((...args) => logger.info(args.join(" "))))
23
+ // 1. Generate unique request IDs for all API calls
24
+ app.use("/api/*", requestId())
25
+
26
+ // 2. Set up Pino child logger with correlation metadata per request
27
+ app.use("/api/*", async (c, next) => {
28
+ const reqId = c.get("requestId") || ""
29
+ const { method, url } = c.req
30
+
31
+ const reqLogger = logger.child({
32
+ requestId: reqId,
33
+ method,
34
+ url: new URL(url).pathname,
35
+ })
36
+
37
+ c.set("logger", reqLogger)
38
+ await next()
39
+ })
40
+
41
+ // 3. Request Logging (bridge Hono's output into our Pino logger)
42
+ app.use(
43
+ "/api/*",
44
+ honoLogger((...args) => {
45
+ logger.info(args.join(" "))
46
+ })
47
+ )
23
48
 
24
49
  app.use(
25
50
  "/api/*",
@@ -36,7 +61,11 @@ app.use(
36
61
  })
37
62
  )
38
63
 
39
- app.route("/api/auth", authRoutes)
40
- app.route("/api/user", userRoutes)
64
+ const routes = app
65
+ .route("/api/auth", authRoutes)
66
+ .route("/api/user", userRoutes)
41
67
 
42
68
  app.get("/", (c) => c.json({ status: "ok" }))
69
+
70
+ export type AppType = typeof routes
71
+
@@ -7,5 +7,5 @@ import { env } from "../../config/env.js"
7
7
  * Starts the `postgres-js` connection pool using the validated DATABASE_URL environment setting
8
8
  * and wraps it with the Drizzle ORM client wrapper.
9
9
  */
10
- const client = postgres(env.DATABASE_URL)
10
+ const client = postgres(env.DATABASE_URL, { max: 1 })
11
11
  export const db = drizzle(client)
@@ -6,10 +6,37 @@ import { env } from "../../config/env.js"
6
6
  * Configured using `pino`. Automatically toggles logging verbosity (info vs debug)
7
7
  * and formats stdout output using `pino-pretty` during local development.
8
8
  */
9
- export const logger = pino({
10
- level: env.NODE_ENV === "production" ? "info" : "debug",
11
- transport:
12
- env.NODE_ENV === "production"
13
- ? undefined
14
- : { target: "pino-pretty", options: { colorize: true } },
15
- })
9
+ const level = env.NODE_ENV === "production" ? "info" : "debug"
10
+
11
+ let loggerInstance: pino.Logger
12
+
13
+ if (env.LOG_FILE_PATH) {
14
+ // If a log file path is provided, stream to both stdout (console) and the file
15
+ const streams = [
16
+ { stream: process.stdout },
17
+ { stream: pino.destination({ dest: env.LOG_FILE_PATH, sync: false }) }
18
+ ]
19
+ loggerInstance = pino({ level }, pino.multistream(streams))
20
+ } else {
21
+ // Otherwise default to standard stdout (with pretty formatting in local development)
22
+ loggerInstance = pino({
23
+ level,
24
+ transport:
25
+ env.NODE_ENV === "production"
26
+ ? undefined
27
+ : { target: "pino-pretty", options: { colorize: true } },
28
+ })
29
+ }
30
+
31
+ export const logger = loggerInstance
32
+
33
+
34
+ import type { Logger } from "pino"
35
+ import type { AuthTokenPayload } from "../../domains/auth/auth.types.js"
36
+
37
+ export type AppVariables = {
38
+ requestId: string
39
+ logger: Logger
40
+ user?: AuthTokenPayload
41
+ }
42
+
@@ -1,19 +1,18 @@
1
1
  import type { MiddlewareHandler } from "hono"
2
2
  import { getCookie } from "hono/cookie"
3
3
  import { authService } from "../../domains/auth/auth.service.js"
4
- import type { AuthTokenPayload } from "../../domains/auth/auth.types.js"
5
-
6
- export type AuthVariables = { user: AuthTokenPayload }
4
+ import type { AppVariables } from "../logger/logger.js"
7
5
 
8
6
  /**
9
7
  * Authentication guard middleware.
10
8
  * Enforces the presence and validity of the short-lived JWT access token in request cookies.
11
- * Resolves the payload and binds it to Hono context variables (`c.get("user")`).
9
+ * Resolves the payload, binds it to Hono context variables (`c.get("user")`),
10
+ * and attaches the authenticated userId to the request logger context.
12
11
  *
13
12
  * Usage:
14
13
  * Apply to any endpoint requiring authentication (e.g. `userRoutes.get("/me", requireAuth, ...)`).
15
14
  */
16
- export const requireAuth: MiddlewareHandler<{ Variables: AuthVariables }> = async (c, next) => {
15
+ export const requireAuth: MiddlewareHandler<{ Variables: AppVariables }> = async (c, next) => {
17
16
  // Extract token from cookie (JWT)
18
17
  const token = getCookie(c, "access_token")
19
18
  if (!token) return c.json({ error: "Unauthorized" }, 401)
@@ -22,6 +21,12 @@ export const requireAuth: MiddlewareHandler<{ Variables: AuthVariables }> = asyn
22
21
  // Decode and verify signature & expiration
23
22
  const payload = await authService.verifyAccessToken(token)
24
23
  c.set("user", payload)
24
+
25
+ // Enrich the child logger with the user's ID for all downstream actions
26
+ const reqLogger = c.get("logger")
27
+ if (reqLogger) {
28
+ c.set("logger", reqLogger.child({ userId: Number(payload.sub) }))
29
+ }
25
30
  } catch {
26
31
  // Return standard 401 response on invalid / expired signature
27
32
  return c.json({ error: "Unauthorized" }, 401)
@@ -0,0 +1 @@
1
+ NEXT_PUBLIC_API_URL=http://localhost:3001
@@ -1,36 +1,41 @@
1
- This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
1
+ # RestackPattern - Frontend App (Next.js)
2
2
 
3
- ## Getting Started
3
+ This is the Next.js 16 (App Router) frontend app for the RestackPattern monorepo. It uses a Semi-DDD folder structure, vanilla CSS, and connects to Hono through its official RPC client (`hc<AppType>`), giving end-to-end type inference on every request/response without manually declaring types.
4
4
 
5
- First, run the development server:
5
+ ## Directory Structure (Semi-DDD)
6
6
 
7
- ```bash
8
- npm run dev
9
- # or
10
- yarn dev
11
- # or
12
- pnpm dev
13
- # or
14
- bun dev
15
- ```
16
-
17
- Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18
-
19
- You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
7
+ Code is structured by business domain/feature:
20
8
 
21
- This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
9
+ ```
10
+ src/
11
+ ├── app/ # Routing configs, layouts, pages (no complex JSX/state here!)
12
+ ├── shared/ # Reusable components, client wrappers, hooks
13
+ │ ├── components/ui/ # Component primitives (e.g. Shadcn atoms)
14
+ │ └── lib/api-client.ts # Hono RPC client (hc<AppType>) + auto session refresh
15
+ └── domains/
16
+ ├── auth/ # Login/Register components, auth services, hooks
17
+ └── user/ # User profile UI components, hooks
18
+ ```
22
19
 
23
- ## Learn More
20
+ ### Folder Rules:
21
+ - `app/` is strictly for routing and page wrappers. Do not implement complex forms, state, or layouts directly here. Always delegate rendering to domain feature components.
22
+ - `domains/[feature]/components` - UI components specific to the business feature.
23
+ - `domains/[feature]/hooks` - Handles fetching, mutations, and local state.
24
+ - `domains/[feature]/services` - Small wrappers making API requests.
24
25
 
25
- To learn more about Next.js, take a look at the following resources:
26
+ ---
26
27
 
27
- - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
- - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
28
+ ## Development
29
29
 
30
- You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
30
+ Ensure the backend API is running at `http://localhost:3001`.
31
31
 
32
- ## Deploy on Vercel
32
+ ```bash
33
+ # 1. Optional - configure environment variables (defaults to localhost:3001)
34
+ cp .env.example .env
33
35
 
34
- The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
36
+ # 2. Start Next.js development server
37
+ pnpm dev
38
+ ```
35
39
 
36
- Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
40
+ - Dev server will run at [http://localhost:3000](http://localhost:3000).
41
+ - Runs with Turbopack enabled for lightning-fast hot reloading.
@@ -1,5 +1,5 @@
1
1
  import type { AuthUser, LoginInput, RegisterInput } from "@restack/shared"
2
- import { apiClient } from "@/shared/lib/api-client"
2
+ import { client, unwrap } from "@/shared/lib/api-client"
3
3
 
4
4
  /**
5
5
  * Service API layer for the Auth domain.
@@ -11,17 +11,11 @@ import { apiClient } from "@/shared/lib/api-client"
11
11
  */
12
12
  export const authApi = {
13
13
  register: (data: RegisterInput) =>
14
- apiClient<{ user: AuthUser }>("/api/auth/register", {
15
- method: "POST",
16
- body: JSON.stringify(data),
17
- }),
14
+ unwrap<{ user: AuthUser }>(client.api.auth.register.$post({ json: data })),
18
15
 
19
16
  login: (data: LoginInput) =>
20
- apiClient<{ user: AuthUser }>("/api/auth/login", {
21
- method: "POST",
22
- body: JSON.stringify(data),
23
- }),
17
+ unwrap<{ user: AuthUser }>(client.api.auth.login.$post({ json: data })),
24
18
 
25
19
  logout: () =>
26
- apiClient<{ success: boolean }>("/api/auth/logout", { method: "POST" }),
20
+ unwrap<{ success: boolean }>(client.api.auth.logout.$post()),
27
21
  }
@@ -1,5 +1,5 @@
1
1
  import type { AuthUser, UpdateProfileInput } from "@restack/shared"
2
- import { apiClient } from "@/shared/lib/api-client"
2
+ import { client, unwrap } from "@/shared/lib/api-client"
3
3
 
4
4
  /**
5
5
  * Service API layer for the User domain.
@@ -11,11 +11,9 @@ import { apiClient } from "@/shared/lib/api-client"
11
11
  * - Request and response structures are mapped using schemas shared from `@restack/shared`.
12
12
  */
13
13
  export const userApi = {
14
- me: () => apiClient<{ user: AuthUser }>("/api/user/me"),
14
+ me: () =>
15
+ unwrap<{ user: AuthUser }>(client.api.user.me.$get()),
15
16
 
16
17
  updateProfile: (data: UpdateProfileInput) =>
17
- apiClient<{ user: AuthUser }>("/api/user/profile", {
18
- method: "PUT",
19
- body: JSON.stringify(data),
20
- }),
18
+ unwrap<{ user: AuthUser }>(client.api.user.profile.$put({ json: data })),
21
19
  }
@@ -32,6 +32,7 @@ yarn-error.log*
32
32
 
33
33
  # env files (can opt-in for committing if needed)
34
34
  .env*
35
+ !.env.example
35
36
 
36
37
  # vercel
37
38
  .vercel
@@ -39,5 +40,5 @@ yarn-error.log*
39
40
  # typescript
40
41
  *.tsbuildinfo
41
42
  next-env.d.ts
42
-
43
- .vercel
43
+
44
+ .vercel
@@ -12,6 +12,7 @@
12
12
  "@restack/shared": "workspace:*",
13
13
  "class-variance-authority": "^0.7.1",
14
14
  "clsx": "^2.1.1",
15
+ "hono": "^4.12.27",
15
16
  "next": "16.2.9",
16
17
  "radix-ui": "^1.6.1",
17
18
  "react": "19.2.4",
@@ -31,5 +32,8 @@
31
32
  "eslint-config-next": "16.2.9",
32
33
  "tailwindcss": "^4",
33
34
  "typescript": "^5"
35
+ },
36
+ "engines": {
37
+ "node": ">=20"
34
38
  }
35
39
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect, useState } from "react"
4
4
  import { useRouter } from "next/navigation"
5
- import { apiClient } from "../lib/api-client"
5
+ import { client, unwrap } from "../lib/api-client"
6
6
 
7
7
  /**
8
8
  * Client-side auth gate for pages with no data fetch of their own to piggyback
@@ -15,11 +15,17 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
15
15
  const [checked, setChecked] = useState(false)
16
16
 
17
17
  useEffect(() => {
18
- apiClient("/api/user/me")
18
+ unwrap(client.api.user.me.$get())
19
19
  .then(() => setChecked(true))
20
20
  .catch(() => router.replace("/login"))
21
21
  }, [router])
22
22
 
23
- if (!checked) return null
23
+ if (!checked) {
24
+ return (
25
+ <div className="flex min-h-screen items-center justify-center bg-background">
26
+ <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
27
+ </div>
28
+ )
29
+ }
24
30
  return <>{children}</>
25
31
  }
@@ -1,73 +1,77 @@
1
- /**
2
- * Global HTTP client helper for frontend domains.
3
- * Wraps global fetch and configures default options such as content types,
4
- * credentials handling (sending HTTP cookies), and base API URL resolution.
5
- */
1
+ import { hc } from "hono/client"
2
+ import type { AppType } from "../../../backend/src/hono-app"
3
+
6
4
  const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"
7
5
 
8
- /**
9
- * Custom API Error wrapping utility.
10
- * Captures both the backend error message payload and the raw HTTP response status code.
11
- */
12
- class ApiError extends Error {
6
+ export class ApiError extends Error {
13
7
  constructor(message: string, public status: number) {
14
8
  super(message)
15
9
  }
16
10
  }
17
11
 
18
- interface ApiErrorBody {
19
- error?: string
20
- }
21
-
22
- async function request<T>(path: string, options?: RequestInit): Promise<T> {
23
- const res = await fetch(`${BASE_URL}${path}`, {
24
- ...options,
25
- headers: {
26
- "Content-Type": "application/json",
27
- ...options?.headers,
28
- },
29
- credentials: "include",
30
- })
31
-
32
- const contentType = res.headers.get("content-type")
33
- const data: unknown =
34
- contentType && contentType.includes("application/json")
35
- ? await res.json()
36
- : { error: (await res.text()) || "Request failed" }
37
-
38
- if (!res.ok) throw new ApiError((data as ApiErrorBody).error ?? "Request failed", res.status)
39
- return data as T
40
- }
41
-
42
- // The refresh token is single-use (backend rotates it atomically), so if several
43
- // requests 401 at once they must share one refresh call, not each fire their own —
44
- // only the first would succeed and the rest would wrongly fail as "logged out".
45
- let refreshPromise: Promise<unknown> | null = null
12
+ // Share a single refresh request promise to prevent TOCTOU race conditions
13
+ let refreshPromise: Promise<Response> | null = null
46
14
 
47
15
  function refreshSession() {
48
16
  if (!refreshPromise) {
49
- refreshPromise = request("/api/auth/refresh", { method: "POST" }).finally(() => {
17
+ refreshPromise = fetch(`${BASE_URL}/api/auth/refresh`, {
18
+ method: "POST",
19
+ credentials: "include",
20
+ }).finally(() => {
50
21
  refreshPromise = null
51
22
  })
52
23
  }
53
24
  return refreshPromise
54
25
  }
55
26
 
56
- export async function apiClient<T>(
57
- path: string,
58
- options?: RequestInit
59
- ): Promise<T> {
60
- try {
61
- return await request<T>(path, options)
62
- } catch (e) {
63
- // The access_token cookie is short-lived by design (15m). On a 401, silently
64
- // exchange the refresh_token for a new pair once, then retry the original call.
65
- // Auth routes are excluded so a failed refresh can't loop back into itself.
66
- const isAuthRoute = path.startsWith("/api/auth/")
67
- if (e instanceof ApiError && e.status === 401 && !isAuthRoute) {
68
- await refreshSession()
69
- return request<T>(path, options)
27
+ const customFetch: typeof fetch = async (input, init) => {
28
+ const newInit = {
29
+ ...init,
30
+ credentials: "include" as const,
31
+ }
32
+
33
+ const res = await fetch(input, newInit)
34
+
35
+ // Silently rotate access token on 401, unless it's the auth endpoints themselves
36
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
37
+ const isAuthRoute = url.includes("/api/auth/")
38
+
39
+ if (res.status === 401 && !isAuthRoute) {
40
+ const refreshRes = await refreshSession()
41
+ if (refreshRes.ok) {
42
+ // Retry the original request
43
+ return fetch(input, newInit)
44
+ }
45
+ }
46
+
47
+ return res
48
+ }
49
+
50
+ // Expose the Hono RPC client
51
+ export const client = hc<AppType>(BASE_URL, { fetch: customFetch })
52
+
53
+ interface ApiErrorBody {
54
+ error?: string
55
+ }
56
+
57
+ interface RpcResult {
58
+ ok: boolean
59
+ status: number
60
+ json(): Promise<unknown>
61
+ }
62
+
63
+ // Unwrap Hono RPC response helper to parse JSON or throw formatted ApiError
64
+ export async function unwrap<T>(promise: Promise<RpcResult>): Promise<T> {
65
+ const res = await promise
66
+ if (!res.ok) {
67
+ let errorMsg = "Request failed"
68
+ try {
69
+ const data = (await res.json()) as ApiErrorBody
70
+ errorMsg = data.error ?? errorMsg
71
+ } catch {
72
+ // fallback
70
73
  }
71
- throw e
74
+ throw new ApiError(errorMsg, res.status)
72
75
  }
76
+ return res.json() as Promise<T>
73
77
  }
@@ -32,4 +32,12 @@ Thumbs.db
32
32
  .idea/
33
33
 
34
34
  # Claude Code local overrides (personal, not shared with the team)
35
- .claude/settings.local.json
35
+ .claude/settings.local.json
36
+ test-project/
37
+ test-app/
38
+
39
+ # Exclude concerns and root CLAUDE.md from git
40
+ docs/concerns.md
41
+ CLAUDE.md
42
+
43
+ docs/tutorial.md
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "restack-pattern",
3
3
  "private": true,
4
+ "license": "MIT",
4
5
  "scripts": {
5
6
  "dev": "pnpm --parallel dev",
6
7
  "build": "pnpm --parallel build"
@@ -9,5 +10,8 @@
9
10
  "frontend",
10
11
  "backend",
11
12
  "packages/*"
12
- ]
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ }
13
17
  }
@@ -1,43 +0,0 @@
1
- import type { Context, MiddlewareHandler } from "hono"
2
- import { getConnInfo } from "@hono/node-server/conninfo"
3
-
4
- /**
5
- * In-Memory Fixed-Window Rate Limiting Middleware.
6
- *
7
- * Performance/Topology Note:
8
- * - This implementation stores counts in a local `Map` memory segment.
9
- * - This works perfectly for single-process deployments (like a standalone Railway node).
10
- * - If horizontal scaling (multiple instances) is introduced, this must be swapped
11
- * for a shared datastore (e.g. Redis) to ensure limits are synchronized globally.
12
- */
13
- const WINDOW_MS = 60_000
14
- const MAX_REQUESTS = 10
15
-
16
- const hits = new Map<string, { count: number; resetAt: number }>()
17
-
18
- // getConnInfo reaches into the raw Node socket, which only exists when served via a real
19
- // @hono/node-server connection — it throws in in-memory test harnesses (Hono's `.request()`)
20
- // and some serverless runtimes. Fall back to a shared bucket rather than crashing the request.
21
- const clientIp = (c: Context) => {
22
- try {
23
- return getConnInfo(c).remote.address ?? "unknown"
24
- } catch {
25
- return "unknown"
26
- }
27
- }
28
-
29
- export const rateLimit: MiddlewareHandler = async (c, next) => {
30
- const key = `${c.req.path}:${clientIp(c)}`
31
- const now = Date.now()
32
- const entry = hits.get(key)
33
-
34
- if (!entry || entry.resetAt < now) {
35
- hits.set(key, { count: 1, resetAt: now + WINDOW_MS })
36
- } else if (entry.count >= MAX_REQUESTS) {
37
- return c.json({ error: "Too many requests, try again later" }, 429)
38
- } else {
39
- entry.count++
40
- }
41
-
42
- await next()
43
- }