Merge branch 'main' into deps/expo-sdk-56-packages
This commit is contained in:
commit
aa3afeeaec
14 changed files with 311 additions and 81 deletions
|
|
@ -5,6 +5,7 @@ import { activities, routes, syncImports, users, follows } from "@trails-cool/db
|
||||||
import type { Visibility } from "@trails-cool/db/schema/journal";
|
import type { Visibility } from "@trails-cool/db/schema/journal";
|
||||||
import { validateGpx, writeGeom } from "./gpx-save.server.ts";
|
import { validateGpx, writeGeom } from "./gpx-save.server.ts";
|
||||||
import type { GpxData } from "./gpx-save.server.ts";
|
import type { GpxData } from "./gpx-save.server.ts";
|
||||||
|
import { enqueueOptional } from "./boss.server.ts";
|
||||||
|
|
||||||
export interface ActivityInput {
|
export interface ActivityInput {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -36,7 +37,6 @@ export async function updateActivityVisibility(
|
||||||
// idempotent, so toggling private→public→private→public won't spam
|
// idempotent, so toggling private→public→private→public won't spam
|
||||||
// followers (only the first transition per activity emits).
|
// followers (only the first transition per activity emits).
|
||||||
if (visibility === "public") {
|
if (visibility === "public") {
|
||||||
const { enqueueOptional } = await import("./boss.server.ts");
|
|
||||||
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "updateActivityVisibility" });
|
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "updateActivityVisibility" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,7 +91,6 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
|
||||||
// updateActivityVisibility path for the case where visibility is set
|
// updateActivityVisibility path for the case where visibility is set
|
||||||
// up-front rather than flipped later).
|
// up-front rather than flipped later).
|
||||||
if (input.visibility === "public") {
|
if (input.visibility === "public") {
|
||||||
const { enqueueOptional } = await import("./boss.server.ts");
|
|
||||||
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "createActivity" });
|
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "createActivity" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -279,9 +279,13 @@ export async function createMagicToken(email: string): Promise<{ token: string;
|
||||||
export async function verifyLoginCode(email: string, code: string): Promise<string> {
|
export async function verifyLoginCode(email: string, code: string): Promise<string> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
|
// Consume atomically: a single UPDATE…RETURNING ensures only one
|
||||||
|
// concurrent request wins. The old select-then-update sequence was a
|
||||||
|
// TOCTOU window where two clients could both see `used_at IS NULL`
|
||||||
|
// and both succeed.
|
||||||
const [record] = await db
|
const [record] = await db
|
||||||
.select()
|
.update(magicTokens)
|
||||||
.from(magicTokens)
|
.set({ usedAt: new Date() })
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(magicTokens.email, email),
|
eq(magicTokens.email, email),
|
||||||
|
|
@ -290,15 +294,11 @@ export async function verifyLoginCode(email: string, code: string): Promise<stri
|
||||||
gt(magicTokens.expiresAt, new Date()),
|
gt(magicTokens.expiresAt, new Date()),
|
||||||
isNull(magicTokens.usedAt),
|
isNull(magicTokens.usedAt),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
if (!record) throw new Error("Invalid or expired code");
|
if (!record) throw new Error("Invalid or expired code");
|
||||||
|
|
||||||
await db
|
|
||||||
.update(magicTokens)
|
|
||||||
.set({ usedAt: new Date() })
|
|
||||||
.where(eq(magicTokens.id, record.id));
|
|
||||||
|
|
||||||
const [user] = await db.select().from(users).where(eq(users.email, record.email));
|
const [user] = await db.select().from(users).where(eq(users.email, record.email));
|
||||||
if (!user) throw new Error("User not found");
|
if (!user) throw new Error("User not found");
|
||||||
|
|
||||||
|
|
@ -332,9 +332,10 @@ export async function initiateEmailChange(userId: string, newEmail: string): Pro
|
||||||
export async function verifyMagicToken(token: string): Promise<string> {
|
export async function verifyMagicToken(token: string): Promise<string> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
|
// Atomic consume — see verifyLoginCode for rationale.
|
||||||
const [record] = await db
|
const [record] = await db
|
||||||
.select()
|
.update(magicTokens)
|
||||||
.from(magicTokens)
|
.set({ usedAt: new Date() })
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(magicTokens.token, token),
|
eq(magicTokens.token, token),
|
||||||
|
|
@ -342,15 +343,11 @@ export async function verifyMagicToken(token: string): Promise<string> {
|
||||||
gt(magicTokens.expiresAt, new Date()),
|
gt(magicTokens.expiresAt, new Date()),
|
||||||
isNull(magicTokens.usedAt),
|
isNull(magicTokens.usedAt),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
if (!record) throw new Error("Invalid or expired magic link");
|
if (!record) throw new Error("Invalid or expired magic link");
|
||||||
|
|
||||||
await db
|
|
||||||
.update(magicTokens)
|
|
||||||
.set({ usedAt: new Date() })
|
|
||||||
.where(eq(magicTokens.id, record.id));
|
|
||||||
|
|
||||||
const [user] = await db.select().from(users).where(eq(users.email, record.email));
|
const [user] = await db.select().from(users).where(eq(users.email, record.email));
|
||||||
if (!user) throw new Error("User not found");
|
if (!user) throw new Error("User not found");
|
||||||
|
|
||||||
|
|
@ -360,9 +357,14 @@ export async function verifyMagicToken(token: string): Promise<string> {
|
||||||
export async function verifyEmailChange(token: string, userId: string): Promise<string> {
|
export async function verifyEmailChange(token: string, userId: string): Promise<string> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
|
// Atomic consume — see verifyLoginCode for rationale. The token is
|
||||||
|
// marked used regardless of whether the email is still available;
|
||||||
|
// re-checking availability after the consume keeps the token
|
||||||
|
// single-use even if the new email was claimed in between (the
|
||||||
|
// original implementation also marked it used in that case).
|
||||||
const [record] = await db
|
const [record] = await db
|
||||||
.select()
|
.update(magicTokens)
|
||||||
.from(magicTokens)
|
.set({ usedAt: new Date() })
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(magicTokens.token, token),
|
eq(magicTokens.token, token),
|
||||||
|
|
@ -370,25 +372,20 @@ export async function verifyEmailChange(token: string, userId: string): Promise<
|
||||||
gt(magicTokens.expiresAt, new Date()),
|
gt(magicTokens.expiresAt, new Date()),
|
||||||
isNull(magicTokens.usedAt),
|
isNull(magicTokens.usedAt),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
if (!record) throw new Error("Invalid or expired verification link");
|
if (!record) throw new Error("Invalid or expired verification link");
|
||||||
|
|
||||||
const newEmail = record.email;
|
const newEmail = record.email;
|
||||||
|
|
||||||
// Re-check email availability at verification time — someone may have
|
// Re-check email availability at verification time — someone may have
|
||||||
// registered with this email between initiation and verification
|
// registered with this email between initiation and verification.
|
||||||
const [existing] = await db.select().from(users).where(eq(users.email, newEmail));
|
const [existing] = await db.select().from(users).where(eq(users.email, newEmail));
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await db.update(magicTokens).set({ usedAt: new Date() }).where(eq(magicTokens.id, record.id));
|
|
||||||
throw new Error("This email is now in use by another account");
|
throw new Error("This email is now in use by another account");
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
|
||||||
.update(magicTokens)
|
|
||||||
.set({ usedAt: new Date() })
|
|
||||||
.where(eq(magicTokens.id, record.id));
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ email: newEmail })
|
.set({ email: newEmail })
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
// is bound to the Node process — startWorker calls boss.start(); the
|
// is bound to the Node process — startWorker calls boss.start(); the
|
||||||
// SIGTERM handler stops it.
|
// SIGTERM handler stops it.
|
||||||
|
|
||||||
|
import { logger } from "./logger.server.ts";
|
||||||
|
|
||||||
// Structurally typed (we only need `send`) so we don't have to pull
|
// Structurally typed (we only need `send`) so we don't have to pull
|
||||||
// pg-boss into the journal app's dep graph just for the typedef.
|
// pg-boss into the journal app's dep graph just for the typedef.
|
||||||
interface BossLike {
|
interface BossLike {
|
||||||
|
|
@ -43,9 +45,6 @@ export async function enqueueOptional(
|
||||||
const boss = getBoss();
|
const boss = getBoss();
|
||||||
await boss.send(queue, data);
|
await boss.send(queue, data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Lazy import to avoid cycles in test environments where logger.server
|
|
||||||
// pulls in env-dependent setup.
|
|
||||||
const { logger } = await import("./logger.server.ts");
|
|
||||||
logger.warn({ err, queue, ...ctx }, "boss.send failed; continuing");
|
logger.warn({ err, queue, ...ctx }, "boss.send failed; continuing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import { decrypt } from "../../../crypto.server.ts";
|
import { decrypt } from "../../../crypto.server.ts";
|
||||||
import { fetchKomootTours, fetchKomootTourGpx } from "../../../komoot.server.ts";
|
import { fetchKomootTours, fetchKomootTourGpx } from "../../../komoot.server.ts";
|
||||||
import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts";
|
import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts";
|
||||||
|
import { getServiceById } from "../../manager.ts";
|
||||||
import type {
|
import type {
|
||||||
CapabilityContext,
|
CapabilityContext,
|
||||||
ImportableList,
|
ImportableList,
|
||||||
|
|
@ -58,7 +59,6 @@ export const komootImporter: Importer = {
|
||||||
return ctx.withFreshCredentials(async (rawCreds) => {
|
return ctx.withFreshCredentials(async (rawCreds) => {
|
||||||
const creds = rawCreds as KomootCreds;
|
const creds = rawCreds as KomootCreds;
|
||||||
|
|
||||||
const { getServiceById } = await import("../../manager.ts");
|
|
||||||
const service = await getServiceById(ctx.serviceId);
|
const service = await getServiceById(ctx.serviceId);
|
||||||
if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`);
|
if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,3 +100,106 @@ describe("wahooImporter.listImportable", () => {
|
||||||
expect(result.workouts.map((w) => w.id)).toEqual(["1"]);
|
expect(result.workouts.map((w) => w.id)).toEqual(["1"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("wahooImporter.importOne pagination", () => {
|
||||||
|
function fitToGpxMock() {
|
||||||
|
// The importer calls fitToGpx — short-circuit it so the test focuses
|
||||||
|
// on pagination, not FIT parsing.
|
||||||
|
return Promise.resolve("<gpx></gpx>");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("paginates past page 1 until it finds the target workout", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("../../fit.ts", () => ({ fitToGpx: fitToGpxMock }));
|
||||||
|
vi.doMock("../../../sync/imports.server.ts", () => ({
|
||||||
|
importActivity: vi.fn().mockResolvedValue({ activityId: "a-1" }),
|
||||||
|
isAlreadyImported: vi.fn().mockResolvedValue(false),
|
||||||
|
}));
|
||||||
|
vi.doMock("../../manager.ts", () => ({
|
||||||
|
getServiceById: vi.fn().mockResolvedValue({ id: "svc-1", userId: "u-1" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Page 1: workouts 1..30, Page 2: workouts 31..50 (the target = 42)
|
||||||
|
fetchSpy.mockImplementation((url) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("page=2")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
workouts: [
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
name: "Old ride",
|
||||||
|
workout_type: "biking",
|
||||||
|
starts: "2025-12-01T07:00:00Z",
|
||||||
|
workout_summary: { file: { url: "https://cdn.example/42.fit" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 50,
|
||||||
|
page: 2,
|
||||||
|
per_page: 30,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (u.includes("/42.fit")) {
|
||||||
|
return Promise.resolve(new Response(new ArrayBuffer(4), { status: 200 }));
|
||||||
|
}
|
||||||
|
// page 1 by default — does NOT contain id 42
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
workouts: [
|
||||||
|
{ id: 1, name: "Recent", workout_type: "biking", starts: "2026-05-01T07:00:00Z" },
|
||||||
|
],
|
||||||
|
total: 50,
|
||||||
|
page: 1,
|
||||||
|
per_page: 30,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { wahooImporter } = await import("./importer.ts");
|
||||||
|
const result = await wahooImporter.importOne(ctxWith(), "42");
|
||||||
|
expect(result.activityId).toBe("a-1");
|
||||||
|
// Fetched at least pages 1 and 2 of the /v1/workouts endpoint
|
||||||
|
const workoutCalls = fetchSpy.mock.calls.filter(([u]) =>
|
||||||
|
String(u).includes("/v1/workouts?"),
|
||||||
|
);
|
||||||
|
expect(workoutCalls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a clear error when the workout is not found on any page", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("../../fit.ts", () => ({ fitToGpx: fitToGpxMock }));
|
||||||
|
vi.doMock("../../../sync/imports.server.ts", () => ({
|
||||||
|
importActivity: vi.fn(),
|
||||||
|
isAlreadyImported: vi.fn().mockResolvedValue(false),
|
||||||
|
}));
|
||||||
|
vi.doMock("../../manager.ts", () => ({
|
||||||
|
getServiceById: vi.fn().mockResolvedValue({ id: "svc-1", userId: "u-1" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
fetchSpy.mockImplementation(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
workouts: [
|
||||||
|
{ id: 1, name: "Recent", workout_type: "biking", starts: "2026-05-01T07:00:00Z" },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
per_page: 30,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { wahooImporter } = await import("./importer.ts");
|
||||||
|
await expect(wahooImporter.importOne(ctxWith(), "999")).rejects.toThrow(/not found/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { fitToGpx } from "../../fit.ts";
|
import { fitToGpx } from "../../fit.ts";
|
||||||
import { fetchWithTimeout } from "../../../http.server.ts";
|
import { fetchWithTimeout } from "../../../http.server.ts";
|
||||||
import { importActivity, isAlreadyImported } from "../../../sync/imports.server.ts";
|
import { importActivity, isAlreadyImported } from "../../../sync/imports.server.ts";
|
||||||
|
import { getServiceById } from "../../manager.ts";
|
||||||
import type {
|
import type {
|
||||||
CapabilityContext,
|
CapabilityContext,
|
||||||
ImportableList,
|
ImportableList,
|
||||||
|
|
@ -103,20 +104,34 @@ export const wahooImporter: Importer = {
|
||||||
ctx: CapabilityContext,
|
ctx: CapabilityContext,
|
||||||
workoutId: string,
|
workoutId: string,
|
||||||
): Promise<ImportResult> {
|
): Promise<ImportResult> {
|
||||||
// Look up the workout to get the file URL (Wahoo doesn't expose a
|
// Wahoo doesn't expose a direct /v1/workouts/<id> endpoint with file
|
||||||
// direct /v1/workouts/<id> with file; we re-fetch the page).
|
// URL, so we paginate /v1/workouts looking for the target. Bound by
|
||||||
// For simplicity we ask Wahoo for the workout directly; if that fails
|
// the total / per_page Wahoo returns on page 1, with a hard ceiling
|
||||||
// we fall back to scanning page 1.
|
// so a misbehaving API can't loop us forever.
|
||||||
const list = await ctx.withFreshCredentials((creds) =>
|
const MAX_PAGES = 100;
|
||||||
fetchWahooWorkoutPage(creds as OAuthCredentials, 1),
|
let workout: WahooWorkout | undefined;
|
||||||
);
|
let totalPages = 1;
|
||||||
const workout = list.workouts.find((w) => String(w.id) === workoutId);
|
for (let page = 1; page <= Math.min(totalPages, MAX_PAGES); page++) {
|
||||||
if (!workout) throw new Error(`Wahoo workout ${workoutId} not found on page 1`);
|
const list = await ctx.withFreshCredentials((creds) =>
|
||||||
|
fetchWahooWorkoutPage(creds as OAuthCredentials, page),
|
||||||
|
);
|
||||||
|
// perPage may not divide total cleanly; ceil so we don't stop one
|
||||||
|
// page short.
|
||||||
|
if (page === 1 && list.per_page > 0) {
|
||||||
|
totalPages = Math.ceil(list.total / list.per_page);
|
||||||
|
}
|
||||||
|
const found = list.workouts.find((w) => String(w.id) === workoutId);
|
||||||
|
if (found) {
|
||||||
|
workout = found;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (list.workouts.length === 0) break;
|
||||||
|
}
|
||||||
|
if (!workout) throw new Error(`Wahoo workout ${workoutId} not found`);
|
||||||
|
|
||||||
// Resolve the connected service's user id via the capability context.
|
// Resolve the connected service's user id via the capability context.
|
||||||
// The caller (route handler) supplies userId out-of-band — for now the
|
// The caller (route handler) supplies userId out-of-band — for now the
|
||||||
// route handler bridges the gap. We use the manager's getServiceById.
|
// route handler bridges the gap. We use the manager's getServiceById.
|
||||||
const { getServiceById } = await import("../../manager.ts");
|
|
||||||
const service = await getServiceById(ctx.serviceId);
|
const service = await getServiceById(ctx.serviceId);
|
||||||
if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`);
|
if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`);
|
||||||
|
|
||||||
|
|
|
||||||
49
apps/journal/app/lib/logger.server.test.ts
Normal file
49
apps/journal/app/lib/logger.server.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Writable } from "node:stream";
|
||||||
|
import pino from "pino";
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
|
|
||||||
|
// We test the *mixin contract* — that pino's `mixin` callback reads
|
||||||
|
// from the async-local store and tags every record. Constructing a
|
||||||
|
// fresh pino instance per test (vs. importing the module-level one)
|
||||||
|
// lets us capture stdout cleanly.
|
||||||
|
|
||||||
|
describe("logger mixin attaches requestId from async context", () => {
|
||||||
|
function captureLogger(als: AsyncLocalStorage<{ requestId: string }>) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const sink = new Writable({
|
||||||
|
write(chunk, _enc, cb) {
|
||||||
|
lines.push(chunk.toString());
|
||||||
|
cb();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const lg = pino(
|
||||||
|
{
|
||||||
|
level: "info",
|
||||||
|
mixin: () => {
|
||||||
|
const ctx = als.getStore();
|
||||||
|
return ctx ? { requestId: ctx.requestId } : {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sink,
|
||||||
|
);
|
||||||
|
return { lg, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("tags log records with requestId when inside als.run()", () => {
|
||||||
|
const als = new AsyncLocalStorage<{ requestId: string }>();
|
||||||
|
const { lg, lines } = captureLogger(als);
|
||||||
|
als.run({ requestId: "abc-123" }, () => lg.info("hello"));
|
||||||
|
const record = JSON.parse(lines[0]!);
|
||||||
|
expect(record.requestId).toBe("abc-123");
|
||||||
|
expect(record.msg).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits requestId when no async context is active", () => {
|
||||||
|
const als = new AsyncLocalStorage<{ requestId: string }>();
|
||||||
|
const { lg, lines } = captureLogger(als);
|
||||||
|
lg.info("bare");
|
||||||
|
const record = JSON.parse(lines[0]!);
|
||||||
|
expect(record.requestId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,27 @@
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request context propagated through the async stack. The HTTP
|
||||||
|
* server wraps each request in `requestContext.run({ requestId }, ...)`
|
||||||
|
* so every downstream `logger.info(...)` automatically tags log lines
|
||||||
|
* with `requestId` — no need to thread it through every call site.
|
||||||
|
*/
|
||||||
|
export interface RequestContext {
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestContext = new AsyncLocalStorage<RequestContext>();
|
||||||
|
|
||||||
export const logger = pino({
|
export const logger = pino({
|
||||||
level: process.env.LOG_LEVEL ?? "info",
|
level: process.env.LOG_LEVEL ?? "info",
|
||||||
|
// Mixin runs on every log call and merges its return value into the
|
||||||
|
// emitted record. Reading from ALS here is what makes requestId
|
||||||
|
// automatic for every downstream logger.info/warn/error.
|
||||||
|
mixin: () => {
|
||||||
|
const ctx = requestContext.getStore();
|
||||||
|
return ctx ? { requestId: ctx.requestId } : {};
|
||||||
|
},
|
||||||
...(process.env.NODE_ENV !== "production"
|
...(process.env.NODE_ENV !== "production"
|
||||||
? { transport: { target: "pino-pretty" } }
|
? { transport: { target: "pino-pretty" } }
|
||||||
: {}),
|
: {}),
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,23 @@ import { browserSentryConfig } from "@trails-cool/sentry-config";
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
|
||||||
|
// Build-time DSN injection: `VITE_SENTRY_DSN` (if set during `pnpm build`)
|
||||||
|
// overrides the flagship default so self-hosters can ship their own
|
||||||
|
// Sentry project. Set it to `""` (empty string) to disable Sentry on
|
||||||
|
// the client entirely.
|
||||||
|
const FLAGSHIP_JOURNAL_DSN =
|
||||||
|
"https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728";
|
||||||
|
const CLIENT_DSN =
|
||||||
|
(import.meta.env as Record<string, string | undefined>).VITE_SENTRY_DSN ??
|
||||||
|
FLAGSHIP_JOURNAL_DSN;
|
||||||
|
|
||||||
export function initSentryClient() {
|
export function initSentryClient() {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
if (!CLIENT_DSN) return;
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728",
|
dsn: CLIENT_DSN,
|
||||||
integrations: [
|
integrations: [
|
||||||
Sentry.reactRouterV7BrowserTracingIntegration({
|
Sentry.reactRouterV7BrowserTracingIntegration({
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { getSessionUser } from "~/lib/auth/session.server";
|
||||||
import { LocaleProvider } from "~/components/LocaleContext";
|
import { LocaleProvider } from "~/components/LocaleContext";
|
||||||
import { AlphaBanner } from "~/components/AlphaBanner";
|
import { AlphaBanner } from "~/components/AlphaBanner";
|
||||||
import { useUnreadNotifications } from "~/hooks/useUnreadNotifications";
|
import { useUnreadNotifications } from "~/hooks/useUnreadNotifications";
|
||||||
|
import { countUnread } from "~/lib/notifications.server";
|
||||||
import { Footer } from "~/components/Footer";
|
import { Footer } from "~/components/Footer";
|
||||||
import { AccountDropdown } from "~/components/AccountDropdown";
|
import { AccountDropdown } from "~/components/AccountDropdown";
|
||||||
import { MobileNavMenu } from "~/components/MobileNavMenu";
|
import { MobileNavMenu } from "~/components/MobileNavMenu";
|
||||||
|
|
@ -69,12 +70,9 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||||
// Unread-notification count for the navbar bell badge. Pending follow
|
// Unread-notification count for the navbar bell badge. Pending follow
|
||||||
// requests are not separately counted here — each pending request
|
// requests are not separately counted here — each pending request
|
||||||
// creates an unread `follow_request_received` notification, so the
|
// creates an unread `follow_request_received` notification, so the
|
||||||
// unread count already covers them. Hidden behind a dynamic import so
|
// unread count already covers them.
|
||||||
// the root layout doesn't pull in the notifications module on
|
|
||||||
// anonymous renders.
|
|
||||||
let unreadNotifications = 0;
|
let unreadNotifications = 0;
|
||||||
if (user) {
|
if (user) {
|
||||||
const { countUnread } = await import("./lib/notifications.server.ts");
|
|
||||||
unreadNotifications = await countUnread(user.id);
|
unreadNotifications = await countUnread(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/
|
||||||
import { getDb } from "~/lib/db";
|
import { getDb } from "~/lib/db";
|
||||||
import { syncPushes } from "@trails-cool/db/schema/journal";
|
import { syncPushes } from "@trails-cool/db/schema/journal";
|
||||||
import { getService } from "~/lib/connected-services";
|
import { getService } from "~/lib/connected-services";
|
||||||
|
import { computeDays, parseGpxAsync } from "@trails-cool/gpx";
|
||||||
|
|
||||||
export async function loadRouteDetail(request: Request, id: string | undefined) {
|
export async function loadRouteDetail(request: Request, id: string | undefined) {
|
||||||
const routeId = id ?? "";
|
const routeId = id ?? "";
|
||||||
|
|
@ -32,7 +33,6 @@ export async function loadRouteDetail(request: Request, id: string | undefined)
|
||||||
let waypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string; osmId?: number; poiTags?: Record<string, string> }> = [];
|
let waypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string; osmId?: number; poiTags?: Record<string, string> }> = [];
|
||||||
if (route.gpx) {
|
if (route.gpx) {
|
||||||
try {
|
try {
|
||||||
const { computeDays, parseGpxAsync } = await import("@trails-cool/gpx");
|
|
||||||
const gpxData = await parseGpxAsync(route.gpx);
|
const gpxData = await parseGpxAsync(route.gpx);
|
||||||
waypoints = gpxData.waypoints.map((w) => ({
|
waypoints = gpxData.waypoints.map((w) => ({
|
||||||
lat: w.lat,
|
lat: w.lat,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from "~/lib/connected-services";
|
} from "~/lib/connected-services";
|
||||||
import { getImportedIds, recordImport } from "~/lib/sync/imports.server";
|
import { getImportedIds, recordImport } from "~/lib/sync/imports.server";
|
||||||
import { createActivity } from "~/lib/activities.server";
|
import { createActivity } from "~/lib/activities.server";
|
||||||
|
import { generateGpx } from "@trails-cool/gpx";
|
||||||
|
|
||||||
export async function loadSyncImportProvider(request: Request, provider: string | undefined) {
|
export async function loadSyncImportProvider(request: Request, provider: string | undefined) {
|
||||||
const user = await requireSessionUser(request);
|
const user = await requireSessionUser(request);
|
||||||
|
|
@ -77,9 +78,11 @@ export async function syncImportProviderAction(request: Request, provider: strin
|
||||||
if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
|
if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
|
||||||
return Buffer.from(await resp.arrayBuffer());
|
return Buffer.from(await resp.arrayBuffer());
|
||||||
});
|
});
|
||||||
// Lazy-load to avoid bundling fit-file-parser into all routes.
|
// Lazy-load fit-file-parser only — it's heavy and only the FIT
|
||||||
|
// ingestion path needs it. @trails-cool/gpx is already pulled in
|
||||||
|
// statically by other modules in the chunk, so the dynamic import
|
||||||
|
// there was ineffective.
|
||||||
const { default: FitParser } = await import("fit-file-parser");
|
const { default: FitParser } = await import("fit-file-parser");
|
||||||
const { generateGpx } = await import("@trails-cool/gpx");
|
|
||||||
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||||
const parser = new FitParser({ force: true });
|
const parser = new FitParser({ force: true });
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,27 @@ import { createRequestListener } from "@react-router/node";
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
import { createReadStream, statSync } from "node:fs";
|
import { createReadStream, statSync } from "node:fs";
|
||||||
import { join, extname, resolve } from "node:path";
|
import { join, extname, resolve } from "node:path";
|
||||||
import { logger } from "./app/lib/logger.server.ts";
|
import { logger, requestContext } from "./app/lib/logger.server.ts";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts";
|
import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts";
|
||||||
import { createBoss, startWorker } from "@trails-cool/jobs";
|
import { createBoss, startWorker } from "@trails-cool/jobs";
|
||||||
import { getDatabaseUrl } from "@trails-cool/db";
|
import { getDatabaseUrl } from "@trails-cool/db";
|
||||||
import postgres from "postgres";
|
import postgres, { type Sql } from "postgres";
|
||||||
|
|
||||||
Sentry.init({
|
// Sentry DSN is read from env so self-hosted instances don't ship their
|
||||||
dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728",
|
// errors to the trails.cool flagship Sentry by default. The flagship
|
||||||
...nodeSentryConfig("journal server"),
|
// keeps its DSN as the fallback; setting SENTRY_DSN="" (or any other
|
||||||
beforeSend: drop404s,
|
// truthy value) overrides. SENTRY_DISABLED=true skips init entirely.
|
||||||
});
|
const FLAGSHIP_JOURNAL_SENTRY_DSN =
|
||||||
|
"https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728";
|
||||||
|
const sentryDsn = process.env.SENTRY_DSN ?? FLAGSHIP_JOURNAL_SENTRY_DSN;
|
||||||
|
if (process.env.SENTRY_DISABLED !== "true" && sentryDsn !== "") {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: sentryDsn,
|
||||||
|
...nodeSentryConfig("journal server"),
|
||||||
|
beforeSend: drop404s,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
const CLIENT_DIR = resolve(import.meta.dirname, "build", "client");
|
const CLIENT_DIR = resolve(import.meta.dirname, "build", "client");
|
||||||
|
|
@ -66,17 +76,27 @@ async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promis
|
||||||
|
|
||||||
const version = process.env.SENTRY_RELEASE ?? "dev";
|
const version = process.env.SENTRY_RELEASE ?? "dev";
|
||||||
|
|
||||||
|
// Module-level singleton postgres client dedicated to /api/health. The
|
||||||
|
// previous handler opened a fresh client + connection on every call,
|
||||||
|
// which OOM'd the process under monitoring load (probes hit /api/health
|
||||||
|
// every few seconds). `max: 2` is plenty for liveness checks; the main
|
||||||
|
// app DB pool is separate (via @trails-cool/db's createDb).
|
||||||
|
let healthClient: Sql | null = null;
|
||||||
|
function getHealthClient(): Sql {
|
||||||
|
if (!healthClient) {
|
||||||
|
healthClient = postgres(getDatabaseUrl(), { max: 2, idle_timeout: 30 });
|
||||||
|
}
|
||||||
|
return healthClient;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
const client = postgres(getDatabaseUrl(), { max: 1 });
|
|
||||||
try {
|
try {
|
||||||
await client`SELECT 1`;
|
await getHealthClient()`SELECT 1`;
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ status: "ok", version, db: "connected" }));
|
res.end(JSON.stringify({ status: "ok", version, db: "connected" }));
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(503, { "Content-Type": "application/json" });
|
res.writeHead(503, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ status: "degraded", version, db: "unreachable" }));
|
res.end(JSON.stringify({ status: "degraded", version, db: "unreachable" }));
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,22 +104,32 @@ const server = createServer((req, res) => {
|
||||||
const url = req.url ?? "/";
|
const url = req.url ?? "/";
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
if (!url.startsWith("/assets/") && url !== "/api/health" && url !== "/api/metrics") {
|
// Honor an inbound X-Request-Id header (e.g. from Caddy or a probe)
|
||||||
res.on("finish", () => {
|
// so request IDs propagate across the proxy hop. Mint a fresh one if
|
||||||
const duration = Date.now() - start;
|
// absent. Echo on the response so clients can correlate.
|
||||||
logger.info({ method: req.method, path: url, status: res.statusCode, duration }, "request");
|
const inbound = req.headers["x-request-id"];
|
||||||
httpRequestDuration.observe(
|
const requestId =
|
||||||
{ method: req.method ?? "GET", route: url.split("?")[0]!, status: String(res.statusCode) },
|
(Array.isArray(inbound) ? inbound[0] : inbound) || randomUUID();
|
||||||
duration / 1000,
|
res.setHeader("X-Request-Id", requestId);
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url === "/api/health") { handleHealth(req, res); return; }
|
requestContext.run({ requestId }, () => {
|
||||||
if (url === "/api/metrics") { handleMetrics(req, res); return; }
|
if (!url.startsWith("/assets/") && url !== "/api/health" && url !== "/api/metrics") {
|
||||||
if (!serveStatic(req, res)) {
|
res.on("finish", () => {
|
||||||
listener(req, res);
|
const duration = Date.now() - start;
|
||||||
}
|
logger.info({ method: req.method, path: url, status: res.statusCode, duration }, "request");
|
||||||
|
httpRequestDuration.observe(
|
||||||
|
{ method: req.method ?? "GET", route: url.split("?")[0]!, status: String(res.statusCode) },
|
||||||
|
duration / 1000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/health") { handleHealth(req, res); return; }
|
||||||
|
if (url === "/api/metrics") { handleMetrics(req, res); return; }
|
||||||
|
if (!serveStatic(req, res)) {
|
||||||
|
listener(req, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(port, async () => {
|
server.listen(port, async () => {
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,17 @@ import { createBoss, startWorker } from "@trails-cool/jobs";
|
||||||
import { expireSessionsJob } from "./app/jobs/expire-sessions.ts";
|
import { expireSessionsJob } from "./app/jobs/expire-sessions.ts";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
|
|
||||||
Sentry.init({
|
// See apps/journal/server.ts for the SENTRY_DSN / SENTRY_DISABLED contract.
|
||||||
dsn: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208",
|
const FLAGSHIP_PLANNER_SENTRY_DSN =
|
||||||
...nodeSentryConfig("planner server"),
|
"https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208";
|
||||||
beforeSend: drop404s,
|
const sentryDsn = process.env.SENTRY_DSN ?? FLAGSHIP_PLANNER_SENTRY_DSN;
|
||||||
});
|
if (process.env.SENTRY_DISABLED !== "true" && sentryDsn !== "") {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: sentryDsn,
|
||||||
|
...nodeSentryConfig("planner server"),
|
||||||
|
beforeSend: drop404s,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3001);
|
const port = Number(process.env.PORT ?? 3001);
|
||||||
const CLIENT_DIR = resolve(import.meta.dirname, "build", "client");
|
const CLIENT_DIR = resolve(import.meta.dirname, "build", "client");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue