mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(api-handler): guard against private-field crash on force-static routes
Next 16's dev server passes a synthetic placeholder (not a real
NextRequest instance) as the first argument to route handlers
declared `export const dynamic = "force-static"`. NextRequest's
`method`, `url`, and `headers` getters all read a private `#state`
field; invoking those getters with the wrong `this` binding crashes
with the V8 native error:
TypeError: Cannot read private member #state from an object whose
class did not declare it
at Reflect.get (<anonymous>)
at wrapped (src/lib/api-handler.ts:56:23)
This 500s `/api/version` on every request (the only `force-static`
route in the app today) and would 500 any future force-static route
that anyone adds.
Wrap every direct NextRequest property access inside the apiHandler
wrapper in a `safeRequestProp` helper that swallows the private-field
error and returns a sensible fallback (empty string for `url`, "GET"
for `method`, `null` for header lookups). The fallbacks let logging
instrumentation continue to attach `path` / `method` / `user_agent`
fields when they are available and silently degrade when they are
not — instrumentation must never crash a route handler.
Verification: `curl -sI /api/version` -> HTTP/1.1 200 OK, body
contains the package.json version (was 500). Dynamic routes
(`/auth/login`, `/api/health`) still serve correctly. Affected
tests still pass (vitest src/lib/api-handler + src/app/api/version
+ src/components/targets = 31/31).
This commit is contained in:
1 parent
3c4bbdef4f
commit
15d91834c7
1 file changed
+52
-8
+52
-8
@@ -32,6 +32,26 @@ export class HttpError extends Error {
|
||||
* No CSRF check — HealthLog does not use CSRF tokens.
|
||||
* Auth annotation happens in routes via requireAuth().
|
||||
*/
|
||||
// Read a property from a request-like value without invoking native
|
||||
// private-field getters (NextRequest.method / .url / .headers access
|
||||
// `this.#state` and crash with `Cannot read private member #state from
|
||||
// an object whose class did not declare it` when the request is a
|
||||
// Proxy or a synthetic placeholder — Next 16 passes such placeholders
|
||||
// to `force-static` route handlers during dev). We probe defensively
|
||||
// and fall back to safe defaults so logging instrumentation never
|
||||
// crashes the handler.
|
||||
function safeRequestProp<R>(
|
||||
request: unknown,
|
||||
read: (req: NextRequest) => R,
|
||||
fallback: R,
|
||||
): R {
|
||||
try {
|
||||
return read(request as NextRequest);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// Next.js route handlers come in two shapes — `(request)` for static routes
|
||||
// and `(request, { params })` for dynamic ones. The variadic generic is the
|
||||
// only signature TS accepts that covers both at the call site. The `any[]`
|
||||
@@ -44,23 +64,46 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const wrapped = async (...args: any[]): Promise<Response> => {
|
||||
const request = args[0] as NextRequest;
|
||||
const url = new URL(request.url);
|
||||
const requestUrl = safeRequestProp(request, (r) => r.url, "");
|
||||
const url = (() => {
|
||||
try {
|
||||
return new URL(requestUrl);
|
||||
} catch {
|
||||
// No usable URL (e.g. force-static placeholder) — fall back to
|
||||
// a synthetic origin so the rest of the pipeline can still
|
||||
// attach a pathname.
|
||||
return new URL("http://localhost/");
|
||||
}
|
||||
})();
|
||||
|
||||
const evt = new WideEventBuilder("http");
|
||||
|
||||
// Propagate x-request-id if present
|
||||
const incomingRequestId = request.headers.get("x-request-id");
|
||||
const incomingRequestId = safeRequestProp(
|
||||
request,
|
||||
(r) => r.headers.get("x-request-id"),
|
||||
null,
|
||||
);
|
||||
if (incomingRequestId) evt.setRequestId(incomingRequestId);
|
||||
|
||||
evt.setHttp({
|
||||
method: request.method,
|
||||
method: safeRequestProp(request, (r) => r.method, "GET"),
|
||||
path: url.pathname,
|
||||
route: url.pathname,
|
||||
status: 200,
|
||||
user_agent: request.headers.get("user-agent") ?? undefined,
|
||||
user_agent:
|
||||
safeRequestProp(
|
||||
request,
|
||||
(r) => r.headers.get("user-agent"),
|
||||
null,
|
||||
) ?? undefined,
|
||||
ip:
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
safeRequestProp(
|
||||
request,
|
||||
(r) => r.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
|
||||
null,
|
||||
) ||
|
||||
safeRequestProp(request, (r) => r.headers.get("x-real-ip"), null) ||
|
||||
undefined,
|
||||
});
|
||||
|
||||
@@ -349,9 +392,10 @@ async function reportToGlitchtip(
|
||||
// GlitchTip. Withings legacy callbacks ship `?secret=…` (see C-3) and
|
||||
// OAuth callbacks ship `?code=…&state=…`; if any of those error we
|
||||
// don't want their secrets in someone's incident UI.
|
||||
let scrubbedUrl = request.url;
|
||||
const rawUrl = safeRequestProp(request, (r) => r.url, "");
|
||||
let scrubbedUrl = rawUrl;
|
||||
try {
|
||||
const u = new URL(request.url);
|
||||
const u = new URL(rawUrl);
|
||||
u.search = "";
|
||||
scrubbedUrl = u.toString();
|
||||
} catch {
|
||||
|
||||
Reference in new issue
Block a user