style: préfixe tous les membres private/protected par _

Convention demandée par l'utilisateur : `emit` -> `_emit`, sur toutes les
classes du repo, pas seulement le nouveau code. `public` reste sans
préfixe.

- LoggerService (apps/api) : _minSeverity, _emit.
- ApiClient (apps/web) : _request (39 sites d'appel mis à jour).
- ErrorHandlerService (packages/error-tools) : _fromZodError,
  _fromHttpError, _fromUnknownError.
- ExpressServer (packages/express-tools) : _app, _registeredRoutes.

Aucun changement de comportement — pur renommage interne, aucune méthode
private/protected n'était appelée depuis l'extérieur de sa classe.

Vérifié : pnpm --filter api test (303/303), pnpm lint/build clean sur
tout le repo (apps/api, apps/web, packages/*).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-21 10:32:52 +02:00
parent 22582536a6
commit c5282ba4a7
4 changed files with 70 additions and 70 deletions

View file

@ -60,30 +60,30 @@ const CONSOLE_METHOD: Record<LogLevel, "debug" | "info" | "warn" | "error"> = {
/** Server-side operational logger — see the module doc comment for why this exists and what it's for. */ /** Server-side operational logger — see the module doc comment for why this exists and what it's for. */
export class LoggerService { export class LoggerService {
/** Computed once at construction from `env.NODE_ENV` — see {@link minLevelFor}. */ /** Computed once at construction from `env.NODE_ENV` — see {@link minLevelFor}. */
private readonly minSeverity: number; private readonly _minSeverity: number;
public constructor(nodeEnv: typeof env.NODE_ENV = env.NODE_ENV) { public constructor(nodeEnv: typeof env.NODE_ENV = env.NODE_ENV) {
this.minSeverity = LEVEL_SEVERITY[minLevelFor(nodeEnv)]; this._minSeverity = LEVEL_SEVERITY[minLevelFor(nodeEnv)];
} }
public debug(message: string, meta?: LogMeta): void { public debug(message: string, meta?: LogMeta): void {
this.emit("debug", message, meta); this._emit("debug", message, meta);
} }
public info(message: string, meta?: LogMeta): void { public info(message: string, meta?: LogMeta): void {
this.emit("info", message, meta); this._emit("info", message, meta);
} }
public warn(message: string, meta?: LogMeta): void { public warn(message: string, meta?: LogMeta): void {
this.emit("warn", message, meta); this._emit("warn", message, meta);
} }
public error(message: string, meta?: LogMeta): void { public error(message: string, meta?: LogMeta): void {
this.emit("error", message, meta); this._emit("error", message, meta);
} }
private emit(level: LogLevel, message: string, meta?: LogMeta): void { private _emit(level: LogLevel, message: string, meta?: LogMeta): void {
if (LEVEL_SEVERITY[level] < this.minSeverity) return; if (LEVEL_SEVERITY[level] < this._minSeverity) return;
// `meta` spread first so a caller accidentally passing e.g. `{ message: // `meta` spread first so a caller accidentally passing e.g. `{ message:
// ... }` in it can never shadow the line's own core fields. // ... }` in it can never shadow the line's own core fields.

View file

@ -71,7 +71,7 @@ export class ApiClient {
* *
* @throws {ApiError} if the response status is not in the 2xx range. * @throws {ApiError} if the response status is not in the 2xx range.
*/ */
private async request<TResponseBody>( private async _request<TResponseBody>(
path: string, path: string,
options: RequestInit = {}, options: RequestInit = {},
): Promise<TResponseBody> { ): Promise<TResponseBody> {
@ -102,27 +102,27 @@ export class ApiClient {
/** Creates a profile and starts a session — no household yet, that's an optional step of the onboarding wizard. */ /** Creates a profile and starts a session — no household yet, that's an optional step of the onboarding wizard. */
public signup(input: SignupInput): Promise<SafeUserProfile> { public signup(input: SignupInput): Promise<SafeUserProfile> {
return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) }); return this._request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
} }
/** Verifies credentials and starts a session. */ /** Verifies credentials and starts a session. */
public login(input: LoginInput): Promise<SafeUserProfile> { public login(input: LoginInput): Promise<SafeUserProfile> {
return this.request("/auth/login", { method: "POST", body: JSON.stringify(input) }); return this._request("/auth/login", { method: "POST", body: JSON.stringify(input) });
} }
/** Ends the current session. */ /** Ends the current session. */
public logout(): Promise<void> { public logout(): Promise<void> {
return this.request("/auth/logout", { method: "POST" }); return this._request("/auth/logout", { method: "POST" });
} }
/** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */ /** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */
public me(): Promise<SafeUserProfile> { public me(): Promise<SafeUserProfile> {
return this.request("/auth/me"); return this._request("/auth/me");
} }
/** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. */ /** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. */
public deleteAccount(password: string): Promise<void> { public deleteAccount(password: string): Promise<void> {
return this.request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) }); return this._request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) });
} }
/** /**
@ -131,7 +131,7 @@ export class ApiClient {
* there isn't one for that week yet. * there isn't one for that week yet.
*/ */
public getPlanningForWeek(date: string): Promise<PlanningView | null> { public getPlanningForWeek(date: string): Promise<PlanningView | null> {
return this.request(`/planning?date=${date}`); return this._request(`/planning?date=${date}`);
} }
/** /**
@ -142,37 +142,37 @@ export class ApiClient {
* caller). * caller).
*/ */
public addPlanningItem(input: AddPlanningItemInput): Promise<PlanningItemView> { public addPlanningItem(input: AddPlanningItemInput): Promise<PlanningItemView> {
return this.request("/planning/items", { method: "POST", body: JSON.stringify(input) }); return this._request("/planning/items", { method: "POST", body: JSON.stringify(input) });
} }
/** Removes one recipe from a planning slot — rejects with `PLANNING_ITEM_NOT_FOUND`. */ /** Removes one recipe from a planning slot — rejects with `PLANNING_ITEM_NOT_FOUND`. */
public removePlanningItem(id: number): Promise<void> { public removePlanningItem(id: number): Promise<void> {
return this.request(`/planning/items/${id}`, { method: "DELETE" }); return this._request(`/planning/items/${id}`, { method: "DELETE" });
} }
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */ /** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
public getDiets(): Promise<DietView[]> { public getDiets(): Promise<DietView[]> {
return this.request("/reference/diets"); return this._request("/reference/diets");
} }
/** Reference list of selectable allergens (signup wizard, `/foyer`). Public — no session required. */ /** Reference list of selectable allergens (signup wizard, `/foyer`). Public — no session required. */
public getAllergies(): Promise<AllergyView[]> { public getAllergies(): Promise<AllergyView[]> {
return this.request("/reference/allergies"); return this._request("/reference/allergies");
} }
/** Reference list of ingredients, each resolved to its allergens — static, non-administrable (recipe form's ingredient picker). Public — no session required. */ /** Reference list of ingredients, each resolved to its allergens — static, non-administrable (recipe form's ingredient picker). Public — no session required. */
public getIngredients(): Promise<IngredientView[]> { public getIngredients(): Promise<IngredientView[]> {
return this.request("/reference/ingredients"); return this._request("/reference/ingredients");
} }
/** Reference list of recipe ingredient units (g, kg, cuillère à soupe…) — static, non-administrable (recipe form's per-ingredient unit select). Public — no session required. */ /** Reference list of recipe ingredient units (g, kg, cuillère à soupe…) — static, non-administrable (recipe form's per-ingredient unit select). Public — no session required. */
public getUnits(): Promise<UnitView[]> { public getUnits(): Promise<UnitView[]> {
return this.request("/reference/units"); return this._request("/reference/units");
} }
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */ /** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
public getSources(): Promise<SourceView[]> { public getSources(): Promise<SourceView[]> {
return this.request("/reference/sources"); return this._request("/reference/sources");
} }
/** One page of `sourceKey`'s own catalog (recipe catalog's "Sources" tab), each item flagged with whether it's already been imported. Rejects with `SOURCE_NOT_FOUND` unless the viewer's household has this source enabled (`/parametres/foyer`). */ /** One page of `sourceKey`'s own catalog (recipe catalog's "Sources" tab), each item flagged with whether it's already been imported. Rejects with `SOURCE_NOT_FOUND` unless the viewer's household has this source enabled (`/parametres/foyer`). */
@ -184,12 +184,12 @@ export class ApiClient {
if (params.query) search.set("query", params.query); if (params.query) search.set("query", params.query);
if (params.cursor) search.set("cursor", params.cursor); if (params.cursor) search.set("cursor", params.cursor);
const queryString = search.toString(); const queryString = search.toString();
return this.request(`/sources/${sourceKey}/browse${queryString ? `?${queryString}` : ""}`); return this._request(`/sources/${sourceKey}/browse${queryString ? `?${queryString}` : ""}`);
} }
/** Fully translates one not-yet-saved source item (ingredients/units/techniques resolved where possible) — nothing is persisted. Rejects with `RECIPE_NOT_FOUND` if the source couldn't fetch/parse it. */ /** Fully translates one not-yet-saved source item (ingredients/units/techniques resolved where possible) — nothing is persisted. Rejects with `RECIPE_NOT_FOUND` if the source couldn't fetch/parse it. */
public previewSourceItem(sourceKey: string, externalId: string): Promise<RecipeImportDraftView> { public previewSourceItem(sourceKey: string, externalId: string): Promise<RecipeImportDraftView> {
return this.request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`); return this._request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`);
} }
/** Finalizes an import — `input` is a fully-resolved `CreateRecipeInput`, exactly like a manual `createRecipe()` call (the review screen, `ImportRecipePage`, is what makes sure of that before calling this). Rejects with `RECIPE_ALREADY_IMPORTED` if this item was imported since the preview was fetched. */ /** Finalizes an import — `input` is a fully-resolved `CreateRecipeInput`, exactly like a manual `createRecipe()` call (the review screen, `ImportRecipePage`, is what makes sure of that before calling this). Rejects with `RECIPE_ALREADY_IMPORTED` if this item was imported since the preview was fetched. */
@ -198,7 +198,7 @@ export class ApiClient {
externalId: string, externalId: string,
input: CreateRecipeInput, input: CreateRecipeInput,
): Promise<RecipeView> { ): Promise<RecipeView> {
return this.request(`/sources/${sourceKey}/import/${encodeURIComponent(externalId)}`, { return this._request(`/sources/${sourceKey}/import/${encodeURIComponent(externalId)}`, {
method: "POST", method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
@ -225,82 +225,82 @@ export class ApiClient {
if (filters.suitableForHousehold) params.set("suitableForHousehold", "true"); if (filters.suitableForHousehold) params.set("suitableForHousehold", "true");
for (const id of filters.ingredientIds ?? []) params.append("ingredientIds", String(id)); for (const id of filters.ingredientIds ?? []) params.append("ingredientIds", String(id));
for (const id of filters.dietIds ?? []) params.append("dietIds", String(id)); for (const id of filters.dietIds ?? []) params.append("dietIds", String(id));
return this.request(`/recipes?${params.toString()}`); return this._request(`/recipes?${params.toString()}`);
} }
/** Fetches one recipe's full detail — rejects with `RECIPE_NOT_FOUND` if `id` doesn't match any recipe. */ /** Fetches one recipe's full detail — rejects with `RECIPE_NOT_FOUND` if `id` doesn't match any recipe. */
public getRecipe(id: number): Promise<RecipeView> { public getRecipe(id: number): Promise<RecipeView> {
return this.request(`/recipes/${id}`); return this._request(`/recipes/${id}`);
} }
/** Adds a recipe to the catalog — rejects with `INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. */ /** Adds a recipe to the catalog — rejects with `INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. */
public createRecipe(input: CreateRecipeInput): Promise<RecipeView> { public createRecipe(input: CreateRecipeInput): Promise<RecipeView> {
return this.request("/recipes", { method: "POST", body: JSON.stringify(input) }); return this._request("/recipes", { method: "POST", body: JSON.stringify(input) });
} }
/** Replaces a recipe's full content (not a partial merge) — same rejections as {@link createRecipe}, plus `RECIPE_NOT_FOUND`. */ /** Replaces a recipe's full content (not a partial merge) — same rejections as {@link createRecipe}, plus `RECIPE_NOT_FOUND`. */
public updateRecipe(id: number, input: UpdateRecipeInput): Promise<RecipeView> { public updateRecipe(id: number, input: UpdateRecipeInput): Promise<RecipeView> {
return this.request(`/recipes/${id}`, { method: "PATCH", body: JSON.stringify(input) }); return this._request(`/recipes/${id}`, { method: "PATCH", body: JSON.stringify(input) });
} }
/** Removes a recipe from the catalog outright — rejects with `RECIPE_IN_USE` if it's still referenced by a planning item. */ /** Removes a recipe from the catalog outright — rejects with `RECIPE_IN_USE` if it's still referenced by a planning item. */
public deleteRecipe(id: number): Promise<void> { public deleteRecipe(id: number): Promise<void> {
return this.request(`/recipes/${id}`, { method: "DELETE" }); return this._request(`/recipes/${id}`, { method: "DELETE" });
} }
/** Favorites a recipe for the current user — idempotent. */ /** Favorites a recipe for the current user — idempotent. */
public addFavoriteRecipe(id: number): Promise<void> { public addFavoriteRecipe(id: number): Promise<void> {
return this.request(`/recipes/${id}/favorite`, { method: "POST" }); return this._request(`/recipes/${id}/favorite`, { method: "POST" });
} }
/** Unfavorites a recipe for the current user — idempotent. */ /** Unfavorites a recipe for the current user — idempotent. */
public removeFavoriteRecipe(id: number): Promise<void> { public removeFavoriteRecipe(id: number): Promise<void> {
return this.request(`/recipes/${id}/favorite`, { method: "DELETE" }); return this._request(`/recipes/${id}/favorite`, { method: "DELETE" });
} }
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */ /** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
public getCurrentHouse(): Promise<HouseView | null> { public getCurrentHouse(): Promise<HouseView | null> {
return this.request("/house/current"); return this._request("/house/current");
} }
/** Renames the current user's household. */ /** Renames the current user's household. */
public renameHouse(name: string): Promise<HouseView> { public renameHouse(name: string): Promise<HouseView> {
return this.request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) }); return this._request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) });
} }
/** Creates a new household, with the caller as its admin — rejects with `ALREADY_HAS_HOUSE` if they already belong to one. */ /** Creates a new household, with the caller as its admin — rejects with `ALREADY_HAS_HOUSE` if they already belong to one. */
public createHouse(name: string): Promise<HouseView> { public createHouse(name: string): Promise<HouseView> {
return this.request("/house", { method: "POST", body: JSON.stringify({ name }) }); return this._request("/house", { method: "POST", body: JSON.stringify({ name }) });
} }
/** Joins an existing household by invite code — rejects with `ALREADY_HAS_HOUSE`/`INVITE_CODE_NOT_FOUND`. */ /** Joins an existing household by invite code — rejects with `ALREADY_HAS_HOUSE`/`INVITE_CODE_NOT_FOUND`. */
public joinHouse(inviteCode: string): Promise<HouseView> { public joinHouse(inviteCode: string): Promise<HouseView> {
return this.request("/house/join", { method: "POST", body: JSON.stringify({ inviteCode }) }); return this._request("/house/join", { method: "POST", body: JSON.stringify({ inviteCode }) });
} }
/** Removes the current user from their household — hands off adminship or deletes the household if they were its last member (see the API's `house.service.ts`). */ /** Removes the current user from their household — hands off adminship or deletes the household if they were its last member (see the API's `house.service.ts`). */
public leaveHouse(): Promise<void> { public leaveHouse(): Promise<void> {
return this.request("/house/leave", { method: "POST" }); return this._request("/house/leave", { method: "POST" });
} }
/** Deletes the current user's household outright — every member loses it. Admin-only. */ /** Deletes the current user's household outright — every member loses it. Admin-only. */
public deleteHouse(): Promise<void> { public deleteHouse(): Promise<void> {
return this.request("/house/current", { method: "DELETE" }); return this._request("/house/current", { method: "DELETE" });
} }
/** Removes one specific member from the current user's household. Admin-only. */ /** Removes one specific member from the current user's household. Admin-only. */
public removeHouseMember(memberId: number): Promise<HouseView> { public removeHouseMember(memberId: number): Promise<HouseView> {
return this.request(`/house/members/${memberId}`, { method: "DELETE" }); return this._request(`/house/members/${memberId}`, { method: "DELETE" });
} }
/** Fetches the current user's household's enabled recipe-source ids — which sources show up in its recipe tabs. Rejects with `HOUSE_NOT_FOUND` if they have no household yet. */ /** Fetches the current user's household's enabled recipe-source ids — which sources show up in its recipe tabs. Rejects with `HOUSE_NOT_FOUND` if they have no household yet. */
public getHouseSourceIds(): Promise<number[]> { public getHouseSourceIds(): Promise<number[]> {
return this.request("/house/current/sources"); return this._request("/house/current/sources");
} }
/** Replaces the current user's household's full enabled-source selection (not a merge — send the complete list; empty hides every external source). Rejects with `HOUSE_NOT_FOUND`/`SOURCE_NOT_FOUND`. */ /** Replaces the current user's household's full enabled-source selection (not a merge — send the complete list; empty hides every external source). Rejects with `HOUSE_NOT_FOUND`/`SOURCE_NOT_FOUND`. */
public updateHouseSourceIds(sourceIds: number[]): Promise<number[]> { public updateHouseSourceIds(sourceIds: number[]): Promise<number[]> {
return this.request("/house/current/sources", { return this._request("/house/current/sources", {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ sourceIds }), body: JSON.stringify({ sourceIds }),
}); });
@ -308,17 +308,17 @@ export class ApiClient {
/** Sets (or clears, with `null`) the current user's dietary regime. */ /** Sets (or clears, with `null`) the current user's dietary regime. */
public updateDiet(dietId: number | null): Promise<SafeUserProfile> { public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) }); return this._request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
} }
/** Fetches the current user's selected allergen ids. */ /** Fetches the current user's selected allergen ids. */
public getAllergyIds(): Promise<number[]> { public getAllergyIds(): Promise<number[]> {
return this.request("/profile/allergies"); return this._request("/profile/allergies");
} }
/** Replaces the current user's full allergen selection (not a merge — send the complete list). */ /** Replaces the current user's full allergen selection (not a merge — send the complete list). */
public updateAllergyIds(allergyIds: number[]): Promise<number[]> { public updateAllergyIds(allergyIds: number[]): Promise<number[]> {
return this.request("/profile/allergies", { return this._request("/profile/allergies", {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ allergyIds }), body: JSON.stringify({ allergyIds }),
}); });
@ -326,12 +326,12 @@ export class ApiClient {
/** Fetches the current user's personally disliked ingredient ids — a taste preference, distinct from `getAllergyIds` (medical). */ /** Fetches the current user's personally disliked ingredient ids — a taste preference, distinct from `getAllergyIds` (medical). */
public getDislikedIngredientIds(): Promise<number[]> { public getDislikedIngredientIds(): Promise<number[]> {
return this.request("/profile/disliked-ingredients"); return this._request("/profile/disliked-ingredients");
} }
/** Replaces the current user's full disliked-ingredient selection (not a merge — send the complete list). */ /** Replaces the current user's full disliked-ingredient selection (not a merge — send the complete list). */
public updateDislikedIngredientIds(dislikedIngredientIds: number[]): Promise<number[]> { public updateDislikedIngredientIds(dislikedIngredientIds: number[]): Promise<number[]> {
return this.request("/profile/disliked-ingredients", { return this._request("/profile/disliked-ingredients", {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ dislikedIngredientIds }), body: JSON.stringify({ dislikedIngredientIds }),
}); });
@ -339,12 +339,12 @@ export class ApiClient {
/** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */ /** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */
public getPreferences(): Promise<PreferencesView> { public getPreferences(): Promise<PreferencesView> {
return this.request("/preferences"); return this._request("/preferences");
} }
/** Sets the current user's theme preference. */ /** Sets the current user's theme preference. */
public updatePreferences(theme: ThemePreference): Promise<PreferencesView> { public updatePreferences(theme: ThemePreference): Promise<PreferencesView> {
return this.request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) }); return this._request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) });
} }
} }

View file

@ -31,16 +31,16 @@ export class ErrorHandlerService {
*/ */
public handle(error: unknown): ErrorHandlingResult { public handle(error: unknown): ErrorHandlingResult {
if (error instanceof ZodError) { if (error instanceof ZodError) {
return this.fromZodError(error); return this._fromZodError(error);
} }
if (error instanceof HttpError) { if (error instanceof HttpError) {
return this.fromHttpError(error); return this._fromHttpError(error);
} }
return this.fromUnknownError(error); return this._fromUnknownError(error);
} }
/** Request body/query failed schema validation — always a 400. */ /** Request body/query failed schema validation — always a 400. */
private fromZodError(error: ZodError): ErrorHandlingResult { private _fromZodError(error: ZodError): ErrorHandlingResult {
return { return {
status: 400, status: 400,
body: { body: {
@ -52,7 +52,7 @@ export class ErrorHandlerService {
} }
/** Our own typed error — status/code were decided by whoever threw it. */ /** Our own typed error — status/code were decided by whoever threw it. */
private fromHttpError(error: HttpError): ErrorHandlingResult { private _fromHttpError(error: HttpError): ErrorHandlingResult {
return { return {
status: error.status, status: error.status,
body: { code: error.code, message: error.message }, body: { code: error.code, message: error.message },
@ -68,7 +68,7 @@ export class ErrorHandlerService {
* which sees every error including this exact "unrecognized" case * which sees every error including this exact "unrecognized" case
* before it ever reaches here). * before it ever reaches here).
*/ */
private fromUnknownError(_error: unknown): ErrorHandlingResult { private _fromUnknownError(_error: unknown): ErrorHandlingResult {
return { return {
status: 500, status: 500,
body: { code: ErrorCode.INTERNAL_ERROR, message: "Internal server error" }, body: { code: ErrorCode.INTERNAL_ERROR, message: "Internal server error" },

View file

@ -31,17 +31,17 @@ export interface ExpressServerCoreOptions {
*/ */
export class ExpressServer { export class ExpressServer {
/** The underlying Express application. */ /** The underlying Express application. */
private readonly app: Express; private readonly _app: Express;
/** Tracks `"METHOD path"` keys already registered via {@link addRoute}, to warn instead of silently double-registering a route. */ /** Tracks `"METHOD path"` keys already registered via {@link addRoute}, to warn instead of silently double-registering a route. */
private readonly registeredRoutes = new Set<string>(); private readonly _registeredRoutes = new Set<string>();
public constructor() { public constructor() {
this.app = express(); this._app = express();
} }
/** The underlying Express application — needed by test tooling (e.g. supertest) that expects a raw `Express` instance. */ /** The underlying Express application — needed by test tooling (e.g. supertest) that expects a raw `Express` instance. */
public get instance(): Express { public get instance(): Express {
return this.app; return this._app;
} }
/** /**
@ -51,14 +51,14 @@ export class ExpressServer {
* any route. * any route.
*/ */
public setupCore(options: ExpressServerCoreOptions): void { public setupCore(options: ExpressServerCoreOptions): void {
this.app.use(cors({ origin: options.corsOrigin, credentials: true })); this._app.use(cors({ origin: options.corsOrigin, credentials: true }));
this.app.use(express.json()); this._app.use(express.json());
this.app.use(cookieParser()); this._app.use(cookieParser());
} }
/** Registers a middleware that runs on every request (e.g. logging, a catch-all 404 handler). */ /** Registers a middleware that runs on every request (e.g. logging, a catch-all 404 handler). */
public addMiddleware(middleware: RequestHandler): void { public addMiddleware(middleware: RequestHandler): void {
this.app.use(middleware); this._app.use(middleware);
} }
/** /**
@ -67,12 +67,12 @@ export class ExpressServer {
* error handler by its arity, and only the last matching one runs. * error handler by its arity, and only the last matching one runs.
*/ */
public setErrorHandler(middleware: ErrorRequestHandler): void { public setErrorHandler(middleware: ErrorRequestHandler): void {
this.app.use(middleware); this._app.use(middleware);
} }
/** Mounts a whole `express.Router` under a base path (e.g. `mountRouter("/auth", authRouter)`). */ /** Mounts a whole `express.Router` under a base path (e.g. `mountRouter("/auth", authRouter)`). */
public mountRouter(basePath: string, router: Router): void { public mountRouter(basePath: string, router: Router): void {
this.app.use(basePath, router); this._app.use(basePath, router);
} }
/** /**
@ -88,8 +88,8 @@ export class ExpressServer {
* this, so `pnpm dev:web`'s own Vite dev server is unaffected. * this, so `pnpm dev:web`'s own Vite dev server is unaffected.
*/ */
public serveStaticFrontend(distDir: string): void { public serveStaticFrontend(distDir: string): void {
this.app.use(express.static(distDir)); this._app.use(express.static(distDir));
this.app.get("*", (_req, res) => { this._app.get("*", (_req, res) => {
res.sendFile(path.join(distDir, "index.html")); res.sendFile(path.join(distDir, "index.html"));
}); });
} }
@ -101,16 +101,16 @@ export class ExpressServer {
*/ */
public addRoute(method: HttpMethod, path: string, ...handlers: RequestHandler[]): void { public addRoute(method: HttpMethod, path: string, ...handlers: RequestHandler[]): void {
const key = `${method.toUpperCase()} ${path}`; const key = `${method.toUpperCase()} ${path}`;
if (this.registeredRoutes.has(key)) { if (this._registeredRoutes.has(key)) {
console.warn(`[ExpressServer] Route already registered, skipping: ${key}`); console.warn(`[ExpressServer] Route already registered, skipping: ${key}`);
return; return;
} }
this.registeredRoutes.add(key); this._registeredRoutes.add(key);
this.app[method](path, ...handlers); this._app[method](path, ...handlers);
} }
/** Starts listening on the given port. `onListening` is called once the server is up (e.g. to log the URL). */ /** Starts listening on the given port. `onListening` is called once the server is up (e.g. to log the URL). */
public listen(port: number, onListening?: () => void): void { public listen(port: number, onListening?: () => void): void {
this.app.listen(port, onListening); this._app.listen(port, onListening);
} }
} }