@usequark/quark-create-app 1.24.3 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.js +17 -2
- package/templates/base-project/apps/web/src/app/api/health/route.js +200 -97
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +20 -0
- package/templates/base-project/apps/web/src/proxy.js +6 -4
- package/templates/worker/src/index.js +3 -3
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -214,8 +214,23 @@ async function initializeGit(projectDir) {
|
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
try {
|
|
217
|
-
// Initialize git repo
|
|
218
|
-
|
|
217
|
+
// Initialize git repo.
|
|
218
|
+
//
|
|
219
|
+
// `-b main` pins the branch name rather than inheriting whatever the
|
|
220
|
+
// machine has in `init.defaultBranch`. Without it the scaffold is not
|
|
221
|
+
// reproducible: `git init` falls back to `master` wherever that setting
|
|
222
|
+
// is unset (the common case, and GitHub's own default until the hint
|
|
223
|
+
// is followed), so the same CLI produced `master` on one machine and
|
|
224
|
+
// `main` on another. `main` is the name GitHub defaults a new repository
|
|
225
|
+
// to, so it is also what the README's push instructions assume.
|
|
226
|
+
//
|
|
227
|
+
// `-b` needs git 2.28 (2020). If it is unavailable the bare `init` still
|
|
228
|
+
// produces a working repository, just with git's own default branch.
|
|
229
|
+
try {
|
|
230
|
+
await execa("git", ["init", "-b", "main"], { cwd: projectDir });
|
|
231
|
+
} catch {
|
|
232
|
+
await execa("git", ["init"], { cwd: projectDir });
|
|
233
|
+
}
|
|
219
234
|
|
|
220
235
|
// Add all files
|
|
221
236
|
await execa("git", ["add", "."], { cwd: projectDir });
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Health Check Endpoint
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Reports the status of the application and its dependencies. Every dependency
|
|
5
|
+
* is probed concurrently, each under its own deadline, and the route always
|
|
6
|
+
* answers `200` — the verdict lives in the body's `status` field.
|
|
7
|
+
*
|
|
8
|
+
* Why always 200: this endpoint is the platform healthcheck. A non-200 here
|
|
9
|
+
* makes the orchestrator restart the container, which drops every warm
|
|
10
|
+
* connection and produces a fresh connection storm on the dependency that was
|
|
11
|
+
* already struggling. That turns a transient blip into a restart loop.
|
|
12
|
+
* Callers that need a hard verdict should read `status`, not the status code.
|
|
5
13
|
*/
|
|
6
14
|
|
|
7
15
|
import {
|
|
@@ -15,146 +23,241 @@ import { NextResponse } from "next/server";
|
|
|
15
23
|
|
|
16
24
|
const logger = createLogger("health");
|
|
17
25
|
|
|
18
|
-
/**
|
|
26
|
+
/** Ceiling for the whole check (ms). */
|
|
19
27
|
const HEALTH_CHECK_TIMEOUT = 5000;
|
|
20
28
|
|
|
29
|
+
/** Ceiling for one dependency probe (ms). Clamped to the overall budget, so a
|
|
30
|
+
* single probe can never outlive the request. */
|
|
31
|
+
const PROBE_TIMEOUT = 3000;
|
|
32
|
+
|
|
33
|
+
/** Message returned in place of a real error when NODE_ENV is production. */
|
|
34
|
+
const GENERIC_MESSAGES = {
|
|
35
|
+
database: "Database unavailable",
|
|
36
|
+
redis: "Redis unavailable",
|
|
37
|
+
storage: "Storage unavailable",
|
|
38
|
+
queue: "Queue unavailable",
|
|
39
|
+
};
|
|
40
|
+
|
|
21
41
|
export async function GET() {
|
|
22
42
|
try {
|
|
23
|
-
const result = await
|
|
24
|
-
const timer = setTimeout(
|
|
25
|
-
() => reject(new Error("Health check timed out")),
|
|
26
|
-
HEALTH_CHECK_TIMEOUT,
|
|
27
|
-
);
|
|
28
|
-
runHealthChecks().then(
|
|
29
|
-
(val) => {
|
|
30
|
-
clearTimeout(timer);
|
|
31
|
-
resolve(val);
|
|
32
|
-
},
|
|
33
|
-
(err) => {
|
|
34
|
-
clearTimeout(timer);
|
|
35
|
-
reject(err);
|
|
36
|
-
},
|
|
37
|
-
);
|
|
38
|
-
});
|
|
43
|
+
const result = await runHealthChecks();
|
|
39
44
|
|
|
45
|
+
// Always 200 — see the module comment. An orchestrator restarting this
|
|
46
|
+
// service is what turns a dependency blip into an outage.
|
|
40
47
|
return NextResponse.json(result, {
|
|
41
|
-
status:
|
|
48
|
+
status: 200,
|
|
49
|
+
headers: { "Cache-Control": "no-store" },
|
|
42
50
|
});
|
|
43
51
|
} catch (error) {
|
|
52
|
+
// Unreachable in practice: every probe below is individually settled and
|
|
53
|
+
// deadline-bounded, so runHealthChecks cannot reject. Kept so a future
|
|
54
|
+
// edit that reintroduces a throw still returns a parseable body.
|
|
44
55
|
logger.error("Health check failed", {
|
|
45
56
|
error: error.message,
|
|
46
57
|
stack: error.stack,
|
|
47
58
|
});
|
|
59
|
+
|
|
48
60
|
return NextResponse.json(
|
|
49
61
|
{
|
|
50
62
|
status: "error",
|
|
51
63
|
timestamp: new Date().toISOString(),
|
|
52
64
|
message: "Service health check failed",
|
|
53
65
|
},
|
|
54
|
-
{ status:
|
|
66
|
+
{ status: 200, headers: { "Cache-Control": "no-store" } },
|
|
55
67
|
);
|
|
56
68
|
}
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/**
|
|
60
|
-
* Runs
|
|
61
|
-
*
|
|
72
|
+
* Runs every dependency probe concurrently and aggregates the results.
|
|
73
|
+
*
|
|
74
|
+
* Concurrency matters as much as the deadlines: run sequentially, a 3s database
|
|
75
|
+
* timeout plus a 3s Redis timeout exhausts the 5s budget before storage is even
|
|
76
|
+
* attempted, and the whole request falls through to the give-up path.
|
|
77
|
+
*
|
|
78
|
+
* @returns {Promise<{ status: string, timestamp: string, durationMs: number, checks: Record<string, object> }>}
|
|
62
79
|
*/
|
|
63
80
|
async function runHealthChecks() {
|
|
64
|
-
const
|
|
65
|
-
|
|
81
|
+
const startedAt = performance.now();
|
|
82
|
+
|
|
83
|
+
// Every probe is wrapped in settleProbe(), so this Promise.all cannot reject
|
|
84
|
+
// and cannot outlast the deadline.
|
|
85
|
+
const [database, redis, storage, queues] = await Promise.all([
|
|
86
|
+
settleProbe("database", () => pingDatabase()),
|
|
87
|
+
settleProbe("redis", () => pingRedis()),
|
|
88
|
+
// `provider` rides along on the error result so a failure still names the
|
|
89
|
+
// provider that failed — settleProbe owns the message redaction.
|
|
90
|
+
settleProbe("storage", checkStorage, {
|
|
91
|
+
provider: process.env.STORAGE_PROVIDER || "local",
|
|
92
|
+
}),
|
|
93
|
+
settleProbe("queues", checkQueues),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const checks = { database, redis, storage };
|
|
97
|
+
if (queues) checks.queues = queues;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
// "degraded" means at least one dependency failed. Both this and "ok"
|
|
101
|
+
// answer 200 — see the module comment.
|
|
102
|
+
status: Object.values(checks).some(isFailing) ? "degraded" : "ok",
|
|
66
103
|
timestamp: new Date().toISOString(),
|
|
67
|
-
|
|
104
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
105
|
+
checks,
|
|
68
106
|
};
|
|
107
|
+
}
|
|
69
108
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Reports whether a check entry counts as a failure. Handles both a flat probe
|
|
111
|
+
* result (`{ status: "error" }`) and the nested queue map, where the verdict
|
|
112
|
+
* lives on each queue rather than on the map itself.
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} check
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
function isFailing(check) {
|
|
118
|
+
if (!check || typeof check !== "object") return false;
|
|
119
|
+
|
|
120
|
+
// A flat probe result carries its own `status`. The nested queue map does
|
|
121
|
+
// not, so fall through to its entries.
|
|
122
|
+
if (typeof (/** @type {any} */ (check).status) === "string") {
|
|
123
|
+
return /** @type {any} */ (check).status === "error";
|
|
80
124
|
}
|
|
81
125
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
126
|
+
return Object.values(/** @type {Record<string, any>} */ (check)).some(
|
|
127
|
+
(entry) => entry?.status === "error",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Runs one dependency probe with its own deadline and normalises every failure
|
|
133
|
+
* mode — rejection, timeout, or a probe that never settles — into a result
|
|
134
|
+
* object. Never throws, so callers can rely on `Promise.all` resolving.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} name - Probe name, also the key under `checks`.
|
|
137
|
+
* @param {() => Promise<object>} probe
|
|
138
|
+
* @param {object} [context] - Fields to merge into the error result.
|
|
139
|
+
* @returns {Promise<{ status: string } & Record<string, unknown>>}
|
|
140
|
+
*/
|
|
141
|
+
async function settleProbe(name, probe, context = {}) {
|
|
142
|
+
// A probe that hangs must not hold up the aggregate, and must never outlive
|
|
143
|
+
// the overall budget. The timer is always cleared, so it never leaks.
|
|
144
|
+
let timer;
|
|
145
|
+
const deadline = new Promise((resolve) => {
|
|
146
|
+
timer = setTimeout(
|
|
147
|
+
() => resolve({ status: "error", message: `${name} check timed out` }),
|
|
148
|
+
Math.min(PROBE_TIMEOUT, HEALTH_CHECK_TIMEOUT),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
return withSafeMessage(name, await Promise.race([probe(), deadline]));
|
|
154
|
+
} catch (/** @type {any} */ error) {
|
|
155
|
+
return withSafeMessage(name, {
|
|
156
|
+
...context,
|
|
88
157
|
status: "error",
|
|
89
|
-
message:
|
|
90
|
-
};
|
|
91
|
-
|
|
158
|
+
message: error?.message,
|
|
159
|
+
});
|
|
160
|
+
} finally {
|
|
161
|
+
clearTimeout(timer);
|
|
92
162
|
}
|
|
163
|
+
}
|
|
93
164
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Returns a check result whose message is safe to serve publicly.
|
|
167
|
+
*
|
|
168
|
+
* A probe can signal failure two ways — by rejecting, or by *resolving* with
|
|
169
|
+
* `{ status: "error", message }`. Both are routed through here so neither can
|
|
170
|
+
* bypass the redaction: `pingRedis` returned `{ status: "error" }` carrying the
|
|
171
|
+
* raw `REDIS_URL`, so a normaliser that only handled rejections would have let
|
|
172
|
+
* the password straight through.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} name - Probe name.
|
|
175
|
+
* @param {object} [result]
|
|
176
|
+
* @returns {object}
|
|
177
|
+
*/
|
|
178
|
+
function withSafeMessage(name, result) {
|
|
179
|
+
if (result?.status !== "error") return result;
|
|
100
180
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const entries = [...registeredQueues.entries()];
|
|
105
|
-
const results = await Promise.allSettled(
|
|
106
|
-
entries.map(([, queue]) =>
|
|
107
|
-
Promise.all([
|
|
108
|
-
queue.getWaitingCount(),
|
|
109
|
-
queue.getActiveCount(),
|
|
110
|
-
queue.getFailedCount(),
|
|
111
|
-
]),
|
|
112
|
-
),
|
|
113
|
-
);
|
|
181
|
+
const { message, ...rest } = result;
|
|
182
|
+
return { ...rest, status: "error", message: safeMessage(name, message) };
|
|
183
|
+
}
|
|
114
184
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
health.checks.queues = queueChecks;
|
|
185
|
+
/**
|
|
186
|
+
* Reduces a failure to a message safe to return from an unauthenticated route.
|
|
187
|
+
*
|
|
188
|
+
* Driver errors carry the connection string they failed on, so the raw text is
|
|
189
|
+
* only echoed outside production. `pingRedis`/`pingDatabase` redact at the
|
|
190
|
+
* source regardless; this is the second layer.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} name - Probe name.
|
|
193
|
+
* @param {string|Error} [source] - The failure message or the error itself.
|
|
194
|
+
* @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
function safeMessage(name, source) {
|
|
197
|
+
if (process.env.NODE_ENV === "production") {
|
|
198
|
+
return GENERIC_MESSAGES[name] ?? "Check unavailable";
|
|
132
199
|
}
|
|
133
200
|
|
|
134
|
-
return
|
|
201
|
+
return source?.message ?? source ?? "Unknown error";
|
|
135
202
|
}
|
|
136
203
|
|
|
137
204
|
/**
|
|
138
205
|
* Verifies storage is reachable and writable by writing then deleting a
|
|
139
206
|
* small sentinel object. Uses whichever provider is configured via
|
|
140
207
|
* STORAGE_PROVIDER (defaults to "local" when unset).
|
|
141
|
-
* @returns {Promise<{ status: string, provider: string
|
|
208
|
+
* @returns {Promise<{ status: string, provider: string }>}
|
|
142
209
|
*/
|
|
143
210
|
async function checkStorage() {
|
|
144
211
|
const provider = process.env.STORAGE_PROVIDER || "local";
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
212
|
+
const storage = createStorage();
|
|
213
|
+
const sentinelKey = ".health-check-sentinel";
|
|
214
|
+
|
|
215
|
+
await storage.put(sentinelKey, Buffer.from("ok"), {
|
|
216
|
+
contentType: "text/plain",
|
|
217
|
+
});
|
|
218
|
+
await storage.delete(sentinelKey);
|
|
219
|
+
|
|
220
|
+
return { status: "ok", provider };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Reports queue depths. Only meaningful in a process that registered the
|
|
225
|
+
* queues — the web app registers none, so this resolves to null and the key is
|
|
226
|
+
* omitted from the response rather than reported as a false pass.
|
|
227
|
+
*
|
|
228
|
+
* @returns {Promise<Record<string, { status: string, waiting?: number, active?: number, failed?: number, message?: string }> | null>}
|
|
229
|
+
*/
|
|
230
|
+
async function checkQueues() {
|
|
231
|
+
const registeredQueues = getRegisteredQueues();
|
|
232
|
+
if (registeredQueues.size === 0) return null;
|
|
233
|
+
|
|
234
|
+
const entries = [...registeredQueues.entries()];
|
|
235
|
+
const results = await Promise.allSettled(
|
|
236
|
+
entries.map(([, queue]) =>
|
|
237
|
+
Promise.all([
|
|
238
|
+
queue.getWaitingCount(),
|
|
239
|
+
queue.getActiveCount(),
|
|
240
|
+
queue.getFailedCount(),
|
|
241
|
+
]),
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const queueChecks = {};
|
|
246
|
+
|
|
247
|
+
for (let i = 0; i < entries.length; i++) {
|
|
248
|
+
const [name] = entries[i];
|
|
249
|
+
const result = results[i];
|
|
250
|
+
|
|
251
|
+
if (result.status === "fulfilled") {
|
|
252
|
+
const [waiting, active, failed] = result.value;
|
|
253
|
+
queueChecks[name] = { status: "ok", waiting, active, failed };
|
|
254
|
+
} else {
|
|
255
|
+
queueChecks[name] = withSafeMessage("queue", {
|
|
256
|
+
status: "error",
|
|
257
|
+
message: result.reason?.message,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
159
260
|
}
|
|
261
|
+
|
|
262
|
+
return queueChecks;
|
|
160
263
|
}
|
|
@@ -7,6 +7,17 @@ const STRICT_AUTH_RATE_LIMIT_ROUTES = new Set([
|
|
|
7
7
|
"/api/auth/signin/credentials",
|
|
8
8
|
]);
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Routes that are never rate limited.
|
|
12
|
+
*
|
|
13
|
+
* `/api/health` is the platform healthcheck. It sits behind the same 100-req
|
|
14
|
+
* `api` bucket as everything else, so a probe that lands in an already-full
|
|
15
|
+
* bucket gets a 429 — the orchestrator reads that as unhealthy and restarts the
|
|
16
|
+
* container, which fills the bucket again on startup. Limiting the probe is how
|
|
17
|
+
* a healthy service gets killed.
|
|
18
|
+
*/
|
|
19
|
+
const RATE_LIMIT_EXEMPT_ROUTES = new Set(["/api/health"]);
|
|
20
|
+
|
|
10
21
|
function getForwardedProtocol(request) {
|
|
11
22
|
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
12
23
|
if (!forwardedProto) return null;
|
|
@@ -78,3 +89,12 @@ export function getRateLimitBucket(pathname, method = "GET") {
|
|
|
78
89
|
? "auth"
|
|
79
90
|
: "api";
|
|
80
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether a route bypasses rate limiting entirely.
|
|
95
|
+
* @param {string} pathname
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
export function isRateLimitExempt(pathname) {
|
|
99
|
+
return RATE_LIMIT_EXEMPT_ROUTES.has(pathname);
|
|
100
|
+
}
|
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
* Guards (in order):
|
|
6
6
|
* 1. Metrics guard - optionally protects /api/metrics with a bearer
|
|
7
7
|
* token. Set METRICS_TOKEN in env to enable.
|
|
8
|
-
* 2. Rate limiting, CORS, security headers for all API routes
|
|
8
|
+
* 2. Rate limiting, CORS, security headers for all API routes, except the
|
|
9
|
+
* rate-limit-exempt healthcheck (see lib/proxy-auth.js).
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { getAllowedOrigins } from "@usequark/quark-config/app-url";
|
|
12
13
|
import { NextResponse } from "next/server";
|
|
13
14
|
|
|
14
15
|
import { buildContentSecurityPolicy } from "./lib/analytics/umami-csp.js";
|
|
15
|
-
import { getRateLimitBucket } from "./lib/proxy-auth";
|
|
16
|
+
import { getRateLimitBucket, isRateLimitExempt } from "./lib/proxy-auth";
|
|
16
17
|
|
|
17
18
|
// ─── 1. Metrics guard ────────────────────────────────────────────────────────
|
|
18
19
|
|
|
@@ -218,8 +219,9 @@ export async function proxy(request) {
|
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
221
|
|
|
221
|
-
// Apply rate limiting to API routes only
|
|
222
|
-
|
|
222
|
+
// Apply rate limiting to API routes only. Exempt routes (the healthcheck)
|
|
223
|
+
// skip the bucket entirely — no counter, no headers.
|
|
224
|
+
if (pathname.startsWith("/api/") && !isRateLimitExempt(pathname)) {
|
|
223
225
|
const ip = getClientIp(request);
|
|
224
226
|
const rateLimitBucket = getRateLimitBucket(pathname, request.method);
|
|
225
227
|
const maxRequests = RATE_LIMIT_CONFIG.maxRequests[rateLimitBucket];
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
createLogger,
|
|
10
10
|
createQueue,
|
|
11
11
|
createWorker,
|
|
12
|
-
|
|
12
|
+
getRedisEndpoint,
|
|
13
13
|
updateQueueDepths,
|
|
14
14
|
} from "@usequark/quark-core";
|
|
15
15
|
import { AppError } from "@usequark/quark-core/errors";
|
|
@@ -170,7 +170,7 @@ export async function waitForRedis(
|
|
|
170
170
|
|
|
171
171
|
// All retries exhausted
|
|
172
172
|
throw new AppError(
|
|
173
|
-
`Redis unavailable at ${
|
|
173
|
+
`Redis unavailable at ${getRedisEndpoint()} after ${maxRetries} attempts. Start Redis or check REDIS_URL/REDIS_HOST/REDIS_PORT.`,
|
|
174
174
|
503,
|
|
175
175
|
"REDIS_UNAVAILABLE",
|
|
176
176
|
);
|
|
@@ -397,7 +397,7 @@ async function startWorker() {
|
|
|
397
397
|
isDevMode ? { maxRetries: 3, intervalMs: 500 } : {},
|
|
398
398
|
);
|
|
399
399
|
|
|
400
|
-
logger.info("Redis connected", { address:
|
|
400
|
+
logger.info("Redis connected", { address: getRedisEndpoint() });
|
|
401
401
|
|
|
402
402
|
// Wait for database schema to be ready (migrations may still be running)
|
|
403
403
|
logger.info("Checking database schema readiness...");
|