diff --git a/package.json b/package.json index db57bd378b0..e27c43d620e 100644 --- a/package.json +++ b/package.json @@ -1420,12 +1420,18 @@ "lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.ts", "lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs", "lint:plugins:plugin-sdk-subpaths-exported": "node scripts/check-plugin-sdk-subpath-exports.mjs", +<<<<<<< HEAD "lint:scripts": "pnpm lint:docker-e2e && node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.scripts.json scripts", "lint:swift": "swiftlint lint --config config/swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", +======= + "lint:scripts": "pnpm lint:docker-e2e && pnpm lint:tmp:no-raw-http2-imports && node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.scripts.json scripts", + "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", +>>>>>>> 59b4c14509 (lint: ban raw HTTP2 imports) "lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs", "lint:tmp:dynamic-import-warts": "node scripts/check-dynamic-import-warts.mjs", "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", "lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs", + "lint:tmp:no-raw-http2-imports": "node scripts/check-no-raw-http2-imports.mjs", "lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs", "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", "lint:web-fetch-provider-boundaries": "node scripts/check-web-fetch-provider-boundaries.mjs", diff --git a/scripts/check-no-raw-http2-imports.mjs b/scripts/check-no-raw-http2-imports.mjs new file mode 100644 index 00000000000..5c1b2c8c7d9 --- /dev/null +++ b/scripts/check-no-raw-http2-imports.mjs @@ -0,0 +1,120 @@ +import fs from "node:fs"; +import path from "node:path"; +const SOURCE_ROOTS = ["src", "extensions"]; +const DEFAULT_SKIPPED_DIR_NAMES = new Set(["node_modules", "dist", "coverage", ".generated"]); + +function isCodeFile(filePath) { + if (filePath.endsWith(".d.ts")) { + return false; + } + return /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(filePath); +} + +function collectFilesSync(rootDir, options) { + const skipDirNames = options.skipDirNames ?? DEFAULT_SKIPPED_DIR_NAMES; + const files = []; + const stack = [rootDir]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + let entries = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (!skipDirNames.has(entry.name)) { + stack.push(fullPath); + } + continue; + } + if (entry.isFile() && options.includeFile(fullPath)) { + files.push(fullPath); + } + } + } + + return files; +} + +function toPosixPath(filePath) { + return filePath.replaceAll("\\", "/"); +} + +const FORBIDDEN_HTTP2_MODULES = new Set(["node:http2", "http2"]); +const ALLOWED_PRODUCTION_FILES = new Set(["src/infra/push-apns-http2.ts"]); + +function isTestFile(relativePath) { + return ( + /(?:^|\/)(?:test|test-fixtures)\//u.test(relativePath) || + /\.test\.[cm]?[jt]sx?$/u.test(relativePath) + ); +} + +function lineNumberForOffset(content, offset) { + return content.slice(0, offset).split(/\r?\n/u).length; +} + +function collectHttp2ImportOffenders(filePath) { + const relativePath = toPosixPath(path.relative(process.cwd(), filePath)); + if (ALLOWED_PRODUCTION_FILES.has(relativePath) || isTestFile(relativePath)) { + return []; + } + + const content = fs.readFileSync(filePath, "utf8"); + const offenders = []; + const patterns = [ + /\bimport\s+(?:type\s+)?[\s\S]*?\bfrom\s*["']([^"']+)["']/gu, + /\bexport\s+(?:type\s+)?[\s\S]*?\bfrom\s*["']([^"']+)["']/gu, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/gu, + /\brequire\s*\(\s*["']([^"']+)["']\s*\)/gu, + ]; + + for (const pattern of patterns) { + for (const match of content.matchAll(pattern)) { + const specifier = match[1]; + if (specifier && FORBIDDEN_HTTP2_MODULES.has(specifier)) { + offenders.push({ + file: relativePath, + line: lineNumberForOffset(content, match.index ?? 0), + specifier, + }); + } + } + } + + return offenders; +} + +function collectSourceFiles() { + return SOURCE_ROOTS.flatMap((root) => + collectFilesSync(path.join(process.cwd(), root), { + includeFile: isCodeFile, + }), + ); +} + +function main() { + const offenders = collectSourceFiles().flatMap(collectHttp2ImportOffenders); + if (offenders.length === 0) { + console.log("OK: raw node:http2 imports stay behind the APNs proxy wrapper."); + return; + } + + console.error("Raw node:http2 imports are only allowed in src/infra/push-apns-http2.ts."); + for (const offender of offenders.toSorted( + (a, b) => a.file.localeCompare(b.file) || a.line - b.line, + )) { + console.error(`- ${offender.file}:${offender.line} imports ${offender.specifier}`); + } + console.error("Use connectApnsHttp2Session() so APNs HTTP/2 honors managed proxy policy."); + process.exit(1); +} + +main(); diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index 32086865b0b..695276167d5 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -1,6 +1,5 @@ import { createHash, createPrivateKey, sign as signJwt } from "node:crypto"; import fs from "node:fs/promises"; -import http2 from "node:http2"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import {