Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
72 changes: 72 additions & 0 deletions scripts/verify-edge-platform.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
72 changes: 72 additions & 0 deletions tests/protect/edge-export-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading