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