feat: add ESLint, lint-staged, and update pre-commit hook + CI

- Add ESLint 9 flat config (eslint.config.mjs) with typescript-eslint
  recommended rules and eslint-config-prettier
- Add lint-staged to run eslint+prettier only on staged files
- Update pre-commit hook to use lint-staged instead of full prettier check
- Add `lint` and `format:check` scripts to package.json
- Add Lint step to CI workflow
- Fix resulting lint errors: unused vars (_ctx, _options, catch binding),
  any→unknown in type declarations, stale eslint-disable comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-21 09:49:20 +02:00
parent e93bbb8d3e
commit 3aea41f862
11 changed files with 1799 additions and 32 deletions
+4 -1
View File
@@ -21,7 +21,10 @@ jobs:
- run: npm ci
- name: Format check
run: npx prettier --check '**/*.{js,ts,css,json,md}'
run: npm run format:check
- name: Lint
run: npm run lint
- name: Type check
run: npm run typecheck
+1
View File
@@ -0,0 +1 @@
npx lint-staged && npm run typecheck && npm test
+22
View File
@@ -0,0 +1,22 @@
import prettier from "eslint-config-prettier";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist/", "coverage/"] },
...tseslint.configs.recommended,
prettier,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
{
files: ["src/**/*.test.ts", "src/test/**/*.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
);
+1708 -6
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -6,12 +6,24 @@
"scripts": {
"build": "wrangler deploy --dry-run --outdir=dist",
"format": "prettier --write '**/*.{js,ts,css,json,md}'",
"format:check": "prettier --check '**/*.{js,ts,css,json,md}'",
"lint": "eslint src",
"dev": "wrangler dev",
"deploy": "wrangler deploy --env production",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"prepare": "husky"
},
"lint-staged": {
"*.{ts,js}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css}": [
"prettier --write"
]
},
"author": "",
"license": "MIT",
@@ -20,10 +32,14 @@
"@types/mailparser": "^3.4.6",
"@types/rss": "^0.0.32",
"@vitest/coverage-v8": "^4.0.18",
"eslint-config-prettier": "^10.1.8",
"happy-dom": "^20.5.0",
"husky": "^9.1.7",
"lint-staged": "^17.0.5",
"msw": "^2.12.8",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.59.4",
"vitest": "^4.0.18",
"wrangler": "^4.63.0"
},
+1 -1
View File
@@ -5,7 +5,7 @@ import { processEmail, RawAttachment } from "./email-processor";
export async function handleCloudflareEmail(
message: ForwardableEmailMessage,
env: Env,
ctx: ExecutionContext,
_ctx: ExecutionContext,
): Promise<void> {
try {
const email = await PostalMime.parse(message.raw);
+26 -8
View File
@@ -71,9 +71,11 @@ function timingSafeEqual(a: string, b: string): boolean {
const aBytes = enc.encode(a);
const bBytes = enc.encode(b);
// Try native timing-safe implementation first (Cloudflare Workers runtime)
if (typeof (crypto.subtle as any).timingSafeEqual === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const subtle = crypto.subtle as any;
if (typeof subtle.timingSafeEqual === "function") {
if (aBytes.length !== bBytes.length) return false;
return (crypto.subtle as any).timingSafeEqual(aBytes, bBytes);
return subtle.timingSafeEqual(aBytes, bBytes);
}
// Constant-time fallback for Node (test environment): encode length
// mismatch into `diff` so the loop always runs over the full length.
@@ -97,7 +99,9 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
// Proxy auth: only active when both env vars are present
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
const trustedIps = env.PROXY_TRUSTED_IPS.split(",").map((s) => s.trim()).filter(Boolean);
const trustedIps = env.PROXY_TRUSTED_IPS.split(",")
.map((s) => s.trim())
.filter(Boolean);
const clientIp = c.req.header("CF-Connecting-IP") ?? "";
const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? "";
const remoteUser =
@@ -113,7 +117,11 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
}
// Fallback: signed cookie
const authCookie = await getSignedCookie(c, env.ADMIN_PASSWORD, ADMIN_COOKIE_NAME);
const authCookie = await getSignedCookie(
c,
env.ADMIN_PASSWORD,
ADMIN_COOKIE_NAME,
);
if (authCookie !== "1") {
return c.redirect("/admin/login");
}
@@ -146,6 +154,7 @@ const authSchema = z.object({
});
// Base HTML layout with design system
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layout = (title: string, content: any) => {
return html`<!DOCTYPE html>
<html>
@@ -1610,11 +1619,14 @@ app.post("/feeds/create", async (c) => {
if (isJson) {
const body = await c.req.json<Record<string, unknown>>();
title = String(body.title ?? "");
description = body.description != null ? String(body.description) : undefined;
description =
body.description != null ? String(body.description) : undefined;
language = String(body.language ?? "en");
view = "list";
allowedSenders = Array.isArray(body.allowedSenders)
? normalizeAllowedSenders((body.allowedSenders as unknown[]).map(String))
? normalizeAllowedSenders(
(body.allowedSenders as unknown[]).map(String),
)
: [];
} else {
const formData = await c.req.formData();
@@ -1930,7 +1942,8 @@ async function purgeFeedKeysStep(
});
if (emailKeys.length > 0) {
const emailDataResults = await Promise.allSettled(
emailKeys.map((k) =>
emailKeys.map(
(k) =>
emailStorage.get(k, { type: "json" }) as Promise<EmailData | null>,
),
);
@@ -1976,7 +1989,12 @@ app.post("/feeds/:feedId/delete", async (c) => {
// Best-effort cleanup in the background so the request stays fast.
// Use the UI purge endpoint for full, user-visible progress.
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId, { bucket: env.ATTACHMENT_BUCKET }));
waitUntilSafe(
c,
purgeFeedKeysStep(emailStorage, feedId, {
bucket: env.ATTACHMENT_BUCKET,
}),
);
if (wantsJson) {
return c.json({ ok: true, feedId });
}
+13 -10
View File
@@ -71,7 +71,7 @@ class MockCache implements Cache {
async match(
request: RequestInfo,
options?: CacheQueryOptions,
_options?: CacheQueryOptions,
): Promise<Response | undefined> {
const key = request instanceof Request ? request.url : request;
const response = this.store.get(key);
@@ -80,7 +80,7 @@ class MockCache implements Cache {
async delete(
request: RequestInfo,
options?: CacheQueryOptions,
_options?: CacheQueryOptions,
): Promise<boolean> {
const key = request instanceof Request ? request.url : request;
return this.store.delete(key);
@@ -107,7 +107,6 @@ beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
// Type-safe access to Node's global object for setting Workers-like globals in tests
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const g = globalThis as any;
// Mock Cloudflare Workers runtime globals
@@ -118,31 +117,26 @@ beforeAll(() => {
// Mock crypto for generating random values
if (!g.crypto) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
g.crypto = require("crypto").webcrypto;
}
// Ensure other required globals are available
if (!g.FormData) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { FormData } = require("undici");
g.FormData = FormData;
}
if (!g.Headers) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Headers } = require("undici");
g.Headers = Headers;
}
if (!g.Request) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Request } = require("undici");
g.Request = Request;
}
if (!g.Response) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Response } = require("undici");
g.Response = Response;
}
@@ -173,7 +167,13 @@ export class MockR2 {
async put(
key: string,
value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob,
value:
| ReadableStream
| ArrayBuffer
| ArrayBufferView
| string
| null
| Blob,
options?: {
httpMetadata?: { contentType?: string; contentDisposition?: string };
},
@@ -216,7 +216,10 @@ export class MockR2 {
headers.set("Content-Type", entry.httpMetadata.contentType);
}
if (entry.httpMetadata?.contentDisposition) {
headers.set("Content-Disposition", entry.httpMetadata.contentDisposition);
headers.set(
"Content-Disposition",
entry.httpMetadata.contentDisposition,
);
}
},
};
+1 -1
View File
@@ -71,7 +71,7 @@ declare global {
// This is not an ideal solution but works for our example
interface KVNamespace {
get(key: string, options?: { type: "text" }): Promise<string | null>;
get(key: string, options: { type: "json" }): Promise<any | null>;
get(key: string, options: { type: "json" }): Promise<unknown>;
get(
key: string,
options: { type: "arrayBuffer" },
+2 -2
View File
@@ -15,7 +15,7 @@ declare module "rss" {
generator?: string;
categories?: string[];
custom_namespaces?: Record<string, string>;
custom_elements?: any[];
custom_elements?: unknown[];
}
interface RSSItemOptions {
@@ -34,7 +34,7 @@ declare module "rss" {
size?: number;
type?: string;
};
custom_elements?: any[];
custom_elements?: unknown[];
}
interface RSSXMLOptions {
+3 -1
View File
@@ -7,6 +7,7 @@ export class EmailParser {
return match ? match[1] : null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
static parseForwardEmailPayload(payload: any): EmailData {
if (!payload) {
throw new Error("Missing or invalid webhook payload");
@@ -30,6 +31,7 @@ export class EmailParser {
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private static extractHeaders(payload: any): Record<string, string> {
const headers: Record<string, string> = {};
@@ -62,7 +64,7 @@ export class EmailParser {
if (encoding.toUpperCase() === "B") {
try {
return atob(text);
} catch (e) {
} catch {
return text;
}
} else if (encoding.toUpperCase() === "Q") {