diff --git a/package.json b/package.json index db57bd378b0..54d43a21e6f 100644 --- a/package.json +++ b/package.json @@ -1426,6 +1426,7 @@ "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-connect": "node scripts/check-no-raw-http2-connect.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-connect.mjs b/scripts/check-no-raw-http2-connect.mjs new file mode 100644 index 00000000000..ea94797f216 --- /dev/null +++ b/scripts/check-no-raw-http2-connect.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +import path from "node:path"; +import ts from "typescript"; +import { runCallsiteGuard } from "./lib/callsite-guard.mjs"; +import { + collectCallExpressionLines, + runAsScript, + unwrapExpression, +} from "./lib/ts-guard-utils.mjs"; + +const sourceRoots = ["src", "extensions"]; +const allowedRawHttp2ConnectCallsites = new Set([ + "src/infra/push-apns-http2.ts:39", + "src/infra/push-apns-http2.ts:55", +]); + +function isHttp2ConnectCall(expression) { + const callee = unwrapExpression(expression); + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "connect") { + return false; + } + const receiver = unwrapExpression(callee.expression); + return ts.isIdentifier(receiver) && receiver.text === "http2"; +} + +export function findRawHttp2ConnectCallLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + return collectCallExpressionLines(ts, sourceFile, (node) => + isHttp2ConnectCall(node.expression) ? node.expression : null, + ); +} + +export async function main() { + await runCallsiteGuard({ + importMetaUrl: import.meta.url, + sourceRoots, + extraTestSuffixes: [".browser.test.ts", ".node.test.ts"], + findCallLines: findRawHttp2ConnectCallLines, + allowCallsite: (callsite) => allowedRawHttp2ConnectCallsites.has(callsite), + skipRelativePath: (relPath) => + relPath === path.posix.join("src", "infra", "push-apns-http2.test.ts"), + header: "Found raw http2.connect usage outside APNs proxy wrapper:", + footer: + "Use connectApnsHttp2Session() from src/infra/push-apns-http2.ts so APNs HTTP/2 honors managed proxy policy.", + }); +} + +runAsScript(import.meta.url, main); diff --git a/test/scripts/check-no-raw-http2-connect.test.ts b/test/scripts/check-no-raw-http2-connect.test.ts new file mode 100644 index 00000000000..49ae8962c7c --- /dev/null +++ b/test/scripts/check-no-raw-http2-connect.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { findRawHttp2ConnectCallLines } from "../../scripts/check-no-raw-http2-connect.mjs"; + +describe("check-no-raw-http2-connect", () => { + it("finds direct http2.connect calls", () => { + const source = ` + import http2 from "node:http2"; + export function connect() { + return http2.connect("https://api.push.apple.com"); + } + `; + + expect(findRawHttp2ConnectCallLines(source)).toEqual([4]); + }); + + it("finds parenthesized or asserted http2 references", () => { + const source = ` + import http2 from "node:http2"; + export function connect() { + return (http2 as typeof import("node:http2")).connect("https://api.push.apple.com"); + } + `; + + expect(findRawHttp2ConnectCallLines(source)).toEqual([4]); + }); + + it("ignores mentions in strings and comments", () => { + const source = ` + // http2.connect("https://api.push.apple.com") + const text = "http2.connect('https://api.push.apple.com')"; + `; + + expect(findRawHttp2ConnectCallLines(source)).toEqual([]); + }); +});