@techstream/quark-create-app 1.5.2 → 1.6.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 (53) hide show
  1. package/README.md +38 -0
  2. package/package.json +5 -2
  3. package/src/index.js +142 -12
  4. package/templates/base-project/.github/copilot-instructions.md +7 -0
  5. package/templates/base-project/.github/dependabot.yml +12 -0
  6. package/templates/base-project/.github/skills/project-context/SKILL.md +106 -0
  7. package/templates/base-project/.github/workflows/ci.yml +97 -0
  8. package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
  9. package/templates/base-project/.github/workflows/release.yml +38 -0
  10. package/templates/base-project/apps/web/biome.json +7 -0
  11. package/templates/base-project/apps/web/jsconfig.json +10 -0
  12. package/templates/base-project/apps/web/next.config.js +86 -1
  13. package/templates/base-project/apps/web/package.json +4 -4
  14. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +9 -9
  15. package/templates/base-project/apps/web/src/app/api/files/route.js +3 -2
  16. package/templates/base-project/apps/web/src/app/layout.js +3 -4
  17. package/templates/base-project/apps/web/src/app/manifest.js +12 -0
  18. package/templates/base-project/apps/web/src/app/robots.js +21 -0
  19. package/templates/base-project/apps/web/src/app/sitemap.js +20 -0
  20. package/templates/base-project/apps/web/src/lib/seo/indexing.js +23 -0
  21. package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +33 -0
  22. package/templates/base-project/apps/web/src/proxy.js +1 -2
  23. package/templates/base-project/apps/worker/package.json +4 -4
  24. package/templates/base-project/apps/worker/src/index.js +40 -12
  25. package/templates/base-project/apps/worker/src/index.test.js +296 -15
  26. package/templates/base-project/biome.json +44 -0
  27. package/templates/base-project/docker-compose.yml +7 -4
  28. package/templates/base-project/package.json +1 -1
  29. package/templates/base-project/packages/db/package.json +1 -1
  30. package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +42 -0
  31. package/templates/base-project/packages/db/prisma/schema.prisma +20 -16
  32. package/templates/base-project/packages/db/prisma.config.ts +3 -2
  33. package/templates/base-project/packages/db/scripts/seed.js +117 -30
  34. package/templates/base-project/packages/db/src/queries.js +58 -68
  35. package/templates/base-project/packages/db/src/queries.test.js +0 -29
  36. package/templates/base-project/packages/db/src/schemas.js +4 -10
  37. package/templates/base-project/pnpm-workspace.yaml +4 -0
  38. package/templates/base-project/turbo.json +5 -3
  39. package/templates/config/package.json +2 -0
  40. package/templates/config/src/environment.js +270 -0
  41. package/templates/config/src/index.js +10 -18
  42. package/templates/config/src/load-config.js +135 -0
  43. package/templates/config/src/validate-env.js +60 -2
  44. package/templates/jobs/package.json +2 -2
  45. package/templates/jobs/src/definitions.test.js +34 -0
  46. package/templates/jobs/src/index.js +1 -1
  47. package/templates/ui/package.json +4 -4
  48. package/templates/ui/src/button.test.js +23 -0
  49. package/templates/ui/src/index.js +1 -3
  50. package/templates/base-project/apps/web/src/app/api/posts/[id]/route.js +0 -65
  51. package/templates/base-project/apps/web/src/app/api/posts/route.js +0 -42
  52. package/templates/ui/src/card.js +0 -14
  53. package/templates/ui/src/input.js +0 -11
@@ -1,6 +1,91 @@
1
1
  /** @type {import('next').NextConfig} */
2
2
  const nextConfig = {
3
- // No TypeScript configuration needed for JS-only projects
3
+ // Support workspace package resolution (including @techstream/quark-db which uses
4
+ // the Prisma driver-adapter pattern — pure JS, no native engine binary)
5
+ transpilePackages: [
6
+ "@techstream/quark-core",
7
+ "@techstream/quark-db",
8
+ "@techstream/quark-ui",
9
+ "@techstream/quark-jobs",
10
+ ],
11
+
12
+ // Security headers
13
+ // NOTE: These are also applied by proxy.js for proxy-matched routes.
14
+ // Keeping them here as a fallback for routes the proxy doesn't match.
15
+ async headers() {
16
+ return [
17
+ {
18
+ source: "/:path*",
19
+ headers: [
20
+ {
21
+ key: "X-DNS-Prefetch-Control",
22
+ value: "on",
23
+ },
24
+ {
25
+ key: "X-Frame-Options",
26
+ value: "SAMEORIGIN",
27
+ },
28
+ {
29
+ key: "X-Content-Type-Options",
30
+ value: "nosniff",
31
+ },
32
+ {
33
+ key: "Referrer-Policy",
34
+ value: "strict-origin-when-cross-origin",
35
+ },
36
+ {
37
+ key: "Permissions-Policy",
38
+ value: "camera=(), microphone=(), geolocation=()",
39
+ },
40
+ {
41
+ key: "Content-Security-Policy",
42
+ value:
43
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self';",
44
+ },
45
+ ],
46
+ },
47
+ ];
48
+ },
49
+
50
+ // Environment variables validation
51
+ env: {
52
+ APP_URL: process.env.APP_URL,
53
+ NEXTAUTH_URL: process.env.NEXTAUTH_URL || process.env.APP_URL,
54
+ NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
55
+ },
56
+
57
+ // Request body size limits (security)
58
+ experimental: {
59
+ // Limit request body size to prevent DoS attacks
60
+ // Default is 4MB, we're being explicit here
61
+ // Adjust based on your needs (e.g., larger for file uploads)
62
+ serverActions: {
63
+ bodySizeLimit: "2mb", // For Server Actions
64
+ },
65
+ },
66
+
67
+ // API route configuration
68
+ async rewrites() {
69
+ return [];
70
+ },
71
+
72
+ // Compiler options for production optimization
73
+ compiler: {
74
+ removeConsole:
75
+ process.env.NODE_ENV === "production"
76
+ ? { exclude: ["error", "warn"] }
77
+ : false,
78
+ },
79
+
80
+ // Image optimization configuration
81
+ images: {
82
+ domains: [],
83
+ formats: ["image/avif", "image/webp"],
84
+ },
85
+
86
+ // Production-only settings
87
+ poweredByHeader: false,
88
+ compress: true,
4
89
  };
5
90
 
6
91
  export default nextConfig;
@@ -25,9 +25,9 @@
25
25
  "zod": "^4.3.6"
26
26
  },
27
27
  "devDependencies": {
28
- "@techstream/quark-config": "workspace:*",
29
- "@tailwindcss/postcss": "^4",
30
- "@types/node": "^20",
31
- "tailwindcss": "^4"
28
+ "@tailwindcss/postcss": "^4.1.18",
29
+ "@types/node": "^20.19.33",
30
+ "tailwindcss": "^4.1.18",
31
+ "@techstream/quark-config": "workspace:*"
32
32
  }
33
33
  }
@@ -2,13 +2,14 @@ import {
2
2
  createQueue,
3
3
  hashPassword,
4
4
  validateBody,
5
+ withCsrfProtection,
5
6
  } from "@techstream/quark-core";
6
7
  import { user, userRegisterSchema } from "@techstream/quark-db";
7
8
  import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
8
9
  import { NextResponse } from "next/server";
9
10
  import { handleError } from "../../error-handler";
10
11
 
11
- export async function POST(request) {
12
+ export const POST = withCsrfProtection(async (request) => {
12
13
  try {
13
14
  const data = await validateBody(request, userRegisterSchema);
14
15
 
@@ -34,22 +35,21 @@ export async function POST(request) {
34
35
  password: hashedPassword,
35
36
  });
36
37
 
37
- // Enqueue welcome email (fire-and-forget)
38
+ // Don't return the password
39
+ const { password: _, ...safeUser } = newUser;
40
+
41
+ // Enqueue welcome email (fire-and-forget — don't block the response)
38
42
  try {
39
43
  const emailQueue = createQueue(JOB_QUEUES.EMAIL);
40
44
  await emailQueue.add(JOB_NAMES.SEND_WELCOME_EMAIL, {
41
45
  userId: newUser.id,
42
46
  });
43
- } catch (emailError) {
44
- // Don't fail registration if email enqueue fails
45
- console.error("Failed to enqueue welcome email:", emailError);
47
+ } catch {
48
+ // Non-critical user is created even if email fails to enqueue
46
49
  }
47
50
 
48
- // Don't return the password
49
- const { password: _, ...safeUser } = newUser;
50
-
51
51
  return NextResponse.json(safeUser, { status: 201 });
52
52
  } catch (error) {
53
53
  return handleError(error);
54
54
  }
55
- }
55
+ });
@@ -9,6 +9,7 @@ import {
9
9
  generateStorageKey,
10
10
  parseMultipart,
11
11
  validateFile,
12
+ withCsrfProtection,
12
13
  } from "@techstream/quark-core";
13
14
  import { file } from "@techstream/quark-db";
14
15
  import { NextResponse } from "next/server";
@@ -26,7 +27,7 @@ const paginationSchema = z.object({
26
27
  * Upload one or more files via multipart/form-data.
27
28
  * Requires authentication.
28
29
  */
29
- export async function POST(request) {
30
+ export const POST = withCsrfProtection(async (request) => {
30
31
  try {
31
32
  const session = await requireAuth();
32
33
 
@@ -94,7 +95,7 @@ export async function POST(request) {
94
95
  } catch (error) {
95
96
  return handleError(error);
96
97
  }
97
- }
98
+ });
98
99
 
99
100
  /**
100
101
  * GET /api/files
@@ -1,7 +1,6 @@
1
- export const metadata = {
2
- title: "Quark",
3
- description: "A modern monorepo with Next.js, React, and Prisma",
4
- };
1
+ import { getSiteMetadata } from "../lib/seo/site-metadata.js";
2
+
3
+ export const metadata = getSiteMetadata();
5
4
 
6
5
  export default function RootLayout({ children }) {
7
6
  return (
@@ -0,0 +1,12 @@
1
+ import { config, getAppUrl } from "@techstream/quark-config";
2
+
3
+ export default function manifest() {
4
+ return {
5
+ name: config.appName,
6
+ short_name: config.appName,
7
+ description: config.appDescription,
8
+ start_url: "/",
9
+ display: "standalone",
10
+ id: getAppUrl(),
11
+ };
12
+ }
@@ -0,0 +1,21 @@
1
+ import { getAppUrl } from "@techstream/quark-config";
2
+ import { isWebsiteIndexable } from "../lib/seo/indexing.js";
3
+
4
+ export default function robots() {
5
+ const appUrl = getAppUrl();
6
+ const indexable = isWebsiteIndexable();
7
+
8
+ return {
9
+ rules: indexable
10
+ ? {
11
+ userAgent: "*",
12
+ allow: "/",
13
+ disallow: ["/api/"],
14
+ }
15
+ : {
16
+ userAgent: "*",
17
+ disallow: "/",
18
+ },
19
+ sitemap: indexable ? `${appUrl}/sitemap.xml` : undefined,
20
+ };
21
+ }
@@ -0,0 +1,20 @@
1
+ import { getAppUrl } from "@techstream/quark-config";
2
+ import { isWebsiteIndexable } from "../lib/seo/indexing.js";
3
+
4
+ const STATIC_ROUTES = [{ path: "/", changeFrequency: "daily", priority: 1 }];
5
+
6
+ export default function sitemap() {
7
+ if (!isWebsiteIndexable()) {
8
+ return [];
9
+ }
10
+
11
+ const appUrl = getAppUrl();
12
+ const lastModified = new Date();
13
+
14
+ return STATIC_ROUTES.map((route) => ({
15
+ url: `${appUrl}${route.path}`,
16
+ lastModified,
17
+ changeFrequency: route.changeFrequency,
18
+ priority: route.priority,
19
+ }));
20
+ }
@@ -0,0 +1,23 @@
1
+ export function isWebsiteIndexable(env = process.env) {
2
+ return (env.NODE_ENV || "").toLowerCase() === "production";
3
+ }
4
+
5
+ export function getMetadataRobots(env = process.env) {
6
+ if (isWebsiteIndexable(env)) {
7
+ return {
8
+ index: true,
9
+ follow: true,
10
+ };
11
+ }
12
+
13
+ return {
14
+ index: false,
15
+ follow: false,
16
+ nocache: true,
17
+ googleBot: {
18
+ index: false,
19
+ follow: false,
20
+ noimageindex: true,
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,33 @@
1
+ import { config, getAppUrl } from "@techstream/quark-config";
2
+ import { getMetadataRobots } from "./indexing.js";
3
+
4
+ const appUrl = getAppUrl();
5
+ const { appName, appDescription } = config;
6
+
7
+ export function getSiteMetadata() {
8
+ return {
9
+ metadataBase: new URL(appUrl),
10
+ title: {
11
+ default: appName,
12
+ template: `%s | ${appName}`,
13
+ },
14
+ description: appDescription,
15
+ applicationName: appName,
16
+ alternates: {
17
+ canonical: "/",
18
+ },
19
+ openGraph: {
20
+ type: "website",
21
+ url: appUrl,
22
+ title: appName,
23
+ description: appDescription,
24
+ siteName: appName,
25
+ },
26
+ twitter: {
27
+ card: "summary",
28
+ title: appName,
29
+ description: appDescription,
30
+ },
31
+ robots: getMetadataRobots(),
32
+ };
33
+ }
@@ -151,8 +151,7 @@ export function proxy(request) {
151
151
 
152
152
  // Apply rate limiting to API routes only
153
153
  if (pathname.startsWith("/api/")) {
154
- const ip =
155
- request.ip || request.headers.get("x-forwarded-for") || "unknown";
154
+ const ip = request.ip || "unknown";
156
155
  const rateLimitResult = checkRateLimit(ip, pathname);
157
156
 
158
157
  if (rateLimitResult.limited) {
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@techstream/quark-worker",
3
3
  "version": "1.0.0",
4
- "type": "module",
5
4
  "private": true,
5
+ "type": "module",
6
6
  "description": "",
7
7
  "main": "index.js",
8
8
  "scripts": {
@@ -18,11 +18,11 @@
18
18
  "@techstream/quark-core": "^1.0.0",
19
19
  "@techstream/quark-db": "workspace:*",
20
20
  "@techstream/quark-jobs": "workspace:*",
21
- "bullmq": "^5.64.1"
21
+ "bullmq": "^5.67.3"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@techstream/quark-config": "workspace:*",
25
- "@types/node": "^24.10.1",
26
- "tsx": "^4.20.6"
25
+ "@types/node": "^24.10.12",
26
+ "tsx": "^4.21.0"
27
27
  }
28
28
  }
@@ -17,6 +17,7 @@ const logger = createLogger("worker");
17
17
 
18
18
  // Store workers for graceful shutdown
19
19
  const workers = [];
20
+ let isShuttingDown = false;
20
21
 
21
22
  /**
22
23
  * Generic queue processor — dispatches jobs to registered handlers
@@ -56,6 +57,20 @@ function createQueueWorker(queueName) {
56
57
  );
57
58
  });
58
59
 
60
+ queueWorker.on("stalled", (jobId) => {
61
+ logger.warn(`Job ${jobId} in queue "${queueName}" has stalled`, {
62
+ queueName,
63
+ jobId,
64
+ });
65
+ });
66
+
67
+ queueWorker.on("error", (error) => {
68
+ logger.error(`Worker error in queue "${queueName}"`, {
69
+ error: error.message,
70
+ queueName,
71
+ });
72
+ });
73
+
59
74
  logger.info(
60
75
  `Queue "${queueName}" worker started (concurrency: ${queueWorker.opts.concurrency})`,
61
76
  );
@@ -99,15 +114,31 @@ async function startWorker() {
99
114
  /**
100
115
  * Graceful shutdown handler
101
116
  */
102
- async function shutdown() {
103
- logger.info("Shutting down worker service");
117
+ async function shutdown(signal = "unknown") {
118
+ if (isShuttingDown) {
119
+ logger.warn("Shutdown already in progress", { signal });
120
+ return;
121
+ }
122
+
123
+ isShuttingDown = true;
124
+ logger.info("Shutting down worker service", { signal });
104
125
 
105
126
  try {
106
127
  for (const worker of workers) {
107
128
  await worker.close();
108
129
  }
109
130
 
110
- await prisma.$disconnect();
131
+ try {
132
+ await prisma.$disconnect();
133
+ } catch (error) {
134
+ if (error.message?.includes("environment variable is required")) {
135
+ logger.warn("Skipping Prisma disconnect due missing database env", {
136
+ error: error.message,
137
+ });
138
+ } else {
139
+ throw error;
140
+ }
141
+ }
111
142
 
112
143
  logger.info("All workers closed");
113
144
  process.exit(0);
@@ -120,14 +151,11 @@ async function shutdown() {
120
151
  }
121
152
  }
122
153
 
123
- process.on("SIGTERM", shutdown);
124
- process.on("SIGINT", shutdown);
125
-
126
- startWorker();
127
-
128
- // Register shutdown handlers
129
- process.on("SIGTERM", shutdown);
130
- process.on("SIGINT", shutdown);
154
+ process.on("SIGTERM", () => {
155
+ void shutdown("SIGTERM");
156
+ });
157
+ process.on("SIGINT", () => {
158
+ void shutdown("SIGINT");
159
+ });
131
160
 
132
- // Start the worker service
133
161
  startWorker();