diff --git a/package.json b/package.json index f79db2b..7d99ec6 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ ], "scripts": { "build": "tsup && node scripts/build-edge.mjs && node scripts/copy-protect-templates.mjs", + "verify:edge": "node scripts/verify-edge-platform.mjs", "dev": "tsup --watch", "test": "vitest run", "test:manifest": "bun scripts/test-manifest.ts", diff --git a/scripts/verify-edge-platform.mjs b/scripts/verify-edge-platform.mjs new file mode 100644 index 0000000..65b0ee6 --- /dev/null +++ b/scripts/verify-edge-platform.mjs @@ -0,0 +1,72 @@ +// Platform-integration check: compile `@patchstack/connect/protect` with the REAL Cloudflare Workers +// toolchain (wrangler), not a simulation. +// +// Why this exists separately from the test suite: it downloads wrangler and shells out to a platform +// bundler, so it needs network and takes far longer than a unit test — it must not sit in `npm test` +// (which CI runs on four Node versions). The suite covers the same property two cheaper ways: +// - tests/protect/edge-bundle.test.ts — the artifact is edge-bundleable and still enforces +// - tests/protect/edge-export-resolution.test.ts — a consumer's import resolves to the edge branch +// This script is the end-to-end confirmation that a real platform bundler agrees. +// +// node scripts/verify-edge-platform.mjs (or: npm run verify:edge) +// +// Exits non-zero on failure, so it can be wired into a release job. +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +const repo = fileURLToPath(new URL('..', import.meta.url)); +const fail = (msg) => { console.error(`FAIL: ${msg}`); process.exit(1); }; + +if (!existsSync(join(repo, 'dist', 'protect.edge.js'))) { + console.log('building dist/ first…'); + execFileSync('npm', ['run', 'build'], { cwd: repo, stdio: 'inherit' }); +} + +const dir = mkdtempSync(join(tmpdir(), 'ps-edge-platform-')); +try { + mkdirSync(join(dir, 'node_modules', '@patchstack'), { recursive: true }); + symlinkSync(repo, join(dir, 'node_modules', '@patchstack', 'connect'), 'dir'); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'ps-edge-fixture', private: true, type: 'module' })); + writeFileSync(join(dir, 'wrangler.toml'), [ + 'name = "ps-edge-fixture"', + 'main = "worker.js"', + 'compatibility_date = "2024-09-01"', + '', + ].join('\n')); + // A realistic Worker: build the guard once, screen every request through it. + writeFileSync(join(dir, 'worker.js'), [ + 'import { createProtection } from "@patchstack/connect/protect";', + 'let guard;', + 'export default {', + ' async fetch(request) {', + ' guard ??= await createProtection({ rules: { firewall: [], whitelists: [], whitelist_keys: {} }, mode: "block" });', + ' return (await guard.fetchGuard()(request)) ?? new Response("ok");', + ' },', + '};', + '', + ].join('\n')); + + console.log('compiling with wrangler (real Workers bundler)…'); + execFileSync('npx', ['--yes', 'wrangler@4', 'deploy', '--dry-run', '--outdir=out'], { + cwd: dir, + stdio: 'inherit', + env: { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, + }); + + const out = join(dir, 'out', 'worker.js'); + if (!existsSync(out)) fail('wrangler produced no bundle'); + const bundle = readFileSync(out, 'utf8'); + + // The edge artifact is the only one carrying the Node-only stub message: proves the `workerd` + // condition selected it rather than the Node build. + if (!bundle.includes('Node-only')) fail('wrangler resolved the NODE build, not dist/protect.edge.js'); + const nodeImports = bundle.match(/from\s*["'](?:node:)?(?:fs|fs\/promises|path|dns|net|os|child_process)["']/g); + if (nodeImports) fail(`Workers bundle references Node builtins: ${[...new Set(nodeImports)].join(', ')}`); + + console.log('\nOK: wrangler compiled the Worker, selected dist/protect.edge.js, and the bundle has no Node builtins.'); +} finally { + rmSync(dir, { recursive: true, force: true }); +} diff --git a/tests/protect/edge-export-resolution.test.ts b/tests/protect/edge-export-resolution.test.ts new file mode 100644 index 0000000..eb9df56 --- /dev/null +++ b/tests/protect/edge-export-resolution.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +// Resolution test, complementing edge-bundle.test.ts. That one bundles dist/protect.edge.js DIRECTLY, +// which proves the artifact is edge-clean but NOT that a consumer ever reaches it — a mis-ordered or +// mistyped `exports` condition would silently hand an edge bundler the Node build. This test imports +// the real package specifier (`@patchstack/connect/protect`) from a fixture with the package linked into +// node_modules, and resolves it under each edge condition. +// +// The CONTROL is what makes it meaningful: with no edge condition the same import resolves to the Node +// build and FAILS to bundle for a Node-free target. So a pass here is caused by the condition, not by +// the target being lenient. + +const repo = fileURLToPath(new URL('../../', import.meta.url)); +let dir: string; + +async function bundleWith(conditions: string[]): Promise<{ ok: boolean; text: string; errors: string[] }> { + const esbuild = await import('esbuild'); + try { + const r = await esbuild.build({ + entryPoints: [join(dir, 'entry.js')], + bundle: true, + write: false, + format: 'esm', + // 'neutral' adds no implicit conditions — 'browser' would inject the `browser` condition and mask + // whether the edge conditions themselves work. + platform: 'neutral', + conditions, + absWorkingDir: dir, + logLevel: 'silent', + }); + return { ok: true, text: r.outputFiles[0]!.text, errors: [] }; + } catch (e: any) { + return { ok: false, text: '', errors: (e.errors ?? []).map((x: any) => x.text) }; + } +} + +describe('edge conditional-export resolution', () => { + beforeAll(() => { + // The exports map points at dist/, so the artifacts must exist. CI runs tests before the build. + if (!existsSync(join(repo, 'dist', 'protect.edge.js')) || !existsSync(join(repo, 'dist', 'protect.js'))) { + execFileSync('npm', ['run', 'build'], { cwd: repo, stdio: 'ignore' }); + } + dir = mkdtempSync(join(tmpdir(), 'ps-export-res-')); + mkdirSync(join(dir, 'node_modules', '@patchstack'), { recursive: true }); + symlinkSync(repo, join(dir, 'node_modules', '@patchstack', 'connect'), 'dir'); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fixture', private: true, type: 'module' })); + writeFileSync(join(dir, 'entry.js'), 'import { createProtection } from "@patchstack/connect/protect";\nexport { createProtection };\n'); + }, 300_000); + + it.each(['workerd', 'worker', 'edge-light', 'deno', 'browser'])( + 'resolves @patchstack/connect/protect to the edge build under the %s condition', + async (condition) => { + const r = await bundleWith([condition, 'import']); + expect(r.errors).toEqual([]); + expect(r.ok).toBe(true); + // The edge artifact is the only one carrying the Node-only stub message. + expect(r.text).toContain('Node-only'); + }, + 60_000, + ); + + it('CONTROL: without an edge condition it resolves to the Node build, which is not edge-bundleable', async () => { + const r = await bundleWith(['import']); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toMatch(/Could not resolve "(node:)?(fs|path)"/); + }, 60_000); +});