diff --git a/package.json b/package.json index 1eae529f..c1735c2b 100644 --- a/package.json +++ b/package.json @@ -53,10 +53,10 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/morgan": "^1.9.10", - "@types/node": "^22.9.0", + "@types/node": "^24.12.2", "@types/split2": "^4.2.3", "jasmine": "^6.2.0", "simple-git-hooks": "^2.13.1", - "typescript": "^5.6.3" + "typescript": "^6.0.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 585875aa..e2f877e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,8 +61,8 @@ importers: specifier: ^1.9.10 version: 1.9.10 '@types/node': - specifier: ^22.9.0 - version: 22.9.0 + specifier: ^24.12.2 + version: 24.12.2 '@types/split2': specifier: ^4.2.3 version: 4.2.3 @@ -73,8 +73,8 @@ importers: specifier: ^2.13.1 version: 2.13.1 typescript: - specifier: ^5.6.3 - version: 5.6.3 + specifier: ^6.0.3 + version: 6.0.3 packages: @@ -121,12 +121,12 @@ packages: '@types/morgan@1.9.10': resolution: {integrity: sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==} - '@types/node@20.16.1': - resolution: {integrity: sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==} - '@types/node@22.9.0': resolution: {integrity: sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==} + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} @@ -613,8 +613,8 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typescript@5.6.3: - resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -630,6 +630,9 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -675,7 +678,7 @@ snapshots: '@types/compression@1.8.1': dependencies: '@types/express': 5.0.6 - '@types/node': 20.16.1 + '@types/node': 22.9.0 '@types/connect@3.4.38': dependencies: @@ -704,13 +707,13 @@ snapshots: dependencies: '@types/node': 22.9.0 - '@types/node@20.16.1': + '@types/node@22.9.0': dependencies: undici-types: 6.19.8 - '@types/node@22.9.0': + '@types/node@24.12.2': dependencies: - undici-types: 6.19.8 + undici-types: 7.16.0 '@types/qs@6.15.0': {} @@ -727,7 +730,7 @@ snapshots: '@types/split2@4.2.3': dependencies: - '@types/node': 20.16.1 + '@types/node': 22.9.0 accepts@2.0.0: dependencies: @@ -1228,7 +1231,7 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript@5.6.3: {} + typescript@6.0.3: {} ucontent@2.0.0: dependencies: @@ -1246,6 +1249,8 @@ snapshots: undici-types@6.19.8: {} + undici-types@7.16.0: {} + unpipe@1.0.0: {} uparser@0.2.1: {} diff --git a/routes/caniuse/feature.ts b/routes/caniuse/feature.ts index 59b295b4..11fa29c9 100644 --- a/routes/caniuse/feature.ts +++ b/routes/caniuse/feature.ts @@ -24,10 +24,12 @@ export default async function route(req: IRequest, res: Response) { } res.json({ result }); } catch (error) { - const errorCode = error.message === "INTERNAL_ERROR" ? 500 : 404; + const message = error instanceof Error ? error.message : String(error); + const errorCode = message === "INTERNAL_ERROR" ? 500 : 404; + console.error("caniuse feature route error", error); res.status(errorCode); res.setHeader("Content-Type", "text/plain"); - res.send(error.message); + res.send(errorCode === 500 ? "Internal Server Error" : "Not Found"); } } diff --git a/routes/caniuse/update.ts b/routes/caniuse/update.ts index 85472cce..35592f12 100644 --- a/routes/caniuse/update.ts +++ b/routes/caniuse/update.ts @@ -5,7 +5,7 @@ import { cache } from "./lib/index.js"; import { Request, Response } from "express"; const workerFile = path.join(import.meta.dirname, "update.worker.js"); -const taskQueue = new BackgroundTaskQueue( +const taskQueue = new BackgroundTaskQueue( workerFile, "caniuse_update", ); diff --git a/routes/docs/update.ts b/routes/docs/update.ts index b9ede8ac..88c05f72 100644 --- a/routes/docs/update.ts +++ b/routes/docs/update.ts @@ -14,7 +14,8 @@ export default async function route(_req: Request, res: Response) { console.log(`Successfully regenerated docs in ${Date.now() - start}ms.`); res.sendStatus(200); // ok } catch (error) { - const { message = "", statusCode = 500 } = error; + const message = error instanceof Error ? error.message : String(error); + const statusCode = error instanceof HTTPError ? error.statusCode : 500; console.error(`Failed to regenerate docs: ${message.slice(0, 400)}...`); res.status(statusCode); res.send(message); diff --git a/routes/github/commits.ts b/routes/github/commits.ts index 8099421e..2a9330b2 100644 --- a/routes/github/commits.ts +++ b/routes/github/commits.ts @@ -36,7 +36,8 @@ export default async function route(req: IRequest, res: Response) { } res.json(commits); } catch (error) { + console.error("Failed to fetch commits", error); res.set("Content-Type", "text/plain"); - res.status(404).send(error.message); + res.status(404).send("Unable to fetch commits"); } } diff --git a/routes/github/contributors.ts b/routes/github/contributors.ts index aa53e8c7..9032b2c7 100644 --- a/routes/github/contributors.ts +++ b/routes/github/contributors.ts @@ -41,7 +41,7 @@ export default async function route(req: IRequest, res: Response) { contributors.push(contributor); } } catch (err) { - if (err.message && err.message.includes("404 Not Found")) { + if (err instanceof Error && err.message.includes("404 Not Found")) { await cache.set(cacheKey, null); return res.sendStatus(404); } else { diff --git a/routes/github/files.ts b/routes/github/files.ts index 5f6a7227..ccbf4c9b 100644 --- a/routes/github/files.ts +++ b/routes/github/files.ts @@ -17,10 +17,12 @@ export default async function route(req: IRequest, res: Response) { res.set("Cache-Control", `max-age=${seconds("30m")}`); res.json({ entries }); } catch (error) { - const errorCode = error.message === "INTERNAL_ERROR" ? 500 : 404; + const message = error instanceof Error ? error.message : String(error); + const errorCode = message === "INTERNAL_ERROR" ? 500 : 404; + console.error("Failed to get repository files", error); res.status(errorCode); res.setHeader("Content-Type", "text/plain"); - res.send(error.message); + res.send(errorCode === 500 ? "Internal server error" : "Not found"); } } diff --git a/routes/respec/builds/update.ts b/routes/respec/builds/update.ts index 434939ac..08d0a839 100644 --- a/routes/respec/builds/update.ts +++ b/routes/respec/builds/update.ts @@ -3,7 +3,7 @@ import { mkdir, readFile } from "node:fs/promises"; import type { Request, Response } from "express"; -import { env } from "../../../utils/misc.js"; +import { env, HTTPError } from "../../../utils/misc.js"; import sh from "../../../utils/sh.js"; export const PKG_DIR = path.join(env("DATA_DIR"), "respec", "package"); @@ -25,7 +25,8 @@ export default async function route(req: Request, res: Response) { await pullRelease(); res.sendStatus(200); // ok } catch (error) { - const { message = "", statusCode = 500 } = error; + const message = error instanceof Error ? error.message : String(error); + const statusCode = error instanceof HTTPError ? error.statusCode : 500; console.error(`Failed to pull respec release: ${message.slice(0, 400)}...`); res.status(statusCode); res.send(message); diff --git a/routes/w3c/group.ts b/routes/w3c/group.ts index cefecbd7..f3c0be05 100644 --- a/routes/w3c/group.ts +++ b/routes/w3c/group.ts @@ -65,7 +65,8 @@ export default async function route(req: IRequest, res: Response) { res.set("Cache-Control", `max-age=${seconds("24h")}`); res.json(groupInfo); } catch (error) { - const { statusCode = 500, message } = error; + const statusCode = error instanceof HTTPError ? error.statusCode : 500; + const message = error instanceof Error ? error.message : String(error); res.set("Content-Type", "text/plain"); res.status(statusCode).send(message); } @@ -117,9 +118,10 @@ async function fetchGroupInfo( } var json = (await res.json()) as APIResponse; } catch (error) { + if (error instanceof HTTPError) throw error; throw new HTTPError( - error.statusCode || 500, - error.message + 500, + error instanceof Error ? error.message : String(error), ); } diff --git a/routes/xref/lib/search.ts b/routes/xref/lib/search.ts index 13678cfc..ca67dd0a 100644 --- a/routes/xref/lib/search.ts +++ b/routes/xref/lib/search.ts @@ -105,7 +105,7 @@ export function searchOne( function normalizeQuery(query: Query, options: Options) { if (Array.isArray(query.specs) && !Array.isArray(query.specs[0])) { - // @ts-ignore + // @ts-expect-error - backward compatibility: wrapping flat specs array query.specs = [query.specs]; // for backward compatibility } if (!Array.isArray(query.types) || !query.types.length) { diff --git a/routes/xref/lib/store.ts b/routes/xref/lib/store.ts index 41c536eb..7a5f4d25 100644 --- a/routes/xref/lib/store.ts +++ b/routes/xref/lib/store.ts @@ -1,7 +1,7 @@ import path from "path"; import { readFileSync } from "fs"; -import { env } from "../../../utils/misc.js"; +import { env, getErrnoCode } from "../../../utils/misc.js"; import { DataEntry } from "./search.js"; import { HeadingEntry, HeadingsBySpec } from "./scraper.js"; @@ -94,8 +94,8 @@ function readJson(filename: string) { function readJsonOptional(filename: string) { try { return readJson(filename); - } catch (err: any) { - if (err?.code === "ENOENT") { + } catch (err: unknown) { + if (getErrnoCode(err) === "ENOENT") { console.warn(`Optional data file not found: ${filename}`); return {}; } diff --git a/routes/xref/update.ts b/routes/xref/update.ts index af34502c..b80ea137 100644 --- a/routes/xref/update.ts +++ b/routes/xref/update.ts @@ -9,7 +9,7 @@ import { cache as searchCache } from "./lib/search.js"; import { store } from "./lib/store-init.js"; const workerFile = path.join(import.meta.dirname, "update.worker.js"); -const taskQueue = new BackgroundTaskQueue( +const taskQueue = new BackgroundTaskQueue( workerFile, "xref_update", ); diff --git a/tests/utils/misc.test.js b/tests/utils/misc.test.js new file mode 100644 index 00000000..94f4c2e4 --- /dev/null +++ b/tests/utils/misc.test.js @@ -0,0 +1,38 @@ +import { getErrnoCode } from "../../build/utils/misc.js"; + +describe("utils/misc", () => { + describe("getErrnoCode", () => { + it("returns the string code from an Error with a code property", () => { + const err = Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + expect(getErrnoCode(err)).toBe("ENOENT"); + }); + + it("returns the string code from a plain object with a code property", () => { + expect(getErrnoCode({ code: "EACCES" })).toBe("EACCES"); + }); + + it("returns undefined when the code property is not a string", () => { + expect(getErrnoCode({ code: 42 })).toBeUndefined(); + }); + + it("returns undefined for an Error without a code property", () => { + expect(getErrnoCode(new Error("oops"))).toBeUndefined(); + }); + + it("returns undefined for null", () => { + expect(getErrnoCode(null)).toBeUndefined(); + }); + + it("returns undefined for undefined", () => { + expect(getErrnoCode(undefined)).toBeUndefined(); + }); + + it("returns undefined for a string", () => { + expect(getErrnoCode("ENOENT")).toBeUndefined(); + }); + + it("returns undefined for a number", () => { + expect(getErrnoCode(404)).toBeUndefined(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 3f7ed137..a6e4a6fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,11 +5,11 @@ "target": "ESNext", "removeComments": false, "allowSyntheticDefaultImports": true, - "moduleResolution": "node", + "moduleResolution": "nodenext", "noImplicitThis": true, "strictNullChecks": true, "noImplicitAny": true, - "module": "esnext", + "module": "nodenext", "outDir": "build", "sourceMap": true, "resolveJsonModule": true diff --git a/utils/background-task-queue.ts b/utils/background-task-queue.ts index 125758a7..9772c237 100644 --- a/utils/background-task-queue.ts +++ b/utils/background-task-queue.ts @@ -83,7 +83,7 @@ class Logger { id: string; input: unknown; timings: Record = {}; - result: { type: "success" | "failure"; value: unknown }; + result!: { type: "success" | "failure"; value: unknown }; stdout: [date: Date, line: string][] = []; stderr: [date: Date, line: string][] = []; @@ -118,7 +118,9 @@ class Logger { } } -type TaskModule = { default: (input?: unknown) => unknown }; +// Method shorthand syntax enables bivariant parameter checking so concrete +// worker input types (e.g. `{ webhookId: string }`) satisfy this constraint. +type TaskModule = { default(input?: unknown): unknown }; /** * Create a worker thread to run a task in background. A task/job added to the * queue with `add()`, and run serially in order of calling `run()` on the job diff --git a/utils/disk-cache.ts b/utils/disk-cache.ts index cc4db243..8237e91a 100644 --- a/utils/disk-cache.ts +++ b/utils/disk-cache.ts @@ -1,7 +1,7 @@ import path from "path"; import { mkdir, readFile, unlink, writeFile } from "fs/promises"; -import { env } from "./misc.js"; +import { env, getErrnoCode } from "./misc.js"; import { MemCache } from "./mem-cache.js"; interface CacheEntry { @@ -86,7 +86,7 @@ export class DiskCache { const text = await readFile(fileName, "utf-8"); return JSON.parse(text) as CacheEntry; } catch (error) { - if (error.code !== "ENOENT") { + if (getErrnoCode(error) !== "ENOENT") { console.error(error); } } diff --git a/utils/misc.ts b/utils/misc.ts index 2564c701..f3cfbd52 100644 --- a/utils/misc.ts +++ b/utils/misc.ts @@ -50,6 +50,18 @@ export function ms(duration: string) { return seconds(duration) * 1000; } +/** + * Extract the `code` property from a thrown value, or return `undefined`. + * Safe to call with any `unknown` catch value (strings, plain objects, etc.). + */ +export function getErrnoCode(err: unknown): string | undefined { + if (typeof err === "object" && err !== null && "code" in err) { + const { code } = err as NodeJS.ErrnoException; + return typeof code === "string" ? code : undefined; + } + return undefined; +} + export class HTTPError extends Error { constructor(public statusCode: number, message: string, public url?: string) { super(message); diff --git a/utils/view-engine.ts b/utils/view-engine.ts index 4cc01a3e..f1725ced 100644 --- a/utils/view-engine.ts +++ b/utils/view-engine.ts @@ -1,18 +1,14 @@ import { Application } from "express"; -async function engine( - filePath: string, - options: Record, - callback: (err: Error | null, rendered?: string) => void, -) { - try { - const { default: template } = await import(filePath); - const html: string = template(options).toString(); - callback(null, html); - } catch (error) { - callback(error); - } -} +type EngineCallback = Parameters[1]; + +const engine: EngineCallback = (filePath, options, callback) => { + import(filePath).then( + ({ default: template }) => callback(null, template(options).toString()), + error => + callback(error instanceof Error ? error : new Error(String(error))), + ); +}; export function register(app: Application) { app.engine("js", engine);