create-pyro 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +32 -11
  2. package/dist/index.js +150 -30
  3. package/dist/scaffold.js +106 -0
  4. package/package.json +7 -7
  5. package/template/.dockerignore +30 -0
  6. package/template/.env.example +23 -0
  7. package/template/.gitattributes +2 -0
  8. package/template/.github/workflows/ci.yml +43 -0
  9. package/template/.vscode/extensions.json +3 -0
  10. package/template/.vscode/settings.json +8 -0
  11. package/template/README.md +57 -4
  12. package/template/apps/api/Dockerfile +46 -0
  13. package/template/apps/api/package.json +43 -0
  14. package/template/apps/api/src/app.test.ts +44 -0
  15. package/template/apps/api/src/app.ts +54 -0
  16. package/template/apps/api/src/index.ts +13 -0
  17. package/template/apps/api/src/lib/auth.ts +46 -0
  18. package/template/apps/api/src/lib/response.ts +28 -0
  19. package/template/apps/api/src/lib/session.ts +22 -0
  20. package/template/apps/api/src/middleware/auth.ts +34 -0
  21. package/template/apps/api/src/routes/auth.ts +9 -0
  22. package/template/apps/api/src/routes/index.ts +8 -0
  23. package/template/apps/api/src/routes/me.test.ts +45 -0
  24. package/template/apps/api/src/routes/me.ts +29 -0
  25. package/template/apps/api/src/test/database-url.ts +23 -0
  26. package/template/apps/api/src/test/global-setup.ts +36 -0
  27. package/template/apps/api/src/test/setup.ts +8 -0
  28. package/template/apps/api/src/types/context.ts +8 -0
  29. package/template/apps/api/tsconfig.build.json +8 -0
  30. package/template/apps/api/tsconfig.json +9 -0
  31. package/template/apps/api/tsdown.config.ts +13 -0
  32. package/template/apps/api/vitest.config.ts +11 -0
  33. package/template/apps/web/Dockerfile +49 -0
  34. package/template/apps/web/app/app.css +29 -0
  35. package/template/apps/web/app/lib/api.server.ts +13 -0
  36. package/template/apps/web/app/lib/api.test.ts +28 -0
  37. package/template/apps/web/app/lib/api.ts +47 -0
  38. package/template/apps/web/app/lib/auth-client.ts +13 -0
  39. package/template/apps/web/app/lib/env.server.ts +11 -0
  40. package/template/apps/web/app/lib/public-env.ts +17 -0
  41. package/template/apps/web/app/lib/session.server.ts +31 -0
  42. package/template/apps/web/app/lib/utils.ts +6 -0
  43. package/template/apps/web/app/root.tsx +86 -0
  44. package/template/apps/web/app/routes/dashboard.tsx +81 -0
  45. package/template/apps/web/app/routes/home.tsx +161 -0
  46. package/template/apps/web/app/routes.ts +6 -0
  47. package/template/apps/web/package.json +41 -0
  48. package/template/apps/web/public/favicon.svg +5 -0
  49. package/template/apps/web/react-router.config.ts +6 -0
  50. package/template/apps/web/tsconfig.json +23 -0
  51. package/template/apps/web/vite.config.ts +19 -0
  52. package/template/apps/web/vitest.config.ts +13 -0
  53. package/template/biome.json +53 -0
  54. package/template/compose.dev.yml +26 -0
  55. package/template/compose.yml +75 -0
  56. package/template/gitignore +32 -1
  57. package/template/infra/nginx/default.conf +33 -0
  58. package/template/package.json +23 -1
  59. package/template/packages/config/package.json +31 -0
  60. package/template/packages/config/src/index.test.ts +100 -0
  61. package/template/packages/config/src/index.ts +100 -0
  62. package/template/packages/config/tsconfig.build.json +8 -0
  63. package/template/packages/config/tsconfig.json +9 -0
  64. package/template/packages/config/tsdown.config.ts +12 -0
  65. package/template/packages/db/Dockerfile +22 -0
  66. package/template/packages/db/drizzle/0000_init.sql +53 -0
  67. package/template/packages/db/drizzle/meta/0000_snapshot.json +376 -0
  68. package/template/packages/db/drizzle/meta/_journal.json +13 -0
  69. package/template/packages/db/drizzle.config.ts +12 -0
  70. package/template/packages/db/package.json +38 -0
  71. package/template/packages/db/src/client.ts +12 -0
  72. package/template/packages/db/src/index.ts +2 -0
  73. package/template/packages/db/src/schema/auth.ts +96 -0
  74. package/template/packages/db/src/schema/index.ts +1 -0
  75. package/template/packages/db/tsconfig.build.json +7 -0
  76. package/template/packages/db/tsconfig.json +9 -0
  77. package/template/packages/db/tsdown.config.ts +12 -0
  78. package/template/packages/shared/package.json +32 -0
  79. package/template/packages/shared/src/index.ts +2 -0
  80. package/template/packages/shared/src/schemas/auth.test.ts +32 -0
  81. package/template/packages/shared/src/schemas/auth.ts +22 -0
  82. package/template/packages/shared/src/schemas/index.ts +1 -0
  83. package/template/packages/shared/src/types/api.ts +15 -0
  84. package/template/packages/shared/src/types/index.ts +2 -0
  85. package/template/packages/shared/src/types/user.ts +10 -0
  86. package/template/packages/shared/tsconfig.build.json +8 -0
  87. package/template/packages/shared/tsconfig.json +9 -0
  88. package/template/packages/shared/tsdown.config.ts +12 -0
  89. package/template/pnpm-workspace.yaml +25 -0
  90. package/template/tsconfig.json +19 -0
  91. package/template/turbo.json +27 -0
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@repo/api",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "dotenv-run -- tsx watch --include '../../packages/*/dist/**' src/index.ts",
8
+ "build": "tsdown",
9
+ "start": "dotenv-run -- node dist/index.mjs",
10
+ "typecheck": "tsc",
11
+ "test": "dotenv-run -- vitest run",
12
+ "test:watch": "dotenv-run -- vitest"
13
+ },
14
+ "dependencies": {
15
+ "@better-auth/drizzle-adapter": "catalog:",
16
+ "@hono/node-server": "catalog:",
17
+ "@paralleldrive/cuid2": "catalog:",
18
+ "@repo/config": "workspace:*",
19
+ "@repo/db": "workspace:*",
20
+ "@repo/shared": "workspace:*",
21
+ "better-auth": "catalog:",
22
+ "drizzle-orm": "catalog:",
23
+ "hono": "catalog:"
24
+ },
25
+ "devDependencies": {
26
+ "@dotenv-run/cli": "catalog:",
27
+ "@types/node": "catalog:",
28
+ "@types/pg": "catalog:",
29
+ "pg": "catalog:",
30
+ "tsdown": "catalog:",
31
+ "tsx": "catalog:",
32
+ "typescript": "catalog:",
33
+ "vitest": "catalog:"
34
+ },
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.mts",
38
+ "import": "./dist/index.mjs"
39
+ }
40
+ },
41
+ "main": "./dist/index.mjs",
42
+ "types": "./dist/index.d.mts"
43
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createApp } from "./app";
3
+ import type { SessionResolver } from "./lib/session";
4
+
5
+ const signedOut: SessionResolver = async () => null;
6
+
7
+ describe("app", () => {
8
+ it("answers the health check", async () => {
9
+ const app = createApp({ sessionResolver: signedOut });
10
+ const res = await app.request("/api/health");
11
+ expect(res.status).toBe(200);
12
+ expect(await res.json()).toEqual({ ok: true });
13
+ });
14
+
15
+ it("rejects /api/me when signed out", async () => {
16
+ const app = createApp({ sessionResolver: signedOut });
17
+ const res = await app.request("/api/me");
18
+ expect(res.status).toBe(401);
19
+ expect(await res.json()).toMatchObject({
20
+ success: false,
21
+ error: { code: "UNAUTHORIZED" },
22
+ });
23
+ });
24
+
25
+ it("returns a JSON 404 for unknown routes", async () => {
26
+ const app = createApp({ sessionResolver: signedOut });
27
+ const res = await app.request("/api/nope");
28
+ expect(res.status).toBe(404);
29
+ });
30
+
31
+ it("only allows the configured web origin", async () => {
32
+ const app = createApp({ sessionResolver: signedOut });
33
+ const allowed = await app.request("/api/health", {
34
+ headers: { Origin: "http://localhost:3000" },
35
+ });
36
+ const denied = await app.request("/api/health", {
37
+ headers: { Origin: "https://evil.example" },
38
+ });
39
+ expect(allowed.headers.get("access-control-allow-origin")).toBe(
40
+ "http://localhost:3000",
41
+ );
42
+ expect(denied.headers.get("access-control-allow-origin")).toBeNull();
43
+ });
44
+ });
@@ -0,0 +1,54 @@
1
+ import { Hono } from "hono";
2
+ import { cors } from "hono/cors";
3
+ import { HTTPException } from "hono/http-exception";
4
+ import { logger } from "hono/logger";
5
+ import { apiEnv } from "@repo/config";
6
+ import { fail } from "./lib/response";
7
+ import { resolveSession, type SessionResolver } from "./lib/session";
8
+ import { createAuthMiddleware } from "./middleware/auth";
9
+ import api from "./routes";
10
+ import type { AppEnv } from "./types/context";
11
+
12
+ export type CreateAppOptions = {
13
+ /** Swap how a request's session is resolved, e.g. with a stub in tests. */
14
+ sessionResolver?: SessionResolver;
15
+ };
16
+
17
+ const SKIP_LOG = new Set(["/api/health", "/api/auth/get-session"]);
18
+
19
+ export function createApp(options: CreateAppOptions = {}) {
20
+ const resolver = options.sessionResolver ?? resolveSession;
21
+
22
+ return new Hono<AppEnv>()
23
+ .use("*", async (c, next) => {
24
+ if (apiEnv.NODE_ENV === "test" || SKIP_LOG.has(c.req.path)) return next();
25
+ return logger()(c, next);
26
+ })
27
+
28
+ .use(
29
+ "/api/*",
30
+ cors({
31
+ origin: (origin) => (origin === apiEnv.CORS_ORIGIN ? origin : null),
32
+ credentials: true,
33
+ }),
34
+ )
35
+
36
+ .use("/api/*", createAuthMiddleware(resolver))
37
+
38
+ .get("/api/health", (c) => c.json({ ok: true }))
39
+
40
+ .route("/api", api)
41
+
42
+ .notFound((c) => fail(c, "NOT_FOUND", "Not found", 404))
43
+
44
+ .onError((err, c) => {
45
+ if (err instanceof HTTPException) return err.getResponse();
46
+
47
+ console.error(err);
48
+ return fail(c, "INTERNAL", "Internal server error", 500);
49
+ });
50
+ }
51
+
52
+ const app = createApp();
53
+
54
+ export default app;
@@ -0,0 +1,13 @@
1
+ import { serve } from "@hono/node-server";
2
+ import { apiEnv } from "@repo/config";
3
+ import app from "./app";
4
+
5
+ const isProd = apiEnv.NODE_ENV === "production";
6
+
7
+ serve({ fetch: app.fetch, port: apiEnv.API_PORT }, (info) => {
8
+ console.log(
9
+ `API running on ${isProd ? `port ${info.port}` : `http://localhost:${info.port}`}`,
10
+ );
11
+ });
12
+
13
+ export type AppType = typeof app;
@@ -0,0 +1,46 @@
1
+ import { drizzleAdapter } from "@better-auth/drizzle-adapter";
2
+ import { createId } from "@paralleldrive/cuid2";
3
+ import { betterAuth } from "better-auth";
4
+ import { apiEnv, authEnv } from "@repo/config";
5
+ import {
6
+ accountsTable,
7
+ db,
8
+ sessionsTable,
9
+ usersTable,
10
+ verificationsTable,
11
+ } from "@repo/db";
12
+ import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from "@repo/shared";
13
+
14
+ export const auth = betterAuth({
15
+ baseURL: authEnv.BETTER_AUTH_URL,
16
+ secret: authEnv.BETTER_AUTH_SECRET,
17
+ basePath: "/api/auth",
18
+ database: drizzleAdapter(db, {
19
+ provider: "pg",
20
+ schema: {
21
+ user: usersTable,
22
+ session: sessionsTable,
23
+ account: accountsTable,
24
+ verification: verificationsTable,
25
+ },
26
+ }),
27
+ emailAndPassword: {
28
+ enabled: true,
29
+ minPasswordLength: PASSWORD_MIN_LENGTH,
30
+ maxPasswordLength: PASSWORD_MAX_LENGTH,
31
+ },
32
+ // To add Google sign-in: add GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET to
33
+ // authSchema in packages/config, then uncomment this block.
34
+ // socialProviders: {
35
+ // google: {
36
+ // clientId: authEnv.GOOGLE_CLIENT_ID,
37
+ // clientSecret: authEnv.GOOGLE_CLIENT_SECRET,
38
+ // },
39
+ // },
40
+ advanced: {
41
+ database: {
42
+ generateId: () => createId(),
43
+ },
44
+ },
45
+ trustedOrigins: [apiEnv.CORS_ORIGIN],
46
+ });
@@ -0,0 +1,28 @@
1
+ import type { Context } from "hono";
2
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
3
+ import type { ApiError, ApiSuccess } from "@repo/shared";
4
+ import type { AppEnv } from "../types/context";
5
+
6
+ /** The signed-in user. Only call behind `requireAuth()`. */
7
+ export function getUser(c: Context<AppEnv>) {
8
+ const user = c.get("user");
9
+ if (!user) throw new Error("No user on the context — requireAuth() missing");
10
+ return user;
11
+ }
12
+
13
+ export function ok<T>(c: Context, data: T, status: ContentfulStatusCode = 200) {
14
+ return c.json({ success: true, data } satisfies ApiSuccess<T>, status);
15
+ }
16
+
17
+ export function fail(
18
+ c: Context,
19
+ code: string,
20
+ message: string,
21
+ status: ContentfulStatusCode = 400,
22
+ details?: unknown,
23
+ ) {
24
+ return c.json(
25
+ { success: false, error: { code, message, details } } satisfies ApiError,
26
+ status,
27
+ );
28
+ }
@@ -0,0 +1,22 @@
1
+ import { auth } from "./auth";
2
+
3
+ export type Actor = {
4
+ id: string;
5
+ name: string;
6
+ email: string;
7
+ };
8
+
9
+ export type ResolvedSession = {
10
+ user: Actor;
11
+ sessionId: string;
12
+ } | null;
13
+
14
+ export type SessionResolver = (headers: Headers) => Promise<ResolvedSession>;
15
+
16
+ export const resolveSession: SessionResolver = async (headers) => {
17
+ const session = await auth.api.getSession({ headers });
18
+ if (!session) return null;
19
+
20
+ const { id, name, email } = session.user;
21
+ return { user: { id, name, email }, sessionId: session.session.id };
22
+ };
@@ -0,0 +1,34 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import { createMiddleware } from "hono/factory";
3
+ import { fail } from "../lib/response";
4
+ import type { SessionResolver } from "../lib/session";
5
+ import type { AppEnv } from "../types/context";
6
+
7
+ /** Puts the request's user (or null) on the context for every /api route. */
8
+ export function createAuthMiddleware(
9
+ resolver: SessionResolver,
10
+ ): MiddlewareHandler<AppEnv> {
11
+ return async (c, next) => {
12
+ c.set("user", null);
13
+ c.set("sessionId", null);
14
+
15
+ const path = c.req.path;
16
+ if (path.startsWith("/api/auth") || path === "/api/health") return next();
17
+
18
+ const resolved = await resolver(c.req.raw.headers);
19
+ if (resolved) {
20
+ c.set("user", resolved.user);
21
+ c.set("sessionId", resolved.sessionId);
22
+ }
23
+
24
+ await next();
25
+ };
26
+ }
27
+
28
+ /** Rejects the request with 401 unless someone is signed in. */
29
+ export const requireAuth = () =>
30
+ createMiddleware<AppEnv>(async (c, next) => {
31
+ if (!c.get("user"))
32
+ return fail(c, "UNAUTHORIZED", "You need to sign in", 401);
33
+ await next();
34
+ });
@@ -0,0 +1,9 @@
1
+ import { Hono } from "hono";
2
+ import { auth } from "../lib/auth";
3
+ import type { AppEnv } from "../types/context";
4
+
5
+ const authRouter = new Hono<AppEnv>().on(["GET", "POST"], "/*", (c) =>
6
+ auth.handler(c.req.raw),
7
+ );
8
+
9
+ export default authRouter;
@@ -0,0 +1,8 @@
1
+ import { Hono } from "hono";
2
+ import type { AppEnv } from "../types/context";
3
+ import authRouter from "./auth";
4
+ import me from "./me";
5
+
6
+ const api = new Hono<AppEnv>().route("/auth", authRouter).route("/me", me);
7
+
8
+ export default api;
@@ -0,0 +1,45 @@
1
+ import { isCuid } from "@paralleldrive/cuid2";
2
+ import { testClient } from "hono/testing";
3
+ import { describe, expect, it } from "vitest";
4
+ import app from "../app";
5
+
6
+ // Runs against the real test database (see src/test/global-setup.ts).
7
+
8
+ const ORIGIN = "http://localhost:3000";
9
+
10
+ async function signUp() {
11
+ const email = `user-${crypto.randomUUID()}@example.com`;
12
+ const res = await app.request("/api/auth/sign-up/email", {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/json", Origin: ORIGIN },
15
+ body: JSON.stringify({ name: "Test User", email, password: "password123" }),
16
+ });
17
+ expect(res.status).toBe(200);
18
+
19
+ const cookie = res.headers
20
+ .getSetCookie()
21
+ .map((c) => c.split(";")[0])
22
+ .join("; ");
23
+ return { email, cookie };
24
+ }
25
+
26
+ describe("GET /api/me", () => {
27
+ const client = testClient(app);
28
+
29
+ it("returns the signed-in user, with a cuid2 id", async () => {
30
+ const { email, cookie } = await signUp();
31
+
32
+ const res = await client.api.me.$get({}, { headers: { cookie } });
33
+ expect(res.status).toBe(200);
34
+
35
+ const body = await res.json();
36
+ if (!body.success) throw new Error(body.error.message);
37
+ expect(body.data).toMatchObject({ email, name: "Test User" });
38
+ expect(isCuid(body.data.id)).toBe(true);
39
+ });
40
+
41
+ it("returns 401 without a session cookie", async () => {
42
+ const res = await client.api.me.$get();
43
+ expect(res.status).toBe(401);
44
+ });
45
+ });
@@ -0,0 +1,29 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { Hono } from "hono";
3
+ import { db, usersTable } from "@repo/db";
4
+ import { fail, getUser, ok } from "../lib/response";
5
+ import { requireAuth } from "../middleware/auth";
6
+ import type { AppEnv } from "../types/context";
7
+
8
+ const me = new Hono<AppEnv>().get("/", requireAuth(), async (c) => {
9
+ const { id } = getUser(c);
10
+
11
+ const [user] = await db
12
+ .select({
13
+ id: usersTable.id,
14
+ name: usersTable.name,
15
+ email: usersTable.email,
16
+ emailVerified: usersTable.emailVerified,
17
+ image: usersTable.image,
18
+ createdAt: usersTable.createdAt,
19
+ updatedAt: usersTable.updatedAt,
20
+ })
21
+ .from(usersTable)
22
+ .where(eq(usersTable.id, id))
23
+ .limit(1);
24
+
25
+ if (!user) return fail(c, "NOT_FOUND", "User not found", 404);
26
+ return ok(c, user);
27
+ });
28
+
29
+ export default me;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The database the suite runs against: TEST_DATABASE_URL if set, otherwise
3
+ * DATABASE_URL with `_test` appended to its name, so tests never touch the
4
+ * dev database.
5
+ */
6
+ export function testDatabaseUrl() {
7
+ const explicit = process.env.TEST_DATABASE_URL;
8
+ const base = explicit ?? process.env.DATABASE_URL;
9
+
10
+ if (!base)
11
+ throw new Error(
12
+ "Set DATABASE_URL (or TEST_DATABASE_URL) before running the apps/api suite.",
13
+ );
14
+
15
+ if (explicit) return explicit;
16
+
17
+ const url = new URL(base);
18
+ const name = url.pathname.replace(/^\//, "");
19
+ if (!name) throw new Error("No database name in DATABASE_URL");
20
+ if (!name.endsWith("_test")) url.pathname = `/${name}_test`;
21
+
22
+ return url.toString();
23
+ }
@@ -0,0 +1,36 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { drizzle } from "drizzle-orm/node-postgres";
3
+ import { migrate } from "drizzle-orm/node-postgres/migrator";
4
+ import { Client, Pool } from "pg";
5
+ import { testDatabaseUrl } from "./database-url";
6
+
7
+ const MIGRATIONS = fileURLToPath(
8
+ new URL("../../../../packages/db/drizzle", import.meta.url),
9
+ );
10
+
11
+ /** Creates the test database if it's missing, then applies every migration. */
12
+ export default async function setup() {
13
+ const url = testDatabaseUrl();
14
+ const name = new URL(url).pathname.replace(/^\//, "");
15
+
16
+ const admin = new URL(url);
17
+ admin.pathname = "/postgres";
18
+ const client = new Client({ connectionString: admin.toString() });
19
+ await client.connect();
20
+ try {
21
+ const { rowCount } = await client.query(
22
+ "select 1 from pg_database where datname = $1",
23
+ [name],
24
+ );
25
+ if (!rowCount) await client.query(`CREATE DATABASE "${name}"`);
26
+ } finally {
27
+ await client.end();
28
+ }
29
+
30
+ const pool = new Pool({ connectionString: url });
31
+ try {
32
+ await migrate(drizzle(pool), { migrationsFolder: MIGRATIONS });
33
+ } finally {
34
+ await pool.end();
35
+ }
36
+ }
@@ -0,0 +1,8 @@
1
+ import { testDatabaseUrl } from "./database-url";
2
+
3
+ process.env.DATABASE_URL = testDatabaseUrl();
4
+ process.env.NODE_ENV = "test";
5
+
6
+ process.env.BETTER_AUTH_URL ??= "http://localhost:3002";
7
+ process.env.BETTER_AUTH_SECRET ??= "test-secret-at-least-32-characters-long!!";
8
+ process.env.CORS_ORIGIN ??= "http://localhost:3000";
@@ -0,0 +1,8 @@
1
+ import type { Actor } from "../lib/session";
2
+
3
+ export type AppEnv = {
4
+ Variables: {
5
+ user: Actor | null;
6
+ sessionId: string | null;
7
+ };
8
+ };
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "composite": false,
5
+ "noEmit": false
6
+ },
7
+ "exclude": ["src/**/*.test.ts", "src/test"]
8
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "src",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src"]
9
+ }
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig({
4
+ tsconfig: "tsconfig.build.json",
5
+ entry: ["src/index.ts"],
6
+ format: ["esm"],
7
+ // The declarations are what apps/web imports `AppType` from.
8
+ dts: true,
9
+ sourcemap: true,
10
+ clean: true,
11
+ treeshake: true,
12
+ target: false,
13
+ });
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globalSetup: ["./src/test/global-setup.ts"],
6
+ setupFiles: ["./src/test/setup.ts"],
7
+ fileParallelism: false,
8
+ hookTimeout: 30_000,
9
+ testTimeout: 30_000,
10
+ },
11
+ });
@@ -0,0 +1,49 @@
1
+ FROM node:24-alpine AS builder
2
+ WORKDIR /app
3
+
4
+ # Vite resolves the root tsconfig's project references, so the tsconfigs of
5
+ # workspaces this image doesn't build (api, db) still need to be present.
6
+ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.json ./
7
+ COPY apps/api/package.json apps/api/tsconfig.json ./apps/api/
8
+ COPY apps/web/package.json ./apps/web/
9
+ COPY packages/config/package.json ./packages/config/
10
+ COPY packages/db/package.json packages/db/tsconfig.json ./packages/db/
11
+ COPY packages/shared/package.json ./packages/shared/
12
+
13
+ RUN corepack enable && pnpm install --frozen-lockfile --filter @repo/web...
14
+
15
+ COPY apps/web ./apps/web
16
+ COPY packages/config ./packages/config
17
+ COPY packages/shared ./packages/shared
18
+
19
+ # @repo/api is a types-only devDependency; the bundler drops those imports,
20
+ # so the api itself doesn't need building here.
21
+ RUN pnpm --filter @repo/config --filter @repo/shared --filter @repo/web build
22
+
23
+ FROM node:24-alpine
24
+ WORKDIR /app
25
+
26
+ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
27
+ COPY apps/api/package.json ./apps/api/
28
+ COPY apps/web/package.json ./apps/web/
29
+ COPY packages/config/package.json ./packages/config/
30
+ COPY packages/db/package.json ./packages/db/
31
+ COPY packages/shared/package.json ./packages/shared/
32
+
33
+ RUN corepack enable && pnpm install --frozen-lockfile --filter @repo/web... --prod
34
+
35
+ COPY --from=builder /app/packages/config/dist ./packages/config/dist
36
+ COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
37
+ COPY --from=builder /app/apps/web/dist ./apps/web/dist
38
+
39
+ ENV NODE_ENV=production
40
+ ENV PORT=3000
41
+ EXPOSE 3000
42
+
43
+ USER node
44
+ WORKDIR /app/apps/web
45
+
46
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
47
+ CMD node -e "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
48
+
49
+ CMD ["node_modules/.bin/react-router-serve", "dist/server/index.js"]
@@ -0,0 +1,29 @@
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ --font-sans:
5
+ ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
6
+ "Helvetica Neue", Arial, sans-serif;
7
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
8
+
9
+ --color-ink: oklch(0.21 0.01 60);
10
+ --color-ink-muted: oklch(0.5 0.01 60);
11
+ --color-surface: oklch(0.99 0.003 80);
12
+ --color-surface-raised: oklch(1 0 0);
13
+ --color-line: oklch(0.91 0.006 80);
14
+ --color-accent: oklch(0.62 0.19 40);
15
+ --color-accent-strong: oklch(0.54 0.19 38);
16
+ --color-danger: oklch(0.55 0.2 25);
17
+ }
18
+
19
+ @layer base {
20
+ html {
21
+ color-scheme: light;
22
+ -webkit-font-smoothing: antialiased;
23
+ -moz-osx-font-smoothing: grayscale;
24
+ }
25
+
26
+ body {
27
+ @apply bg-surface font-sans text-ink;
28
+ }
29
+ }
@@ -0,0 +1,13 @@
1
+ import { hc } from "hono/client";
2
+ import type { AppType } from "@repo/api";
3
+ import { serverEnv } from "./env.server";
4
+
5
+ /**
6
+ * A typed api client for loaders and actions. It forwards the incoming
7
+ * request's cookies, so calls run as the signed-in user.
8
+ */
9
+ export function api(request: Request) {
10
+ return hc<AppType>(serverEnv.INTERNAL_API_URL, {
11
+ headers: { cookie: request.headers.get("cookie") ?? "" },
12
+ });
13
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ApiRequestError, unwrap } from "./api";
3
+
4
+ const respond = (status: number, body: unknown) =>
5
+ new Response(JSON.stringify(body), { status });
6
+
7
+ describe("unwrap", () => {
8
+ it("returns the data of a successful response", async () => {
9
+ const data = await unwrap(
10
+ respond(200, { success: true, data: { id: "abc" } }),
11
+ );
12
+ expect(data).toEqual({ id: "abc" });
13
+ });
14
+
15
+ it("throws the api's error for a failed response", async () => {
16
+ const promise = unwrap(
17
+ respond(401, {
18
+ success: false,
19
+ error: { code: "UNAUTHORIZED", message: "You need to sign in" },
20
+ }),
21
+ );
22
+ await expect(promise).rejects.toBeInstanceOf(ApiRequestError);
23
+ await expect(promise).rejects.toMatchObject({
24
+ code: "UNAUTHORIZED",
25
+ status: 401,
26
+ });
27
+ });
28
+ });