diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3b37b6..30eecaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,27 @@ jobs: - run: bun install --frozen-lockfile - run: bun run typecheck + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - name: Run Tests + env: + APP_NAME: ${{ secrets.APP_NAME }} + ENV: ${{ secrets.ENV }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }} + AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} + AXIOM_DATASET: ${{ secrets.AXIOM_DATASET }} + SERVICE_VERSION: ${{ secrets.SERVICE_VERSION }} + run: bun run test + build: name: Build runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..77485b3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,161 @@ +# AGENTS.md + +Guidelines for AI coding agents working in this repository. + +## Tech Stack + +- **Runtime:** Bun +- **Frontend:** React 19 + Vite + TailwindCSS v4 +- **Backend:** Hono (running on Bun) +- **Database:** PostgreSQL + Drizzle ORM +- **Auth:** Better Auth +- **Validation:** Zod +- **Linting/Formatting:** Biome +- **i18n:** i18next + +## Commands + +```bash +# Development +bun dev # Start full dev environment (DB, Vite, Drizzle Studio) +bun run build # Production build +bun start # Run production server + +# Code Quality +bun run lint # Fix lint issues (Biome + locale check) +bun run lint:check # Check only (CI) +bun run format # Fix formatting +bun run format:check # Check only (CI) +bun run typecheck # Type-check all projects +bun run all # Run format + lint + typecheck + build + +# Database +bun run db:start # Start PostgreSQL container +bun run db:push # Sync schema to DB (dev only) +bun run db:generate # Generate migrations +bun run db:migrate # Apply migrations +bun run db:regenerate-auth # Regenerate Better Auth schema +``` + +## Project Structure + +``` +web/ # Frontend (React) + components/ui/ # Reusable UI components (shadcn pattern) + lib/api.ts # Type-safe Hono RPC client + lib/auth-client.ts # Better Auth client + i18n/locales/ # Translation files + +server/ # Backend (Hono) + server.tsx # Entry point & route assembly + auth.ts # Better Auth config + logger.ts # Pino logger with trace context + middleware/ # Global middleware + features/ # Feature modules (one folder per feature) + {feature}/ + index.ts # Re-exports feature routes + routes/ # One file per route handler + database/ + schema/auth.ts # Better Auth tables (auto-generated) + schema/app.ts # Custom tables (add your tables here) + +env.ts # Environment schema (Zod) +``` + +## Code Style + +### Formatting (Biome) +- 2-space indentation, double quotes, imports auto-organized + +### TypeScript +- Strict mode enabled +- Use `type` imports: `import type { Foo } from "bar"` +- Path aliases: `@/*` (root), `~/*` (web/) + +### Naming +- Files: `kebab-case.ts` +- Components: `PascalCase` +- Variables/functions: `camelCase` +- Database tables: `snake_case` + +### React Components +- Use function declarations, not arrow functions for components +- Props type inline: `function Button({ ...props }: React.ComponentProps<"button">)` +- Use `cn()` from `~/lib/utils` for class merging + +### Hono Routes +- One route handler per file in `server/features/{feature}/routes/` +- Use Zod validation with `zValidator`: +```typescript +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; + +const schema = z.object({ name: z.string().min(1) }); + +export const myRoute = new Hono().get( + "/", + zValidator("query", schema), + async (c) => { + const { name } = c.req.valid("query"); + return c.json({ message: `Hello, ${name}!` }); + } +); +``` + +### Database (Drizzle) +- Define tables in `server/database/schema/app.ts` +- Use `drizzle-orm/pg-core` for table definitions +- Reference auth tables from `./auth.ts` for foreign keys + +### Environment Variables +- Define in `env.ts` with Zod schemas +- Access via `import env from "@/env"` +- Never hardcode secrets + +### i18n +- Add translations to `web/i18n/locales/{lang}/common.ts` +- Use `useTranslation("common")` hook +- Run `bun run check-locale` to verify all keys exist in all locales + +### Error Handling +- Use Zod for input validation +- Wrap pages in `` +- Server errors logged via `logger` from `@/server/logger` + +### Logging (Server) +```typescript +import { logger } from "@/server/logger"; +logger.info({ userId, action }, "User performed action"); +``` +Logger auto-injects traceId, spanId, userId when available. + +### Tracing (OpenTelemetry) +- HTTP requests, DB queries, and fetch calls are auto-traced +- For custom spans: `import { withSpan } from "@/server/tracing"` + +## Adding New Features + +**Backend Route:** Create `server/features/{feature}/routes/{route-name}.ts`, re-export in `index.ts`, mount in `server/server.tsx` + +**Frontend Page:** Add route to `web/router.tsx`, create component in `web/components/` + +**Database Table:** Add to `server/database/schema/app.ts`, run `bun run db:push` (dev) or `db:generate && db:migrate` (prod) + +## Better Auth + +- Config: `server/auth.ts` +- Client: `web/lib/auth-client.ts` +- After adding plugins: `bun run db:regenerate-auth` +- Session in routes: `await auth.api.getSession({ headers: c.req.raw.headers })` + +## API Client (Frontend) + +Use type-safe Hono RPC: +```typescript +import { api } from "~/lib/api"; +import { useMutation } from "@tanstack/react-query"; + +const mutation = useMutation(api["my-route"].$get.mutationOptions({})); +mutation.mutate({ query: { name: "World" } }); +``` diff --git a/README.md b/README.md index 3693b39..419395f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Banner Title + Banner Title

# Full Stack Starter @@ -71,27 +71,63 @@ This command starts: | `bun run db:start` | Start PostgreSQL container | | `bun run db:studio` | Open Drizzle Studio | | `bun run db:reset` | Reset database (destroys all data) | +| `bun run db:push` | Sync schema to DB (dev only, no migrations) | +| `bun run db:generate` | Generate migration files from schema changes | +| `bun run db:migrate` | Apply pending migrations | +| `bun run db:regenerate-auth` | Regenerate Better Auth schema | | `bun run import:staging` | Import data from staging | +**Workflow:** +- **Development:** Use `db:push` for fast iteration (no migration files) +- **Staging/Production:** Use `db:generate` + `db:migrate` (tracked, reviewable changes) + +Re-run `db:regenerate-auth` when adding Better Auth plugins or upgrading. + +**Schema files:** +- `server/database/schema/auth.ts` - Auto-generated (safe to overwrite) +- `server/database/schema/app.ts` - Your custom tables (never overwritten) + ## Project Structure ``` -├── web/ # Frontend (React) -│ ├── app.tsx # Main app component -│ ├── client.tsx # Client entry point -│ ├── router.tsx # Route definitions -│ ├── components/ # UI components -│ ├── i18n/ # Internationalization -│ └── styles.css # Global styles -├── server/ # Backend (Hono) -│ ├── server.tsx # Server entry point -│ ├── logger.ts # Pino logger with trace context -│ └── db/ # Database schema & config -├── lib/ # Shared utilities -│ └── tracing.ts # OpenTelemetry helpers -├── docs/ # Documentation -├── scripts/ # Utility scripts -└── env.ts # Environment schema (Zod) +├── web/ # Frontend (React) +│ ├── app.tsx # Main app component +│ ├── client.tsx # Client entry point +│ ├── router.tsx # Route definitions +│ ├── components/ # UI components +│ ├── lib/ +│ │ ├── api.ts # Hono RPC client (type-safe API calls) +│ │ └── auth-client.ts # Better Auth client +│ ├── i18n/ # Internationalization +│ └── styles.css # Global styles +├── server/ # Backend (Hono) +│ ├── server.ts # Server entry point & route assembly +│ ├── lib/ # Shared utilities +│ │ ├── auth.ts # Better Auth configuration +│ │ ├── logger.ts # Pino logger with trace context +│ │ ├── router.ts # Typed Hono router factory +│ │ ├── tracing.ts # OpenTelemetry tracing helpers +│ │ ├── request-context.ts # AsyncLocalStorage request context +│ │ └── instrumentation.ts # Node SDK setup +│ ├── middleware/ # Hono middleware +│ │ ├── auth.middleware.ts # User context middleware +│ │ └── db.middleware.ts # Database middleware +│ ├── features/ # Feature modules +│ │ ├── auth/ # Auth routes (Better Auth handler) +│ │ ├── health/ # Health check +│ │ └── demo/ # Demo routes +│ │ └── routes/ # Route handlers (one file per route) +│ └── database/ +│ ├── index.ts # Drizzle connection +│ └── schema/ +│ ├── auth.ts # Better Auth tables (auto-generated) +│ ├── app.ts # Your custom tables +│ └── index.ts # Re-exports all tables +├── shared/ # Shared utilities +│ └── tracing.ts # OpenTelemetry helpers +├── docs/ # Documentation +├── scripts/ # Utility scripts +└── env.ts # Environment schema (Zod) ``` ## CI @@ -110,3 +146,8 @@ GitHub Actions runs on every push and PR to `master`: | [OpenTelemetry Guide](./docs/otel-guide.md) | How to add tracing to your code | | [OpenTelemetry Architecture](./docs/otel-architecture.md) | Why the setup is structured this way | | [Capacitor Guide](./docs/capacitor.md) | Building native iOS/Android apps | + +## TODO + +- [ ] DB sync from staging (implement `scripts/import-staging.sh`) +- [ ] Sentry frontend integration diff --git a/bun.lock b/bun.lock index 8c4bc59..b5c50aa 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,9 @@ "name": "setup", "dependencies": { "@hono/otel": "^1.1.0", + "@hono/zod-validator": "^0.7.6", + "@kubiks/otel-better-auth": "^2.0.2", + "@kubiks/otel-drizzle": "^2.1.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.67.3", "@opentelemetry/context-zone": "^2.3.0", @@ -25,12 +28,14 @@ "@sentry/bun": "^10.32.1", "@t3-oss/env-core": "^0.13.10", "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-query": "^5.90.19", "better-auth": "^1.4.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-kit": "^0.31.8", "drizzle-orm": "^0.45.1", "hono": "^4.11.3", + "hono-rpc-query": "^1.5.0", "i18next": "^25.7.4", "i18next-browser-languagedetector": "^8.2.0", "lucide-react": "^0.562.0", @@ -45,6 +50,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.11", + "@electric-sql/pglite": "^0.3.15", "@hono/vite-dev-server": "^0.24.0", "@types/bun": "latest", "@types/pg": "^8.16.0", @@ -56,7 +62,7 @@ "tw-animate-css": "^1.4.0", "vite": "^7.3.0", "vite-react-ssg": "^0.8.9", - "wait-on": "^8.0.0", + "vitest": "^4.0.18", }, "peerDependencies": { "typescript": "^5", @@ -148,6 +154,8 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], @@ -208,24 +216,14 @@ "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], - "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], - - "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="], - - "@hapi/hoek": ["@hapi/hoek@11.0.7", "", {}, "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ=="], - - "@hapi/pinpoint": ["@hapi/pinpoint@2.0.1", "", {}, "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q=="], - - "@hapi/tlds": ["@hapi/tlds@1.1.4", "", {}, "sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA=="], - - "@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="], - "@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="], "@hono/otel": ["@hono/otel@1.1.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.28.0" }, "peerDependencies": { "hono": ">=4.0.0" } }, "sha512-3lXExGP+odVTF3W1kTHgRGw4d4xdiYpeRs8dnTwfnHfw5uGEXgUzmkB4/ZQd3tDxYRt7eUhnWuBk5ChV97eqkA=="], "@hono/vite-dev-server": ["@hono/vite-dev-server@0.24.0", "", { "dependencies": { "@hono/node-server": "^1.14.2", "minimatch": "^9.0.3" }, "peerDependencies": { "hono": "*", "miniflare": "*", "wrangler": "*" }, "optionalPeers": ["miniflare", "wrangler"] }, "sha512-yV+DHE9suDPPvZW/o4VhY2KFl0vrIPvt0zDlj22V6wkTsEKy7TGpAd4tw0swOhN3Zfb7mXch7QAGEtO9c6qrMQ=="], + "@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -238,6 +236,10 @@ "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + "@kubiks/otel-better-auth": ["@kubiks/otel-better-auth@2.0.2", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "better-auth": ">=0.1.0" } }, "sha512-xNRTlAu/y1X/9ff9z6ehAZ9txIeOH5u4mYBGSqEuFyUNH/K3KVEIOZqAkUYPAyY057LJAdXaM9abZyBsPWZBOw=="], + + "@kubiks/otel-drizzle": ["@kubiks/otel-drizzle@2.1.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "drizzle-orm": ">=0.28.0" } }, "sha512-9UHb0od3jwa6zTWMyEYPIZcUq5PDaziCmQLMLakSK2zeqy12SFZ3SAGWXJTgEr8valn/Wa+DKVs+Z3aqKQUpvg=="], + "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], @@ -530,6 +532,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.19", "", {}, "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.90.19", "", { "dependencies": { "@tanstack/query-core": "5.90.19" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ=="], + "@types/aws-lambda": ["@types/aws-lambda@8.10.159", "", {}, "sha512-SAP22WSGNN12OQ8PlCzGzRCZ7QDCwI85dQZbmpz7+mAk+L7j+wI7qnvmdKh+o7A5LaOp6QnOZ2NJphAZQTTHQg=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -544,8 +550,12 @@ "@types/bunyan": ["@types/bunyan@1.8.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/memcached": ["@types/memcached@2.2.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg=="], @@ -568,6 +578,20 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], + "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="], + + "@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="], + + "@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], + + "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], @@ -578,12 +602,12 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], - "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], @@ -606,6 +630,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001762", "", {}, "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], @@ -660,6 +686,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -670,14 +698,16 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], @@ -718,6 +748,8 @@ "hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="], + "hono-rpc-query": ["hono-rpc-query@1.5.0", "", { "peerDependencies": { "@tanstack/react-query": "^5.75.5", "hono": "^4.7.8" } }, "sha512-shbnJj1Q5FcKXbQQChM/jVVda0fByMqZA6+sYPVnJEq3qQKXQLOhcbpPpkQIURhKOMV2aMZxR/GsmwtcLe8Bwg=="], + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], @@ -746,8 +778,6 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "joi": ["joi@18.0.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.0.0" } }, "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA=="], - "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -790,8 +820,6 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], - "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -812,8 +840,6 @@ "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -830,6 +856,8 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], "p-queue": ["p-queue@8.1.1", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^6.1.2" } }, "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ=="], @@ -838,6 +866,8 @@ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="], "pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="], @@ -880,8 +910,6 @@ "protobufjs": ["protobufjs@8.0.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -942,6 +970,8 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -952,6 +982,10 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -968,8 +1002,14 @@ "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], @@ -998,12 +1038,12 @@ "vite-react-ssg": ["vite-react-ssg@0.8.9", "", { "dependencies": { "fs-extra": "^11.3.0", "html5parser": "^2.0.2", "jsdom": "^24.1.3", "kolorist": "^1.8.0", "p-queue": "^8.1.0", "react-helmet-async": "^1.3.0", "yargs": "^17.7.2" }, "peerDependencies": { "beasties": "^0.1.0", "critters": "^0.0.24", "prettier": "*", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-router-dom": "^6.14.1", "styled-components": "^6.0.0", "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["beasties", "critters", "prettier", "react-router-dom", "styled-components"], "bin": { "vite-react-ssg": "bin/vite-react-ssg.js" } }, "sha512-IzmIHLvPBYo2BQZe0hvGgm716BzzKm0bQkRcpXGGQ2RvdfwLbNAhrNIGgjVd4CAubPWXgYHzP5BAaI8sUmFrag=="], + "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], - "wait-on": ["wait-on@8.0.5", "", { "dependencies": { "axios": "^1.12.1", "joi": "^18.0.1", "lodash": "^4.17.21", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag=="], - "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], @@ -1012,6 +1052,8 @@ "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f71902e..e36ecd3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -8,7 +8,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} ports: - - "5432:5432" + - "${POSTGRES_PORT:-5432}:5432" volumes: - ./db:/var/lib/postgresql/data healthcheck: diff --git a/docs/capacitor.svg b/docs/assets/capacitor.svg similarity index 100% rename from docs/capacitor.svg rename to docs/assets/capacitor.svg diff --git a/title.svg b/docs/assets/title.svg similarity index 100% rename from title.svg rename to docs/assets/title.svg diff --git a/docs/capacitor.md b/docs/capacitor.md index 6b3420d..6c204ab 100644 --- a/docs/capacitor.md +++ b/docs/capacitor.md @@ -1,5 +1,5 @@

- Capacitor Guide + Capacitor Guide

A comprehensive guide for adding [Capacitor](https://capacitorjs.com/) to this project to build native iOS and Android apps from your web codebase. diff --git a/docs/otel-architecture.md b/docs/otel-architecture.md index 3b76022..17e433c 100644 --- a/docs/otel-architecture.md +++ b/docs/otel-architecture.md @@ -79,7 +79,7 @@ The instrumentation import **must** be first so it can patch modules before they // IMPORTANT: Instrumentation must be first import "@/server/instrumentation"; -import { db } from "@/server/db"; // pg is patched before this loads +import { db } from "@/server/database"; // pg is patched before this loads ``` We've disabled biome's import organizer for this file to preserve the order. diff --git a/drizzle.config.ts b/drizzle.config.ts index a5d6305..fbae262 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -7,8 +7,8 @@ if (!url) { } export default defineConfig({ - schema: "./server/db/schema.ts", - out: "./server/db/migrations", + schema: "./server/database/schema", + out: "./server/database/migrations", dialect: "postgresql", dbCredentials: { url, diff --git a/env.ts b/env.ts index 4500a4d..f13686f 100644 --- a/env.ts +++ b/env.ts @@ -1,6 +1,12 @@ import { createEnv } from "@t3-oss/env-core"; import { z } from "zod"; +// Use Bun.env if available, otherwise fall back to process.env (for CLI tools like better-auth) +const runtimeEnv = + typeof Bun !== "undefined" + ? Bun.env + : (process.env as Record); + const env = createEnv({ server: { APP_NAME: z.string().min(1), @@ -15,24 +21,25 @@ const env = createEnv({ AXIOM_TOKEN: z.string().min(1).optional(), AXIOM_DATASET: z.string().min(1).optional(), OTEL_SERVICE_NAME: z.string().default("server"), - // Set via CI/CD: SERVICE_VERSION=$(git rev-parse --short HEAD) SERVICE_VERSION: z.string().default("dev"), + AUTH_COOKIE_MAX_AGE_SECONDS: z.coerce.number().default(60 * 60 * 24 * 7), // 7 days }, // TODO: client secrets runtimeEnvStrict: { - APP_NAME: Bun.env.APP_NAME, - ENV: Bun.env.ENV, - LOG_LEVEL: Bun.env.LOG_LEVEL, - SENTRY_DSN: Bun.env.SENTRY_DSN, - DATABASE_URL: Bun.env.DATABASE_URL, - BETTER_AUTH_SECRET: Bun.env.BETTER_AUTH_SECRET, - BETTER_AUTH_URL: Bun.env.BETTER_AUTH_URL, - AXIOM_TOKEN: Bun.env.AXIOM_TOKEN, - AXIOM_DATASET: Bun.env.AXIOM_DATASET, - OTEL_SERVICE_NAME: Bun.env.OTEL_SERVICE_NAME, - SERVICE_VERSION: Bun.env.SERVICE_VERSION, + APP_NAME: runtimeEnv.APP_NAME, + ENV: runtimeEnv.ENV, + LOG_LEVEL: runtimeEnv.LOG_LEVEL, + SENTRY_DSN: runtimeEnv.SENTRY_DSN, + DATABASE_URL: runtimeEnv.DATABASE_URL, + BETTER_AUTH_SECRET: runtimeEnv.BETTER_AUTH_SECRET, + BETTER_AUTH_URL: runtimeEnv.BETTER_AUTH_URL, + AXIOM_TOKEN: runtimeEnv.AXIOM_TOKEN, + AXIOM_DATASET: runtimeEnv.AXIOM_DATASET, + OTEL_SERVICE_NAME: runtimeEnv.OTEL_SERVICE_NAME, + SERVICE_VERSION: runtimeEnv.SERVICE_VERSION, + AUTH_COOKIE_MAX_AGE_SECONDS: runtimeEnv.AUTH_COOKIE_MAX_AGE_SECONDS, }, }); export default env; diff --git a/flake.nix b/flake.nix index 723a7ae..0fc2ea3 100644 --- a/flake.nix +++ b/flake.nix @@ -3,26 +3,30 @@ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { - nixpkgs, - flake-utils, - ... - }: + outputs = + { + nixpkgs, + flake-utils, + ... + }: flake-utils.lib.eachDefaultSystem ( - system: let + system: + let pkgs = import nixpkgs { inherit system; }; nativeBuildInputs = with pkgs; [ bun infisical + railway ]; - buildInputs = []; + buildInputs = [ ]; in - with pkgs; { - devShells.default = mkShell { - inherit buildInputs nativeBuildInputs; - }; - } + with pkgs; + { + devShells.default = mkShell { + inherit buildInputs nativeBuildInputs; + }; + } ); } diff --git a/package.json b/package.json index bf14fea..4089b3a 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,19 @@ "db:studio": "drizzle-kit studio", "db:start": "docker compose -f docker-compose.dev.yml up", "db:wait": "bash -c 'until docker compose -f docker-compose.dev.yml exec -T postgres pg_isready -U ${POSTGRES_USER:-postgres} > /dev/null 2>&1; do sleep 1; done'", - "db:reset": "docker compose -f docker-compose.dev.yml down -v && rm -rf db && bun run db:start", + "db:push": "infisical run -- drizzle-kit push", + "db:generate": "infisical run -- drizzle-kit generate", + "db:migrate": "infisical run -- drizzle-kit migrate", + "db:import": "infisical run -- bun run scripts/db-import.ts", + "db:regenerate-auth": "infisical run -- npx @better-auth/cli generate --output ./server/database/schema/auth.ts -y", "build": "vite-react-ssg build -c vite.config.ssg.ts && vite build", "start": "NODE_ENV=production PORT=8080 bun run dist-server/_worker.js", "import:staging": "bash scripts/import-staging.sh", "typecheck": "tsc --noEmit && tsc --noEmit -p server/tsconfig.json && tsc --noEmit -p web/tsconfig.json", - "all": "bun run format && bun run lint && bun run typecheck && bun run build", + "all": "bun run format && bun run lint && bun run typecheck && bun run build && bun run test:dev", "lint": "biome check . --write && bun run check-locale", + "test": "vitest run", + "test:dev": "infisical run -- vitest watch", "lint:check": "biome check . && bun run check-locale", "format": "biome format . --write", "format:check": "biome format .", @@ -23,6 +29,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.11", + "@electric-sql/pglite": "^0.3.15", "@hono/vite-dev-server": "^0.24.0", "@types/bun": "latest", "@types/pg": "^8.16.0", @@ -34,13 +41,16 @@ "tw-animate-css": "^1.4.0", "vite": "^7.3.0", "vite-react-ssg": "^0.8.9", - "wait-on": "^8.0.0" + "vitest": "^4.0.18" }, "peerDependencies": { "typescript": "^5" }, "dependencies": { "@hono/otel": "^1.1.0", + "@hono/zod-validator": "^0.7.6", + "@kubiks/otel-better-auth": "^2.0.2", + "@kubiks/otel-drizzle": "^2.1.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.67.3", "@opentelemetry/context-zone": "^2.3.0", @@ -60,12 +70,14 @@ "@sentry/bun": "^10.32.1", "@t3-oss/env-core": "^0.13.10", "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-query": "^5.90.19", "better-auth": "^1.4.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-kit": "^0.31.8", "drizzle-orm": "^0.45.1", "hono": "^4.11.3", + "hono-rpc-query": "^1.5.0", "i18next": "^25.7.4", "i18next-browser-languagedetector": "^8.2.0", "lucide-react": "^0.562.0", diff --git a/scripts/db-import.ts b/scripts/db-import.ts new file mode 100644 index 0000000..5e39667 --- /dev/null +++ b/scripts/db-import.ts @@ -0,0 +1,123 @@ +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + from: { type: "string", default: "staging" }, + to: { type: "string", default: "local" }, + force: { type: "boolean", default: false }, + }, +}); + +const fromEnv = values.from ?? "staging"; +const toTarget = values.to ?? "local"; +const force = values.force ?? false; + +async function getRailwayDatabaseUrl(env: string): Promise { + const proc = Bun.spawn(["railway", "variables", "-e", env, "--json"], { + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`Failed to get Railway variables for "${env}":\n${stderr}`); + } + + const json = JSON.parse(await new Response(proc.stdout).text()); + const url = json.DATABASE_URL; + if (!url) { + throw new Error(`DATABASE_URL not found in Railway environment "${env}"`); + } + return url; +} + +async function getTargetUrl(target: string): Promise { + if (target === "local") { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error( + "DATABASE_URL not set in environment. Is infisical/dotenv configured?", + ); + } + return url; + } + return getRailwayDatabaseUrl(target); +} + +async function confirm(message: string): Promise { + process.stdout.write(`${message} [y/N] `); + for await (const line of console) { + return line.trim().toLowerCase() === "y"; + } + return false; +} + +async function main() { + console.log(`Importing DB: ${fromEnv} → ${toTarget}`); + + const sourceUrl = await getRailwayDatabaseUrl(fromEnv); + const targetUrl = await getTargetUrl(toTarget); + + // Prompt for confirmation when targeting a Railway environment + if (toTarget !== "local" && !force) { + const ok = await confirm( + `This will overwrite the "${toTarget}" database. Continue?`, + ); + if (!ok) { + console.log("Aborted."); + process.exit(0); + } + } + + console.log("Starting pg_dump..."); + + const dump = Bun.spawn( + ["pg_dump", "--no-owner", "--no-acl", "-Fc", sourceUrl], + { stdout: "pipe", stderr: "pipe" }, + ); + + const restore = Bun.spawn( + [ + "pg_restore", + "--no-owner", + "--no-acl", + "--clean", + "--if-exists", + "-d", + targetUrl, + ], + { stdin: dump.stdout, stderr: "pipe" }, + ); + + const [dumpExit, restoreExit] = await Promise.all([ + dump.exited, + restore.exited, + ]); + + if (dumpExit !== 0) { + const stderr = await new Response(dump.stderr).text(); + console.error(`pg_dump failed (exit ${dumpExit}):\n${stderr}`); + process.exit(1); + } + + if (restoreExit !== 0) { + const stderr = await new Response(restore.stderr).text(); + // pg_restore exits 1 for warnings (e.g. dropping non-existent objects), which is fine with --clean --if-exists + if (restoreExit !== 1) { + console.error(`pg_restore failed (exit ${restoreExit}):\n${stderr}`); + process.exit(1); + } + if (stderr.trim()) { + console.warn(`pg_restore warnings:\n${stderr}`); + } + } + + console.log("Done."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 0000000..caba343 --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,108 @@ +/** + * Development orchestrator script. + * + * Starts the full dev environment with dynamic port allocation, allowing + * multiple instances to run simultaneously without port conflicts. + * + * Flow: + * 1. Find available ports for PostgreSQL, Vite, and Drizzle Studio + * 2. Start PostgreSQL in Docker with a unique project name + * 3. Wait for the database to be ready + * 4. Run Vite dev server and Drizzle Studio concurrently + * 5. Clean up all processes and containers on exit (Ctrl+C) + */ + +import { $ } from "bun"; +import concurrently from "concurrently"; + +const COMPOSE_FILE = "docker-compose.dev.yml"; + +// --- Port finding using Bun.listen --- +function findPort(start: number, max: number): number { + for (let port = start; port <= max; port++) { + try { + const server = Bun.listen({ + hostname: "127.0.0.1", + port, + socket: { data() {} }, + }); + server.stop(); + return port; + } catch { + // Port in use, try next + } + } + throw new Error(`No available port found in range ${start}-${max}`); +} + +// --- Main --- +const dbPort = findPort(5432, 5500); +const vitePort = findPort(5173, 5200); +const studioPort = findPort(4983, 5100); + +console.log( + `[dev] Ports - DB: ${dbPort}, Vite: ${vitePort}, Studio: ${studioPort}`, +); + +// Use DB port to create unique project name (so multiple instances don't clash) +const projectName = `dev-${dbPort}`; + +// Override DATABASE_URL with new port +const originalDbUrl = process.env.DATABASE_URL; +if (!originalDbUrl) { + throw new Error("DATABASE_URL environment variable is required"); +} +const dbUrl = new URL(originalDbUrl); +dbUrl.port = String(dbPort); +process.env.DATABASE_URL = dbUrl.toString(); + +// Start docker with unique project name +const docker = Bun.spawn( + ["docker", "compose", "-p", projectName, "-f", COMPOSE_FILE, "up"], + { + stdout: "inherit", + stderr: "inherit", + env: { ...process.env, POSTGRES_PORT: String(dbPort) }, + }, +); + +// Cleanup handler +const cleanup = async () => { + console.log("\n[dev] Shutting down..."); + docker.kill(); + await $`docker compose -p ${projectName} -f ${COMPOSE_FILE} down`.quiet(); + process.exit(0); +}; +process.on("SIGINT", cleanup); +process.on("SIGTERM", cleanup); + +// Wait for DB +console.log("[dev] Waiting for database..."); +while (true) { + const r = + await $`docker compose -p ${projectName} -f ${COMPOSE_FILE} exec -T postgres pg_isready -U postgres` + .quiet() + .nothrow(); + if (r.exitCode === 0) break; + await Bun.sleep(1000); +} +console.log("[dev] Database ready!"); + +// Run dev servers +const { result } = concurrently( + [ + { + command: `vite dev --port ${vitePort}`, + name: "VITE", + prefixColor: "cyan", + }, + { + command: `drizzle-kit studio --port ${studioPort}`, + name: "STUDIO", + prefixColor: "yellow", + }, + ], + { killOthersOn: ["failure"] }, +); + +await result; diff --git a/server/db/index.ts b/server/database/index.ts similarity index 56% rename from server/db/index.ts rename to server/database/index.ts index 8b1a27e..6a10009 100644 --- a/server/db/index.ts +++ b/server/database/index.ts @@ -1,3 +1,6 @@ -import env from "@/env"; +import { instrumentDrizzleClient } from "@kubiks/otel-drizzle"; import { drizzle } from "drizzle-orm/node-postgres"; +import env from "@/env"; + export const db = drizzle(env.DATABASE_URL); +instrumentDrizzleClient(db); diff --git a/server/database/migrations/0000_chunky_fabian_cortez.sql b/server/database/migrations/0000_chunky_fabian_cortez.sql new file mode 100644 index 0000000..682e8eb --- /dev/null +++ b/server/database/migrations/0000_chunky_fabian_cortez.sql @@ -0,0 +1,53 @@ +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier"); \ No newline at end of file diff --git a/server/database/migrations/meta/0000_snapshot.json b/server/database/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..3dad245 --- /dev/null +++ b/server/database/migrations/meta/0000_snapshot.json @@ -0,0 +1,362 @@ +{ + "id": "709af286-11c5-4d75-820c-3ad308379e09", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json new file mode 100644 index 0000000..26a0910 --- /dev/null +++ b/server/database/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1769521098875, + "tag": "0000_chunky_fabian_cortez", + "breakpoints": true + } + ] +} diff --git a/server/database/schema/app.ts b/server/database/schema/app.ts new file mode 100644 index 0000000..f11447d --- /dev/null +++ b/server/database/schema/app.ts @@ -0,0 +1,15 @@ +// Custom application tables - add your own tables here +// This file is NOT overwritten by Better Auth CLI + +// Example: +// import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +// import { user } from "./auth"; +// +// export const project = pgTable("project", { +// id: text("id").primaryKey(), +// name: text("name").notNull(), +// ownerId: text("owner_id") +// .notNull() +// .references(() => user.id, { onDelete: "cascade" }), +// createdAt: timestamp("created_at").defaultNow().notNull(), +// }); diff --git a/server/database/schema/auth.ts b/server/database/schema/auth.ts new file mode 100644 index 0000000..416d034 --- /dev/null +++ b/server/database/schema/auth.ts @@ -0,0 +1,96 @@ +// Auto-generated by Better Auth CLI - DO NOT EDIT MANUALLY +// Regenerate with: infisical run -- npx @better-auth/cli generate --output ./server/database/schema/auth.ts -y + +import { relations } from "drizzle-orm"; +import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const session = pgTable( + "session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [index("session_userId_idx").on(table.userId)], +); + +export const account = pgTable( + "account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)], +); + +export const verification = pgTable( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)], +); + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id], + }), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id], + }), +})); diff --git a/server/database/schema/index.ts b/server/database/schema/index.ts new file mode 100644 index 0000000..1c12081 --- /dev/null +++ b/server/database/schema/index.ts @@ -0,0 +1,4 @@ +// Re-export all schema tables + +export * from "./app"; +export * from "./auth"; diff --git a/server/db/schema.ts b/server/db/schema.ts deleted file mode 100644 index bc2ed2e..0000000 --- a/server/db/schema.ts +++ /dev/null @@ -1 +0,0 @@ -// TODO: add auth schema diff --git a/server/features/auth/index.ts b/server/features/auth/index.ts new file mode 100644 index 0000000..a42ed40 --- /dev/null +++ b/server/features/auth/index.ts @@ -0,0 +1,4 @@ +import { createRouter } from "@/server/lib/router"; +import { authHandler } from "./routes/handler"; + +export const authFeature = createRouter().route("/", authHandler); diff --git a/server/features/auth/routes/handler.ts b/server/features/auth/routes/handler.ts new file mode 100644 index 0000000..158c559 --- /dev/null +++ b/server/features/auth/routes/handler.ts @@ -0,0 +1,16 @@ +import { trace } from "@opentelemetry/api"; +import { auth } from "@/server/lib/auth"; +import { createRouter } from "@/server/lib/router"; + +export const authHandler = createRouter().all("/*", (c) => { + // Add auth action to current span for better observability + // e.g. /api/auth/sign-in/email -> "sign-in/email" + const span = trace.getActiveSpan(); + if (span) { + const path = new URL(c.req.url).pathname; + const action = path.replace(/^\/api\/auth\/?/, "") || "unknown"; + span.setAttribute("auth.action", action); + } + + return auth.handler(c.req.raw); +}); diff --git a/server/features/demo/index.ts b/server/features/demo/index.ts new file mode 100644 index 0000000..c9b3d6c --- /dev/null +++ b/server/features/demo/index.ts @@ -0,0 +1,4 @@ +import { createRouter } from "@/server/lib/router"; +import { demoTraceRoute } from "./routes/demo-trace"; + +export const demo = createRouter().route("/demo-trace", demoTraceRoute); diff --git a/server/features/demo/routes/demo-trace.test.ts b/server/features/demo/routes/demo-trace.test.ts new file mode 100644 index 0000000..de62d4e --- /dev/null +++ b/server/features/demo/routes/demo-trace.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "vitest"; +import { createRouter } from "@/server/lib/router"; +import { authMiddleware } from "@/server/middleware/auth.middleware"; +import { getTestApp } from "@/server/utils/testing/test-app"; +import { TestUser1, testUsers } from "@/server/utils/testing/test-users"; +import { demoTraceRoute } from "./demo-trace"; + +const testApp = createRouter(); +testApp.use(authMiddleware); +testApp.route("/demo-trace", demoTraceRoute); + +test("returns default greeting without name param", async () => { + const { app, dbClient } = await getTestApp(testApp, { testUsers }); + + const res = await app.request("/demo-trace?skipDb=true&delay=0", { + headers: { "X-Test-User-Id": TestUser1.id }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ message: "Hello!" }); + dbClient.close(); +}); + +test("returns personalized greeting with name param", async () => { + const { app, dbClient } = await getTestApp(testApp, { testUsers }); + + const res = await app.request("/demo-trace?name=Tim&skipDb=true&delay=0", { + headers: { "X-Test-User-Id": TestUser1.id }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ message: "Hello, Tim!" }); + dbClient.close(); +}); + +test("returns 401 without authentication", async () => { + const { app, dbClient } = await getTestApp(testApp, { testUsers }); + + const res = await app.request("/demo-trace?skipDb=true&delay=0"); + + expect(res.status).toBe(401); + dbClient.close(); +}); + +test("returns 400 when delay exceeds maximum", async () => { + const { app, dbClient } = await getTestApp(testApp, { testUsers }); + + const res = await app.request("/demo-trace?delay=9999&skipDb=true", { + headers: { "X-Test-User-Id": TestUser1.id }, + }); + + expect(res.status).toBe(400); + dbClient.close(); +}); diff --git a/server/features/demo/routes/demo-trace.ts b/server/features/demo/routes/demo-trace.ts new file mode 100644 index 0000000..8e4df9d --- /dev/null +++ b/server/features/demo/routes/demo-trace.ts @@ -0,0 +1,62 @@ +import { zValidator } from "@hono/zod-validator"; +import { sql } from "drizzle-orm"; +import { z } from "zod"; +import { logger } from "@/server/lib/logger"; +import { createRouter } from "@/server/lib/router"; +import { withSpan } from "@/server/lib/tracing"; + +const querySchema = z.object({ + name: z.string().min(1).optional(), + delay: z.coerce.number().min(0).max(5000).optional().default(500), + skipDb: z.coerce.boolean().optional().default(false), +}); + +export const demoTraceRoute = createRouter().get( + "/", + zValidator("query", querySchema), + async (c) => { + const { name, delay, skipDb } = c.req.valid("query"); + + logger.info({ name, delay, skipDb }, "Demo trace started"); + + if (!skipDb) { + // This DB query is auto-traced by @opentelemetry/instrumentation-pg + const db = c.get("db"); + await db.execute( + sql`SELECT 1 as "connection_test", NOW() as "current_time"`, + ); + logger.info({ query: "connection_test" }, "Database query completed"); + } + + // Only use withSpan when you need custom business logic grouping + await withSpan( + "demo.external_api_call", + { "demo.type": "simulation", "api.endpoint": "https://example.com" }, + async (span) => { + span.addEvent("api.request_started", { + "http.method": "GET", + "http.url": "https://example.com/api", + }); + + // Simulate external API latency + await new Promise((resolve) => setTimeout(resolve, delay)); + + span.addEvent("api.response_received", { + "http.status_code": 200, + "response.size_bytes": 1024, + }); + + logger.info({ latency: delay }, "External API call completed"); + }, + ); + + await fetch("https://jsonplaceholder.typicode.com/todos/1"); + + logger.info("Demo trace completed"); + + const greeting = name ? `Hello, ${name}!` : "Hello!"; + return c.json({ + message: greeting, + }); + }, +); diff --git a/server/features/health/index.ts b/server/features/health/index.ts new file mode 100644 index 0000000..7556949 --- /dev/null +++ b/server/features/health/index.ts @@ -0,0 +1,6 @@ +import { createRouter } from "@/server/lib/router"; +import { getHealthRoute } from "./routes/get-health"; + +export const health = createRouter() + // Add feature-specific middleware here if needed + .route("/", getHealthRoute); diff --git a/server/features/health/routes/get-health.test.ts b/server/features/health/routes/get-health.test.ts new file mode 100644 index 0000000..b523bba --- /dev/null +++ b/server/features/health/routes/get-health.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "vitest"; +import { getTestApp } from "@/server/utils/testing/test-app"; +import { getHealthRoute } from "./get-health"; + +test("health endpoint returns 200 with status ok", async () => { + const { app, dbClient } = await getTestApp(getHealthRoute); + + const res = await app.request("/", { + method: "GET", + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok" }); + dbClient.close(); +}); diff --git a/server/features/health/routes/get-health.ts b/server/features/health/routes/get-health.ts new file mode 100644 index 0000000..346580a --- /dev/null +++ b/server/features/health/routes/get-health.ts @@ -0,0 +1,5 @@ +import { createRouter } from "@/server/lib/router"; + +export const getHealthRoute = createRouter().get("/", (c) => { + return c.json({ status: "ok" }); +}); diff --git a/server/features/otel/index.ts b/server/features/otel/index.ts new file mode 100644 index 0000000..c4524f4 --- /dev/null +++ b/server/features/otel/index.ts @@ -0,0 +1,6 @@ +import { createRouter } from "@/server/lib/router"; +import { postTracesRoute } from "./routes/post-traces"; + +export const otel = createRouter() + // Add feature-specific middleware here if needed + .route("/v1/traces", postTracesRoute); diff --git a/server/features/otel/routes/post-traces.ts b/server/features/otel/routes/post-traces.ts new file mode 100644 index 0000000..e5a503a --- /dev/null +++ b/server/features/otel/routes/post-traces.ts @@ -0,0 +1,45 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import env from "@/env"; +import { logger } from "@/server/lib/logger"; +import { createRouter } from "@/server/lib/router"; + +/** + * OpenTelemetry trace proxy for frontend. + * Forwards browser traces to Axiom, adding auth headers server-side. + */ +export const postTracesRoute = createRouter().post("/", async (c) => { + if (!env.AXIOM_TOKEN || !env.AXIOM_DATASET) { + logger.error("Axiom not configured for trace proxy"); + return c.json({ error: "Axiom not configured" }, 503); + } + + try { + const body = await c.req.arrayBuffer(); + const contentType = + c.req.header("Content-Type") || "application/x-protobuf"; + + const response = await fetch("https://api.axiom.co/v1/traces", { + method: "POST", + headers: { + Authorization: `Bearer ${env.AXIOM_TOKEN}`, + "x-axiom-dataset": env.AXIOM_DATASET, + "Content-Type": contentType, + }, + body: body, + }); + + if (!response.ok) { + const text = await response.text(); + logger.error({ upstreamError: text }, "Axiom error:"); + return c.json( + { error: "Upstream error", details: text }, + response.status as ContentfulStatusCode, + ); + } + + return c.json({ success: true }); + } catch (e) { + logger.error({ error: e }, "Proxy error:"); + return c.json({ error: "Proxy error" }, 500); + } +}); diff --git a/server/lib/auth.ts b/server/lib/auth.ts new file mode 100644 index 0000000..c76e93a --- /dev/null +++ b/server/lib/auth.ts @@ -0,0 +1,26 @@ +import { instrumentBetterAuth } from "@kubiks/otel-better-auth"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import env from "@/env"; +import { db } from "@/server/database"; +import * as schema from "@/server/database/schema"; + +export const auth = instrumentBetterAuth( + betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", + schema, + }), + baseURL: env.BETTER_AUTH_URL, + secret: env.BETTER_AUTH_SECRET, + emailAndPassword: { + enabled: true, + }, + session: { + cookieCache: { + enabled: true, + maxAge: env.AUTH_COOKIE_MAX_AGE_SECONDS, + }, + }, + }), +); diff --git a/server/instrumentation.ts b/server/lib/instrumentation.ts similarity index 96% rename from server/instrumentation.ts rename to server/lib/instrumentation.ts index 4c46dd1..1ded22c 100644 --- a/server/instrumentation.ts +++ b/server/lib/instrumentation.ts @@ -46,6 +46,8 @@ if (env.AXIOM_TOKEN && env.AXIOM_DATASET) { new FetchInstrumentation({ ignoreNetworkEvents: true, propagateTraceHeaderCorsUrls: [internalDomainsPattern], + // Ignore empty URLs (Better Auth makes internal calls with empty URLs) + ignoreUrls: [/^$/], }), ], }); diff --git a/server/logger.ts b/server/lib/logger.ts similarity index 95% rename from server/logger.ts rename to server/lib/logger.ts index 209c3fb..cac84d5 100644 --- a/server/logger.ts +++ b/server/lib/logger.ts @@ -11,7 +11,7 @@ import { requestContext } from "./request-context"; * - userId/userEmail when inside an authenticated request * * Usage: - * import { logger } from "@/server/logger"; + * import { logger } from "@/server/lib/logger"; * logger.info("User clicked checkout"); * * Output (authenticated request with active span): diff --git a/server/request-context.ts b/server/lib/request-context.ts similarity index 100% rename from server/request-context.ts rename to server/lib/request-context.ts diff --git a/server/lib/router.ts b/server/lib/router.ts new file mode 100644 index 0000000..024b13e --- /dev/null +++ b/server/lib/router.ts @@ -0,0 +1,9 @@ +import { Hono } from "hono"; +import type { AuthMiddlewareVariables } from "@/server/middleware/auth.middleware"; +import type { DbMiddlewareVariables } from "@/server/middleware/db.middleware"; + +export type AppEnv = { + Variables: AuthMiddlewareVariables & DbMiddlewareVariables; +}; + +export const createRouter = () => new Hono(); diff --git a/server/tracing.ts b/server/lib/tracing.ts similarity index 97% rename from server/tracing.ts rename to server/lib/tracing.ts index 0d0c179..dbf2527 100644 --- a/server/tracing.ts +++ b/server/lib/tracing.ts @@ -2,7 +2,7 @@ import { addSpanAttributes, createWithSpan, recordSpanError, -} from "@/lib/tracing"; +} from "@/shared/tracing"; /** * Server-side tracing helper. Use for custom spans around business logic. diff --git a/server/middleware/auth.middleware.test.ts b/server/middleware/auth.middleware.test.ts new file mode 100644 index 0000000..317490f --- /dev/null +++ b/server/middleware/auth.middleware.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "vitest"; +import { createRouter } from "@/server/lib/router"; +import { authMiddleware } from "@/server/middleware/auth.middleware"; +import { getTestApp } from "@/server/utils/testing/test-app"; +import { TestUser1, testUsers } from "@/server/utils/testing/test-users"; + +// Create a dummy app with requireAuth middleware +const testApp = createRouter(); +testApp.use(authMiddleware); +testApp.get("/", (c) => { + return c.json({ message: "Hello, world!" }); +}); + +test("unauthenticated requests return 401", async () => { + const { app, dbClient } = await getTestApp(testApp, { + testUsers, + }); + + const res = await app.request("/", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + expect(res.status).toBe(401); + dbClient.close(); +}); + +test("non-existing user returns 401", async () => { + const { app, dbClient } = await getTestApp(testApp); + + const res = await app.request("/", { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-Test-User-Id": "11111111-1111-1111-1111-111111111111", + }, + }); + + expect(res.status).toBe(401); + dbClient.close(); +}); + +test("authenticated requests return 200", async () => { + const { app, dbClient } = await getTestApp(testApp, { + testUsers, + }); + + const res = await app.request("/", { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-Test-User-Id": TestUser1.id, + }, + }); + + expect(res.status).toBe(200); + dbClient.close(); +}); diff --git a/server/middleware/auth.middleware.ts b/server/middleware/auth.middleware.ts new file mode 100644 index 0000000..40b188a --- /dev/null +++ b/server/middleware/auth.middleware.ts @@ -0,0 +1,49 @@ +import { trace } from "@opentelemetry/api"; +import type { MiddlewareHandler } from "hono"; +import { auth } from "@/server/lib/auth"; +import { requestContext } from "@/server/lib/request-context"; + +export type AuthMiddlewareVariables = { + user: typeof auth.$Infer.Session.user; +}; + +/** + * Session middleware - resolves the user from the session and populates context. + * Does not block requests without a session. + * Must be used after the OpenTelemetry middleware. + */ +export const sessionMiddleware: MiddlewareHandler = async (c, next) => { + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + + if (session?.user) { + c.set("user", session.user); + + const span = trace.getActiveSpan(); + span?.setAttribute("user.id", session.user.id); + span?.setAttribute("user.email", session.user.email); + + await requestContext.run( + { userId: session.user.id, userEmail: session.user.email }, + async () => { + await next(); + }, + ); + return; + } + + await next(); +}; + +/** + * Requires an authenticated user on context. Returns 401 if not set. + * Must be used after sessionMiddleware (or test auth middleware). + */ +export const authMiddleware: MiddlewareHandler = async (c, next) => { + const user = c.get("user"); + + if (!user) { + return c.json({ error: "Unauthorized" }, 401); + } + + await next(); +}; diff --git a/server/middleware/db.middleware.ts b/server/middleware/db.middleware.ts new file mode 100644 index 0000000..e4339fa --- /dev/null +++ b/server/middleware/db.middleware.ts @@ -0,0 +1,14 @@ +import type { MiddlewareHandler } from "hono/types"; +import { db } from "@/server/database"; + +export type DbMiddlewareVariables = { + db: typeof db; +}; + +/** + * Database middleware: injects the database instance into the context. + */ +export const dbMiddleware: MiddlewareHandler = async (c, next) => { + c.set("db", db); + await next(); +}; diff --git a/server/server.ts b/server/server.ts new file mode 100644 index 0000000..4faa267 --- /dev/null +++ b/server/server.ts @@ -0,0 +1,77 @@ +// IMPORTANT: Instrumentation must be first to patch modules before they're loaded +import "@/server/lib/instrumentation"; + +import { httpInstrumentationMiddleware } from "@hono/otel"; +import * as Sentry from "@sentry/bun"; +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import env from "@/env"; +import { authFeature } from "@/server/features/auth"; +import { demo } from "@/server/features/demo"; +import { health } from "@/server/features/health"; +import { otel } from "@/server/features/otel"; +import { createRouter } from "@/server/lib/router"; +import { + authMiddleware, + sessionMiddleware, +} from "@/server/middleware/auth.middleware"; +import { dbMiddleware } from "@/server/middleware/db.middleware"; + +// First, init Sentry to capture errors +Sentry.init({ + dsn: env.SENTRY_DSN, + sendDefaultPii: true, +}); + +// Public API routes (no auth required) +const publicApi = new Hono() + .route("/auth", authFeature) + .route("/health", health); + +// Protected API routes (auth + db middleware) +const protectedApi = createRouter() + .use(sessionMiddleware) + .use(authMiddleware) + .use(dbMiddleware) + .route("/", demo); + +// API routes that will be traced and exposed via RPC +const api = new Hono().route("/", publicApi).route("/", protectedApi); + +const app = new Hono() + // OTel proxy must be BEFORE tracing middleware (avoids recursive tracing) + .route("/api/otel", otel) + // OpenTelemetry Middleware - traces all requests after this point + .use(httpInstrumentationMiddleware()) + // Traced API routes - mounted AFTER middleware + .route("/api", api); + +// Static file serving and SPA fallback +const isProd = env.ENV === "production"; + +if (isProd) { + app.use( + "*", + serveStatic({ + root: "./dist-static", + rewriteRequestPath: (requestPath) => { + if (requestPath === "/") return "/index.html"; + if (requestPath.includes(".")) return requestPath; + return `${requestPath}.html`; + }, + }), + ); +} + +// SPA fallback: serve index.html for any unmatched routes +app.get("*", async (c) => { + const html = await Bun.file( + isProd ? "./dist-static/index.html" : "./index.html", + ).text(); + return c.html(html); +}); + +// Export type for Hono RPC client +export type AppType = typeof api; + +export default app; diff --git a/server/server.tsx b/server/server.tsx deleted file mode 100644 index f98f3ad..0000000 --- a/server/server.tsx +++ /dev/null @@ -1,167 +0,0 @@ -// IMPORTANT: Instrumentation must be first to patch modules before they're loaded -import "@/server/instrumentation"; - -import { httpInstrumentationMiddleware } from "@hono/otel"; -import * as Sentry from "@sentry/bun"; -import { sql } from "drizzle-orm"; -import { Hono } from "hono"; -import { serveStatic } from "hono/bun"; -import type { ContentfulStatusCode } from "hono/utils/http-status"; -import env from "@/env.ts"; -import { db } from "@/server/db"; - -const { logger } = await import("@/server/logger"); -const { withSpan } = await import("@/server/tracing"); -const { requestContext } = await import("@/server/request-context"); -const { trace } = await import("@opentelemetry/api"); - -// TODO: Uncomment when Better Auth is set up -// import { auth } from "@/server/auth"; - -// First, init Sentry to capture errors -Sentry.init({ - dsn: env.SENTRY_DSN, - sendDefaultPii: true, -}); -const app = new Hono(); - -app.post("/api/otel/v1/traces", async (c) => { - if (!env.AXIOM_TOKEN || !env.AXIOM_DATASET) { - // Silent fail or log? For now let's return 503 Service Unavailable - logger.error("Axiom not configured for trace proxy"); - return c.json({ error: "Axiom not configured" }, 503); - } - - try { - const body = await c.req.arrayBuffer(); - const contentType = - c.req.header("Content-Type") || "application/x-protobuf"; - - const response = await fetch("https://api.axiom.co/v1/traces", { - method: "POST", - headers: { - Authorization: `Bearer ${env.AXIOM_TOKEN}`, - "x-axiom-dataset": env.AXIOM_DATASET, - "Content-Type": contentType, - }, - body: body, - }); - - if (!response.ok) { - const text = await response.text(); - logger.error({ upstreamError: text }, "Axiom error:"); - return c.json( - { error: "Upstream error", details: text }, - response.status as ContentfulStatusCode, - ); - } - - return c.json({ success: true }); - } catch (e) { - logger.error({ error: e }, "Proxy error:"); - return c.json({ error: "Proxy error" }, 500); - } -}); - -// OpenTelemetry Middleware - auto-traces all requests and injects user context -app.use(httpInstrumentationMiddleware()); - -// User context middleware - injects user info into traces and logs -// TODO: Uncomment auth logic when Better Auth is set up -app.use("*", async (c, next) => { - // const session = await auth.api.getSession({ headers: c.req.raw.headers }); - const session = null as { user: { id: string; email: string } } | null; // Remove this line when auth is ready - - if (session?.user) { - // Add to span for tracing - const span = trace.getActiveSpan(); - span?.setAttribute("user.id", session.user.id); - span?.setAttribute("user.email", session.user.email); - - // Run with user context for logging - await requestContext.run( - { userId: session.user.id, userEmail: session.user.email }, - async () => { - await next(); - }, - ); - } else { - await next(); - } -}); - -app.get("/api/hello", (c) => { - return c.json({ message: "Hello from the Hono Server!" }); -}); - -app.get("/api/demo-trace", async (c) => { - // Logging: automatically includes traceId and spanId - logger.info("Demo trace started"); - - // This DB query is auto-traced by @opentelemetry/instrumentation-pg - await db.execute(sql`SELECT 1 as "connection_test", NOW() as "current_time"`); - logger.info({ query: "connection_test" }, "Database query completed"); - - // Only use withSpan when you need custom business logic grouping - await withSpan( - "demo.external_api_call", - { "demo.type": "simulation", "api.endpoint": "https://example.com" }, - async (span) => { - // Events: timestamped markers within a span - span.addEvent("api.request_started", { - "http.method": "GET", - "http.url": "https://example.com/api", - }); - - // Simulate external API latency - await new Promise((resolve) => setTimeout(resolve, 500)); - - span.addEvent("api.response_received", { - "http.status_code": 200, - "response.size_bytes": 1024, - }); - - logger.info({ latency: 500 }, "External API call completed"); - }, - ); - - await fetch("https://jsonplaceholder.typicode.com/todos/1"); - - logger.info("Demo trace completed"); - - return c.json({ - message: "Trace complete! Check Axiom for the demo trace.", - timestamp: new Date().toISOString(), - }); -}); - -const isProd = env.ENV === "production"; - -if (isProd) { - // Serve pre-built static files from dist-static directory. - // Rewrites paths to map clean URLs to their corresponding HTML files. - app.use( - "*", - serveStatic({ - root: "./dist-static", - rewriteRequestPath: (requestPath) => { - if (requestPath === "/") return "/index.html"; - // Keep asset and JSON paths as-is - if (requestPath.includes(".")) return requestPath; - // Map clean URLs (e.g., /about) to their prerendered HTML - return `${requestPath}.html`; - }, - }), - ); -} - -// SPA fallback: serve index.html for any unmatched routes. -// This allows client-side routing to handle the path instead of returning 404. -app.get("*", async (c) => { - const html = await Bun.file( - isProd ? "./dist-static/index.html" : "./index.html", - ).text(); - return c.html(html); -}); - -export default app; diff --git a/server/utils/testing/test-app.ts b/server/utils/testing/test-app.ts new file mode 100644 index 0000000..cb52062 --- /dev/null +++ b/server/utils/testing/test-app.ts @@ -0,0 +1,34 @@ +import type { Hono } from "hono"; +import { type AppEnv, createRouter } from "@/server/lib/router"; +import testAuthMiddleware from "@/server/utils/testing/test-auth.middleware"; +import { createTestDb } from "@/server/utils/testing/test-db"; +import { createTestDbMiddleware } from "@/server/utils/testing/test-db.middleware"; +import { + type InsertUser, + importTestUsers, +} from "@/server/utils/testing/test-user"; + +interface TestAppOptions { + testUsers?: InsertUser[]; +} + +export async function getTestApp( + app: Hono, + options: TestAppOptions = {}, +) { + // Create a fresh ephemeral database + const { dbClient, db } = await createTestDb(); + + // Import test users if needed + if (options.testUsers) { + await importTestUsers(db, options.testUsers); + } + + // Create fresh app instance to inject the db middleware + const testApp = createRouter(); + testApp.use(createTestDbMiddleware(db)); + testApp.use(testAuthMiddleware); + testApp.route("/", app); + + return { app: testApp, db, dbClient }; +} diff --git a/server/utils/testing/test-auth.middleware.ts b/server/utils/testing/test-auth.middleware.ts new file mode 100644 index 0000000..1a0ca88 --- /dev/null +++ b/server/utils/testing/test-auth.middleware.ts @@ -0,0 +1,38 @@ +import type { PgliteDatabase } from "drizzle-orm/pglite/driver"; +import type { MiddlewareHandler } from "hono"; +import type * as schema from "@/server/database/schema"; +import { logger } from "@/server/lib/logger"; + +const testAuthMiddleware: MiddlewareHandler = async (c, next) => { + // Get bearer from header + const testUserId = c.req.header("X-Test-User-Id"); + + if (testUserId == null) { + logger.info("The test request is not authenticated"); + await next(); + return; + } + + const db: PgliteDatabase = c.get("db"); + + // Select the user from the database + const user = await db.query.user.findFirst({ + where: (user, { eq }) => eq(user.id, testUserId), + }); + + if (user != null) { + const mockedUser = { + id: user.id, + email: user.email, + }; + + logger.info(mockedUser, "Test user is authenticated"); + c.set("user", mockedUser); + } else { + logger.info("The test request is not authenticated"); + } + + await next(); +}; + +export default testAuthMiddleware; diff --git a/server/utils/testing/test-db.middleware.ts b/server/utils/testing/test-db.middleware.ts new file mode 100644 index 0000000..8fbf22a --- /dev/null +++ b/server/utils/testing/test-db.middleware.ts @@ -0,0 +1,11 @@ +import type { drizzle } from "drizzle-orm/pglite"; +import type { MiddlewareHandler } from "hono/types"; + +export function createTestDbMiddleware( + db: ReturnType, +): MiddlewareHandler { + return async (c, next) => { + c.set("db", db); + await next(); + }; +} diff --git a/server/utils/testing/test-db.ts b/server/utils/testing/test-db.ts new file mode 100644 index 0000000..35d3a7a --- /dev/null +++ b/server/utils/testing/test-db.ts @@ -0,0 +1,19 @@ +import { PGlite } from "@electric-sql/pglite"; +import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm"; +import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import * as schema from "@/server/database/schema"; + +export type TestDb = Awaited>; + +export const createTestDb = async () => { + const client = new PGlite({ + extensions: { uuid_ossp, pg_trgm }, + }); + const db = drizzle({ client, schema }); + + await migrate(db, { migrationsFolder: "./server/database/migrations" }); + + return { dbClient: client, db }; +}; diff --git a/server/utils/testing/test-user.ts b/server/utils/testing/test-user.ts new file mode 100644 index 0000000..e0e1a8a --- /dev/null +++ b/server/utils/testing/test-user.ts @@ -0,0 +1,11 @@ +import { user } from "@/server/database/schema"; +import type { TestDb } from "./test-db"; + +export type InsertUser = typeof user.$inferInsert; + +export const importTestUsers = async ( + db: TestDb["db"], + testUsers: InsertUser[], +) => { + await db.insert(user).values(testUsers); +}; diff --git a/server/utils/testing/test-users.ts b/server/utils/testing/test-users.ts new file mode 100644 index 0000000..7b6fa53 --- /dev/null +++ b/server/utils/testing/test-users.ts @@ -0,0 +1,15 @@ +import type { InsertUser } from "@/server/utils/testing/test-user"; + +export const TestUser1: InsertUser = { + id: "00000000-0000-4000-8000-000000000001", + email: "user1@example.com", + name: "Test User 1", +}; + +export const TestUser2: InsertUser = { + id: "00000000-0000-4000-8000-000000000002", + email: "user2@example.com", + name: "Test User 2", +}; + +export const testUsers = [TestUser1, TestUser2]; diff --git a/lib/tracing.ts b/shared/tracing.ts similarity index 100% rename from lib/tracing.ts rename to shared/tracing.ts diff --git a/tsconfig.json b/tsconfig.json index c82f3b4..d6f6e95 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["./*.ts", "lib/**/*.ts", "scripts/**/*.ts"], + "include": ["./*.ts", "shared/**/*.ts", "scripts/**/*.ts"], "exclude": ["node_modules", "dist", "dist-server", "server", "web"], "compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"], diff --git a/vite-plugins.ts b/vite-plugins.ts new file mode 100644 index 0000000..2bcf21e --- /dev/null +++ b/vite-plugins.ts @@ -0,0 +1,88 @@ +import path from "node:path"; +import type { Plugin } from "vite"; + +interface PreventImportsOptions { + /** The directory path to block imports from (can be relative or absolute) */ + folder: string; + /** The directory path from which imports should be blocked (if not specified, blocks from anywhere) */ + fromFolder?: string; + /** List of file paths or glob patterns to ignore from blocking */ + ignores?: string[]; +} + +/** + * Enforces architectural boundaries by blocking imports from a directory. + * + * Leverages Vite's resolver to catch all import variations, including + * normalized paths like `../web/../server`. + * + * @example + * preventImports({ folder: './server' }) + * + * @example + * preventImports({ folder: './server', ignores: ['./server/shared.ts'] }) + * + * @example + * // Only block imports from web/ to server/ + * preventImports({ folder: './server', fromFolder: './web' }) + */ +export const preventImports = ({ + folder, + fromFolder, + ignores, +}: PreventImportsOptions): Plugin => { + // Normalize the blocked path to an absolute path for consistent comparison + const blockedPath = path.resolve(folder); + // Normalize the fromFolder path if provided + const fromPath = fromFolder ? path.resolve(fromFolder) : null; + // Normalize ignore paths to absolute paths + const ignoredPaths = (ignores ?? []).map((p) => path.resolve(p)); + + return { + name: "vite-plugin-prevent-imports", + // ensure this runs before other resolution plugins + enforce: "pre" as const, + + async resolveId(source, importer, options) { + // If there is no importer, it's an entry point (allow it) + if (!importer) return null; + + // If fromFolder is specified, only block imports from files within that folder + if (fromPath && !importer.startsWith(fromPath)) { + return null; + } + + // Resolve the import using Vite's internal resolver 'skipSelf' prevents + // this plugin from entering an infinite loop + const resolution = await this.resolve(source, importer, { + skipSelf: true, + ...options, + }); + + // If resolution fails or is external, let Vite handle it + if (!resolution || !resolution.id) return null; + + // Check if the resolved absolute path starts with the blocked directory + // Using .startsWith is safer than .includes to prevent false positives + if (resolution.id.startsWith(blockedPath)) { + // Check if the resolved path is in the ignore list + const isIgnored = ignoredPaths.some( + (ignoredPath) => + resolution.id === ignoredPath || + resolution.id.startsWith(ignoredPath + path.sep), + ); + + if (!isIgnored) { + throw new Error( + `\n🚨 SECURITY ERROR: Blocked import detected.\n` + + ` File: ${importer}\n` + + ` Attempted to import: ${source}\n` + + ` Resolved to blocked path: ${resolution.id}\n`, + ); + } + } + + return null; // Return null to allow other plugins/Vite to handle valid imports + }, + }; +}; diff --git a/vite.config.ssg.ts b/vite.config.ssg.ts index d689a15..b641e6e 100644 --- a/vite.config.ssg.ts +++ b/vite.config.ssg.ts @@ -2,6 +2,7 @@ import path from "node:path"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +import { preventImports } from "./vite-plugins"; export default defineConfig(() => { return { @@ -15,6 +16,17 @@ export default defineConfig(() => { outDir: "dist-static", emptyOutDir: true, }, - plugins: [react(), tailwindcss()], + plugins: [ + // Do not allow server code to be imported into the client build + preventImports({ + fromFolder: path.resolve(__dirname, "web"), + folder: path.resolve(__dirname, "server"), + // For the Hono RPC to work correctly, we need to allow types to be + // imported from `server.ts` to the client code. + ignores: ["./server/server.ts"], + }), + react(), + tailwindcss(), + ], }; }); diff --git a/vite.config.ts b/vite.config.ts index 8a404cf..931f3b6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import bunAdapter from "@hono/vite-dev-server/bun"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +import { preventImports } from "./vite-plugins"; export default defineConfig(({ command }) => { const resolveConfig = { @@ -21,7 +22,7 @@ export default defineConfig(({ command }) => { outDir: "dist-server", // Server builds to dist-server emptyOutDir: true, // Clean the server output folder rollupOptions: { - input: "./server/server.tsx", + input: "./server/server.ts", output: { entryFileNames: "_worker.js", }, @@ -36,10 +37,18 @@ export default defineConfig(({ command }) => { entries: ["./web/client.ts", "./web/app.tsx"], }, plugins: [ + // Do not allow server code to be imported into the client build + preventImports({ + fromFolder: path.resolve(__dirname, "web"), + folder: path.resolve(__dirname, "server"), + // For the Hono RPC to work correctly, we need to allow types to be + // imported from `server.ts` to the client code. + ignores: ["./server/server.ts"], + }), tailwindcss(), command === "serve" ? react() : undefined, devServer({ - entry: "./server/server.tsx", + entry: "./server/server.ts", adapter: bunAdapter(), }), ], diff --git a/web/app.tsx b/web/app.tsx index 1dc2cff..44d294f 100644 --- a/web/app.tsx +++ b/web/app.tsx @@ -1,33 +1,29 @@ -import { useEffect, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { AuthDemo } from "~/components/auth-demo"; import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; import i18n from "./i18n/i18n"; +import { api } from "./lib/api"; import { withSpan } from "./tracing"; export default function App() { const { t } = useTranslation("common"); + const [name, setName] = useState("World"); - const [msg, setMsg] = useState("Loading..."); + const demoTraceOptions = api["demo-trace"].$get.mutationOptions({}); + const demoTrace = useMutation({ + ...demoTraceOptions, + mutationFn: (args: Parameters[0]) => + withSpan("ui.click.demo_trace", { component: "App" }, () => + demoTraceOptions.mutationFn(args), + ), + }); - useEffect(() => { - fetch("/api/hello") - .then((res) => res.json()) - .then((data) => setMsg(data.message)); - }, []); - - const triggerTrace = async () => { - setMsg("Starting trace..."); - - await withSpan( - "ui.click.demo_button", - { component: "App", event: "click" }, - async () => { - const res = await fetch("/api/demo-trace"); - const data = await res.json(); - setMsg(data.message); - }, - ); - }; + const msg = demoTrace.isPending + ? "Loading..." + : (demoTrace.data?.message ?? t("enterName")); return (
@@ -58,16 +54,35 @@ export default function App() { components={{ strong: }} />

+
+ setName(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && + name && + !demoTrace.isPending && + demoTrace.mutate({ query: { name } }) + } + disabled={demoTrace.isPending} + /> + +
- +
); } diff --git a/web/client.tsx b/web/client.tsx index 4b49a94..f091111 100644 --- a/web/client.tsx +++ b/web/client.tsx @@ -1,7 +1,7 @@ +// IMPORTANT: Instrumentation must be the FIRST import to patch fetch before +// any other module (like better-auth) captures a reference to it +import "./instrumentation"; import "@vitejs/plugin-react/preamble"; -import { initInstrumentation } from "./instrumentation"; - -initInstrumentation(); import "~/styles.css"; import "~/i18n/i18n"; diff --git a/web/components/auth-demo.tsx b/web/components/auth-demo.tsx new file mode 100644 index 0000000..2f227cf --- /dev/null +++ b/web/components/auth-demo.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { Button } from "~/components/ui/button"; +import { signIn, signOut, signUp, useSession } from "~/lib/auth-client"; +import { withSpan } from "~/tracing"; + +export function AuthDemo() { + const { data: session, isPending } = useSession(); + const [email, setEmail] = useState("demo@example.com"); + const [password, setPassword] = useState("password123"); + const [name, setName] = useState("Demo User"); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + const handleSignUp = () => + withSpan("auth.sign_up", { component: "AuthDemo" }, async () => { + setError(null); + setStatus("Signing up..."); + + const result = await signUp.email({ email, password, name }); + + if (result.error) { + setError(result.error.message ?? "Signup failed"); + setStatus(null); + } else { + setStatus("Signed up successfully!"); + } + }); + + const handleSignIn = () => + withSpan("auth.sign_in", { component: "AuthDemo" }, async () => { + setError(null); + setStatus("Signing in..."); + + const result = await signIn.email({ email, password }); + + if (result.error) { + setError(result.error.message ?? "Sign in failed"); + setStatus(null); + } else { + setStatus("Signed in successfully!"); + } + }); + + const handleSignOut = () => + withSpan("auth.sign_out", { component: "AuthDemo" }, async () => { + setError(null); + setStatus("Signing out..."); + + await signOut(); + setStatus("Signed out successfully!"); + }); + + if (isPending) { + return
Loading session...
; + } + + return ( +
+

Auth Demo

+ + {session ? ( +
+
+

Signed in as:

+

{session.user.name}

+

+ {session.user.email} +

+
+ +
+ ) : ( +
+ setName(e.target.value)} + className="rounded border px-3 py-2 text-sm" + /> + setEmail(e.target.value)} + className="rounded border px-3 py-2 text-sm" + /> + setPassword(e.target.value)} + className="rounded border px-3 py-2 text-sm" + /> +
+ + +
+
+ )} + + {status &&

{status}

} + {error &&

{error}

} + +

+ Auth actions are wrapped in OpenTelemetry spans. Check Axiom for traces! +

+
+ ); +} diff --git a/web/components/ui/input.tsx b/web/components/ui/input.tsx new file mode 100644 index 0000000..c0e3c95 --- /dev/null +++ b/web/components/ui/input.tsx @@ -0,0 +1,25 @@ +import type * as React from "react"; + +import { cn } from "~/lib/utils"; + +function Input({ className, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/web/i18n/locales/en/common.ts b/web/i18n/locales/en/common.ts index 3915519..6583254 100644 --- a/web/i18n/locales/en/common.ts +++ b/web/i18n/locales/en/common.ts @@ -1,4 +1,8 @@ export default { serverSays: "Server says: {{message}}", - testMe: "Test me", + enterName: "Enter your name and click the button", + namePlaceholder: "Your name", + sayHello: "Say Hello", + loading: "Loading...", + switchLang: "Switch Language", } as const; diff --git a/web/i18n/locales/sl/common.ts b/web/i18n/locales/sl/common.ts index 3b3e97e..13a7046 100644 --- a/web/i18n/locales/sl/common.ts +++ b/web/i18n/locales/sl/common.ts @@ -1,4 +1,8 @@ export default { - serverSays: "Server pravi: {{message}}", - testMe: "Preizkusi me", + serverSays: "Strežnik pravi: {{message}}", + enterName: "Vnesi svoje ime in klikni gumb", + namePlaceholder: "Tvoje ime", + sayHello: "Pozdravi", + loading: "Nalagam...", + switchLang: "Zamenjaj jezik", } as const; diff --git a/web/instrumentation.ts b/web/instrumentation.ts index 30416bb..d4ef5b1 100644 --- a/web/instrumentation.ts +++ b/web/instrumentation.ts @@ -56,3 +56,7 @@ export function initInstrumentation() { console.debug("Web instrumentation initialized"); } } + +// Auto-initialize at module load time - this MUST run before any other modules +// that use fetch are imported (like better-auth) +initInstrumentation(); diff --git a/web/lib/api.ts b/web/lib/api.ts new file mode 100644 index 0000000..35c5541 --- /dev/null +++ b/web/lib/api.ts @@ -0,0 +1,6 @@ +import { hc } from "hono/client"; +import { hcQuery } from "hono-rpc-query"; +import type { AppType } from "@/server/server"; + +const client = hc("/api"); +export const api = hcQuery(client); diff --git a/web/lib/auth-client.ts b/web/lib/auth-client.ts new file mode 100644 index 0000000..2016848 --- /dev/null +++ b/web/lib/auth-client.ts @@ -0,0 +1,13 @@ +import { createAuthClient } from "better-auth/react"; + +// Better Auth requires a full URL +const baseURL = + typeof window !== "undefined" + ? `${window.location.origin}/api/auth` + : "http://localhost:5173/api/auth"; + +export const authClient = createAuthClient({ + baseURL, +}); + +export const { signIn, signOut, signUp, useSession } = authClient; diff --git a/web/router.tsx b/web/router.tsx index 8cf8129..f1927e6 100644 --- a/web/router.tsx +++ b/web/router.tsx @@ -1,14 +1,19 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { RouteRecord } from "vite-react-ssg"; import App from "./app"; import { ErrorBoundary } from "./components/error-boundary"; +const queryClient = new QueryClient(); + export const routes: RouteRecord[] = [ { path: "/", element: ( - - - + + + + + ), }, ]; diff --git a/web/tracing.ts b/web/tracing.ts index a83efec..6bcb93d 100644 --- a/web/tracing.ts +++ b/web/tracing.ts @@ -2,7 +2,7 @@ import { addSpanAttributes, createWithSpan, recordSpanError, -} from "@/lib/tracing"; +} from "@/shared/tracing"; /** * Frontend tracing helper. Use for user interactions you want to track.