chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* Every named import in a browser app resolves to something its target
* actually exports.
*
* The `.jsx` files are in neither typecheck project, so tsc never reads them,
* and biome's noUndeclaredVariables sees a name that was imported as declared
* whether or not the module has it. A rename that misses one call site
* therefore passes the whole gate and fails in the browser on first paint,
* which is what this closes.
*
* Reading the source rather than importing it: a component module pulls in the
* DOM, `import.meta.env` and a React runtime, none of which a node test has.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/** The apps with a `#/` alias, which is where the exposure is. */
const APPS = ['git-ui', 'spaces-ui', 'account-ui'];
/** @param {string} dir @returns {string[]} */
function sourcesUnder(dir) {
/** @type {string[]} */
const found = [];
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) found.push(...sourcesUnder(path));
else if (/\.jsx?$/.test(name)) found.push(path);
}
return found;
}
/**
* The names one module exports, from its source. Covers the three forms this
* codebase writes: a declaration, a list, and a list re-exported from
* elsewhere, which is followed to the module that declares them.
* @param {string} path
* @param {Set<string>} [seen] - modules already followed, so a cycle ends
* @returns {Set<string>|null} null where the file is not there
*/
function exportsOf(path, seen = new Set()) {
if (seen.has(path)) return new Set();
seen.add(path);
/** @type {string} */
let source;
try {
source = readFileSync(path, 'utf8');
} catch {
return null;
}
/** @type {Set<string>} */
const names = new Set();
const declared =
/^export\s+(?:async\s+)?(?:function|const|let|class)\s+(\w+)/gm;
for (const match of source.matchAll(declared)) names.add(match[1]);
const listed = /^export\s*\{([^}]*)\}\s*(?:from\s*'([^']+)')?/gm;
for (const match of source.matchAll(listed)) {
for (const part of match[1].split(',')) {
const name = part
.trim()
.split(/\s+as\s+/)
.pop()
?.trim();
if (name) names.add(name);
}
// A re-export of a whole module's names cannot be read from the list, so
// the module it names is read too.
if (match[2]?.startsWith('.')) {
const target = resolve(dirname(path), match[2]);
for (const name of exportsOf(target, seen) ?? []) names.add(name);
}
}
return names;
}
/**
* Every `import { … } from '#/…'` in one file.
* @param {string} source
* @returns {Array<{names: string[], from: string}>}
*/
function importsIn(source) {
return [
...source.matchAll(/import\s*\{([^}]*)\}\s*from\s*'(#\/[^']+)'/g),
].map((match) => ({
names: match[1]
.split(',')
.map((/** @type {string} */ part) =>
part
.trim()
.split(/\s+as\s+/)[0]
.trim(),
)
.filter(Boolean),
from: match[2],
}));
}
describe.each(APPS)('%s', (app) => {
const src = join(root, 'packages', app, 'src');
it('imports only names its own modules export', () => {
/** @type {string[]} */
const broken = [];
for (const file of sourcesUnder(src)) {
const source = readFileSync(file, 'utf8');
for (const { names, from } of importsIn(source)) {
const target = join(src, from.slice(2));
const exported = exportsOf(target);
const where = file.slice(root.length + 1);
if (!exported) {
broken.push(`${where}: ${from} is not a file`);
continue;
}
for (const name of names) {
if (!exported.has(name)) {
broken.push(`${where}: ${from} exports no ${name}`);
}
}
}
}
expect(broken).toEqual([]);
});
});
/**
* No module a browser app imports reaches a node-only one.
*
* A bundler drops what nothing calls, so a production build of a package
* whose graph touches `node:http` still comes out clean. A dev server does
* not: it evaluates the module, and the page dies on the import. Reading the
* graph rather than the build is what tells the two apart.
*/
describe('browser packages and node-only modules', () => {
/** Where each bare specifier the apps import resolves to. */
const ENTRYPOINTS = {
'@pdsjs/git/authors': 'packages/git/src/authors.js',
'@pdsjs/git/browser': 'packages/git/src/browser.js',
'@pdsjs/git/client': 'packages/git/src/client.js',
'@pdsjs/git/rules': 'packages/git/src/rules.js',
'@pdsjs/git-ci/spec': 'packages/git-ci/src/spec.js',
};
/**
* Every relative module one entrypoint reaches, and every `node:` import
* among them.
* @param {string} start - path from the repository root
* @returns {string[]} each reach, as `<file> imports <specifier>`
*/
const nodeImportsUnder = (start) => {
const seen = new Set();
/** @type {string[]} */
const found = [];
/** @param {string} file */
const walk = (file) => {
if (seen.has(file)) return;
seen.add(file);
/** @type {string} */
let source;
try {
source = readFileSync(file, 'utf8');
} catch {
return;
}
for (const [, specifier] of source.matchAll(
/^(?:import|export)[^;]*?from\s+'([^']+)'/gm,
)) {
if (specifier.startsWith('node:')) {
found.push(`${file.slice(root.length + 1)} imports ${specifier}`);
} else if (specifier.startsWith('.')) {
walk(resolve(dirname(file), specifier));
}
}
};
walk(resolve(root, start));
return found;
};
for (const [specifier, path] of Object.entries(ENTRYPOINTS)) {
it(`${specifier} reaches nothing that needs node`, () => {
expect(nodeImportsUnder(path)).toEqual([]);
});
}
});