|
| 1 | +import { readFileSync, readdirSync } from 'node:fs' |
| 2 | +import { dirname, join, resolve } from 'node:path' |
| 3 | +import { fileURLToPath } from 'node:url' |
| 4 | +import { describe, expect, it } from 'vitest' |
| 5 | + |
| 6 | +/** |
| 7 | + * Edge-safety guard for issue #487. |
| 8 | + * |
| 9 | + * `@tanstack/ai-code-mode` must bundle cleanly for browsers and edge runtimes |
| 10 | + * (Cloudflare Workers/Pages etc.). That means the source must not import |
| 11 | + * esbuild (a Node-native binary that also pulls in `require("pnpapi")`) or any |
| 12 | + * Node-only built-in module. This is a fast static guard — the full |
| 13 | + * browser/Workers bundle smoke test lives outside the unit suite. |
| 14 | + */ |
| 15 | + |
| 16 | +const here = dirname(fileURLToPath(import.meta.url)) |
| 17 | +const pkgRoot = resolve(here, '..') |
| 18 | +const srcDir = join(pkgRoot, 'src') |
| 19 | + |
| 20 | +// Modules that break edge/browser bundling if imported from source. |
| 21 | +const FORBIDDEN = [ |
| 22 | + 'esbuild', |
| 23 | + 'fs', |
| 24 | + 'path', |
| 25 | + 'os', |
| 26 | + 'child_process', |
| 27 | + 'worker_threads', |
| 28 | + 'module', |
| 29 | + 'vm', |
| 30 | + 'crypto', |
| 31 | +] |
| 32 | + |
| 33 | +// One matcher per forbidden module, compiled once. Matches the real import |
| 34 | +// forms — `from 'mod'`, `import('mod')`, `require('mod')` — tolerating the |
| 35 | +// `node:` prefix and a `/subpath` (so `node:fs/promises` is still caught). |
| 36 | +// `mod` is escaped before interpolation: the current FORBIDDEN entries have no |
| 37 | +// regex metacharacters (so this is a no-op today), but it keeps the pattern |
| 38 | +// correct if a future entry contains one and silences a static-analysis warning. |
| 39 | +const escapeRegex = (s: string): string => |
| 40 | + s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') |
| 41 | +const FORBIDDEN_PATTERNS = FORBIDDEN.map((mod) => ({ |
| 42 | + mod, |
| 43 | + pattern: new RegExp( |
| 44 | + `(?:from|import|require)\\s*\\(?\\s*['"](?:node:)?${escapeRegex(mod)}(?:/[^'"]*)?['"]`, |
| 45 | + ), |
| 46 | +})) |
| 47 | + |
| 48 | +function collectTsFiles(dir: string): Array<string> { |
| 49 | + const out: Array<string> = [] |
| 50 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 51 | + const full = join(dir, entry.name) |
| 52 | + if (entry.isDirectory()) out.push(...collectTsFiles(full)) |
| 53 | + else if (entry.name.endsWith('.ts')) out.push(full) |
| 54 | + } |
| 55 | + return out |
| 56 | +} |
| 57 | + |
| 58 | +// Remove comments while PRESERVING string/template literals, so a JSDoc example |
| 59 | +// that mentions esbuild (the documented opt-in `transpile` adapter) doesn't trip |
| 60 | +// the scan, and — equally important — a `//` or `/* */` sequence inside a string |
| 61 | +// literal can't swallow a real import on the same line. The leading |
| 62 | +// string-literal alternative is matched first so its contents are consumed (and |
| 63 | +// kept) before the comment alternatives can see them. |
| 64 | +function stripComments(text: string): string { |
| 65 | + return text.replace( |
| 66 | + /(["'`])(?:\\.|(?!\1)[^\\])*\1|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, |
| 67 | + (match, quote: string | undefined) => (quote ? match : ''), |
| 68 | + ) |
| 69 | +} |
| 70 | + |
| 71 | +describe('edge-safety (#487)', () => { |
| 72 | + it('does not depend on esbuild in any install-facing bucket', () => { |
| 73 | + const pkg = JSON.parse( |
| 74 | + readFileSync(join(pkgRoot, 'package.json'), 'utf8'), |
| 75 | + ) as { |
| 76 | + dependencies?: Record<string, string> |
| 77 | + peerDependencies?: Record<string, string> |
| 78 | + optionalDependencies?: Record<string, string> |
| 79 | + } |
| 80 | + // Buckets that pull a package into a consumer's install (and thus its |
| 81 | + // bundle). devDependencies don't ship, so they're intentionally not checked. |
| 82 | + for (const bucket of [ |
| 83 | + pkg.dependencies, |
| 84 | + pkg.peerDependencies, |
| 85 | + pkg.optionalDependencies, |
| 86 | + ]) { |
| 87 | + expect(bucket ?? {}).not.toHaveProperty('esbuild') |
| 88 | + } |
| 89 | + }) |
| 90 | + |
| 91 | + it('no source file imports esbuild or a Node-only built-in', () => { |
| 92 | + const files = collectTsFiles(srcDir) |
| 93 | + expect(files.length).toBeGreaterThan(0) |
| 94 | + |
| 95 | + const offenders: Array<string> = [] |
| 96 | + for (const file of files) { |
| 97 | + const text = stripComments(readFileSync(file, 'utf8')) |
| 98 | + for (const { mod, pattern } of FORBIDDEN_PATTERNS) { |
| 99 | + if (pattern.test(text)) { |
| 100 | + offenders.push(`${file.replace(pkgRoot, '.')} -> ${mod}`) |
| 101 | + } |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + expect(offenders).toEqual([]) |
| 106 | + }) |
| 107 | +}) |
| 108 | + |
| 109 | +describe('edge-safety guard self-test', () => { |
| 110 | + // Mirror the scan against crafted inputs to lock in the false-positive / |
| 111 | + // false-negative fixes the guard depends on (a comment-only reference must be |
| 112 | + // ignored; a real import must survive even when a comment-like string shares |
| 113 | + // its line; subpath imports must still match). |
| 114 | + const hits = (src: string): Array<string> => { |
| 115 | + const text = stripComments(src) |
| 116 | + return FORBIDDEN_PATTERNS.filter(({ pattern }) => pattern.test(text)).map( |
| 117 | + ({ mod }) => mod, |
| 118 | + ) |
| 119 | + } |
| 120 | + |
| 121 | + it('flags a real Node-only import', () => { |
| 122 | + expect(hits(`import { readFile } from 'fs'`)).toContain('fs') |
| 123 | + expect(hits(`const x = require('esbuild')`)).toContain('esbuild') |
| 124 | + }) |
| 125 | + |
| 126 | + it('ignores a reference that lives only in a comment', () => { |
| 127 | + expect(hits(`/** import { transformSync } from 'esbuild' */`)).toEqual([]) |
| 128 | + expect(hits(`// import fs from 'fs'`)).toEqual([]) |
| 129 | + }) |
| 130 | + |
| 131 | + it('still flags a real import sharing a line with a comment-like string', () => { |
| 132 | + expect(hits(`const s = "a//b"; import fs from 'fs'`)).toContain('fs') |
| 133 | + expect( |
| 134 | + hits(`const a = "/*"; import { x } from 'path'; const b = "*/"`), |
| 135 | + ).toContain('path') |
| 136 | + }) |
| 137 | + |
| 138 | + it('flags Node-only subpath imports', () => { |
| 139 | + expect(hits(`import { readFile } from 'node:fs/promises'`)).toContain('fs') |
| 140 | + expect(hits(`import x from 'path/posix'`)).toContain('path') |
| 141 | + }) |
| 142 | +}) |
0 commit comments