Skip to content

Commit e490575

Browse files
authored
test: guard runtime-agnostic dist entries against node-builtin imports (#15)
1 parent f163b29 commit e490575

9 files changed

Lines changed: 161 additions & 46 deletions

File tree

packages/devframe/package.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,7 @@
8484
},
8585
"devDependencies": {
8686
"@modelcontextprotocol/sdk": "catalog:deps",
87-
"ansis": "catalog:deps",
8887
"get-port-please": "catalog:deps",
89-
"human-id": "catalog:inlined",
9088
"immer": "catalog:deps",
9189
"launch-editor": "catalog:deps",
9290
"mlly": "catalog:build",
@@ -101,13 +99,11 @@
10199
"whenexpr": "catalog:deps"
102100
},
103101
"inlinedDependencies": {
104-
"ansis": "4.3.0",
105102
"bundle-name": "4.1.0",
106103
"default-browser": "5.5.0",
107104
"default-browser-id": "5.0.1",
108105
"define-lazy-prop": "3.0.0",
109106
"get-port-please": "3.2.0",
110-
"human-id": "4.1.3",
111107
"immer": "11.1.8",
112108
"is-docker": "3.0.0",
113109
"is-in-ssh": "1.0.0",

packages/devframe/src/utils/colors.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import ansis from 'ansis'
2-
31
/**
42
* A colorizer — callable as a function (`colors.red('foo')`) or as a
53
* tagged template (``colors.red`foo ${bar}` ``).
@@ -26,15 +24,33 @@ export interface Colors {
2624
underline: ColorFn
2725
}
2826

27+
function makeColor(open: number, close: number): ColorFn {
28+
const o = `\x1B[${open}m`
29+
const c = `\x1B[${close}m`
30+
return ((arg: unknown, ...values: unknown[]): string => {
31+
if (Array.isArray(arg) && 'raw' in arg) {
32+
const strings = arg as unknown as TemplateStringsArray
33+
let out = ''
34+
for (let i = 0; i < strings.length; i++) {
35+
out += strings[i]
36+
if (i < values.length)
37+
out += String(values[i])
38+
}
39+
return `${o}${out}${c}`
40+
}
41+
return `${o}${String(arg)}${c}`
42+
}) as ColorFn
43+
}
44+
2945
export const colors: Colors = {
30-
blue: ansis.blue,
31-
cyan: ansis.cyan,
32-
gray: ansis.gray,
33-
green: ansis.green,
34-
red: ansis.red,
35-
yellow: ansis.yellow,
36-
bold: ansis.bold,
37-
dim: ansis.dim,
38-
reset: ansis.reset,
39-
underline: ansis.underline,
46+
blue: makeColor(34, 39),
47+
cyan: makeColor(36, 39),
48+
gray: makeColor(90, 39),
49+
green: makeColor(32, 39),
50+
red: makeColor(31, 39),
51+
yellow: makeColor(33, 39),
52+
bold: makeColor(1, 22),
53+
dim: makeColor(2, 22),
54+
reset: makeColor(0, 0),
55+
underline: makeColor(4, 24),
4056
}
Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1-
import { humanId as humanIdImpl } from 'human-id'
1+
// Vendored from `human-id@4.1.3` (MIT — Copyright (c) 2018 RienNeVaPlus).
2+
// The upstream is shipped as CommonJS, which tsdown wraps with a shim that
3+
// imports `node:module`. Inlining the relevant subset keeps this entry
4+
// runtime-agnostic (see `test/runtime-agnostic.test.ts`).
5+
const adjectives = ['afraid', 'all', 'beige', 'better', 'big', 'blue', 'bold', 'brave', 'breezy', 'bright', 'brown', 'bumpy', 'busy', 'calm', 'chatty', 'chilly', 'chubby', 'clean', 'clear', 'clever', 'cold', 'common', 'cool', 'cozy', 'crisp', 'cuddly', 'curly', 'curvy', 'cute', 'cyan', 'dark', 'deep', 'dirty', 'dry', 'dull', 'eager', 'early', 'easy', 'eight', 'eighty', 'eleven', 'empty', 'every', 'fair', 'famous', 'fancy', 'fast', 'few', 'fiery', 'fifty', 'fine', 'five', 'flat', 'floppy', 'fluffy', 'forty', 'four', 'frank', 'free', 'fresh', 'fruity', 'full', 'funky', 'funny', 'fuzzy', 'gentle', 'giant', 'gold', 'good', 'goofy', 'great', 'green', 'grumpy', 'happy', 'heavy', 'hip', 'honest', 'hot', 'huge', 'humble', 'hungry', 'icy', 'itchy', 'jolly', 'khaki', 'kind', 'large', 'late', 'lazy', 'legal', 'lemon', 'light', 'little', 'long', 'loose', 'loud', 'lovely', 'lucky', 'major', 'many', 'metal', 'mighty', 'modern', 'moody', 'neat', 'new', 'nice', 'nine', 'ninety', 'odd', 'old', 'olive', 'open', 'orange', 'perky', 'petite', 'pink', 'plain', 'plenty', 'polite', 'pretty', 'proud', 'public', 'puny', 'purple', 'quick', 'quiet', 'rare', 'ready', 'real', 'red', 'rich', 'ripe', 'salty', 'seven', 'shaggy', 'shaky', 'sharp', 'shiny', 'short', 'shy', 'silent', 'silly', 'silver', 'six', 'sixty', 'slick', 'slimy', 'slow', 'small', 'smart', 'smooth', 'social', 'soft', 'solid', 'some', 'sour', 'sparkly', 'spicy', 'spotty', 'stale', 'strict', 'strong', 'sunny', 'sweet', 'swift', 'tall', 'tame', 'tangy', 'tasty', 'ten', 'tender', 'thick', 'thin', 'thirty', 'three', 'tidy', 'tiny', 'tired', 'tough', 'tricky', 'true', 'twelve', 'twenty', 'two', 'upset', 'vast', 'violet', 'wacky', 'warm', 'wet', 'whole', 'wicked', 'wide', 'wild', 'wise', 'witty', 'yellow', 'young', 'yummy']
6+
7+
const nouns = ['actors', 'ads', 'adults', 'aliens', 'animals', 'ants', 'apes', 'apples', 'areas', 'baboons', 'badgers', 'bags', 'balloons', 'bananas', 'banks', 'bars', 'baths', 'bats', 'beans', 'bears', 'beds', 'beers', 'bees', 'berries', 'bikes', 'birds', 'boats', 'bobcats', 'books', 'bottles', 'boxes', 'breads', 'brooms', 'buckets', 'bugs', 'buses', 'bushes', 'buttons', 'camels', 'cameras', 'candies', 'candles', 'canyons', 'carpets', 'carrots', 'cars', 'cases', 'cats', 'chairs', 'chefs', 'chicken', 'cities', 'clocks', 'cloths', 'clouds', 'clowns', 'clubs', 'coats', 'cobras', 'coins', 'colts', 'comics', 'cooks', 'corners', 'cougars', 'cows', 'crabs', 'crews', 'cups', 'cycles', 'dancers', 'days', 'deer', 'deserts', 'dingos', 'dodos', 'dogs', 'dolls', 'donkeys', 'donuts', 'doodles', 'doors', 'dots', 'dragons', 'drinks', 'dryers', 'ducks', 'eagles', 'ears', 'eels', 'eggs', 'emus', 'ends', 'experts', 'eyes', 'facts', 'falcons', 'fans', 'feet', 'files', 'flies', 'flowers', 'forks', 'foxes', 'friends', 'frogs', 'games', 'garlics', 'geckos', 'geese', 'ghosts', 'gifts', 'glasses', 'goats', 'grapes', 'groups', 'guests', 'hairs', 'hands', 'hats', 'heads', 'hoops', 'hornets', 'horses', 'hotels', 'hounds', 'houses', 'humans', 'icons', 'ideas', 'impalas', 'insects', 'islands', 'items', 'jars', 'jeans', 'jobs', 'jokes', 'keys', 'kids', 'kings', 'kiwis', 'knives', 'lamps', 'lands', 'laws', 'lemons', 'lies', 'lights', 'lilies', 'lines', 'lions', 'lizards', 'llamas', 'loops', 'mails', 'mammals', 'mangos', 'maps', 'masks', 'meals', 'melons', 'memes', 'meteors', 'mice', 'mirrors', 'moles', 'moments', 'monkeys', 'months', 'moons', 'moose', 'mugs', 'nails', 'needles', 'news', 'nights', 'numbers', 'olives', 'onions', 'oranges', 'otters', 'owls', 'pandas', 'pans', 'pants', 'papayas', 'papers', 'parents', 'parks', 'parrots', 'parts', 'paths', 'paws', 'peaches', 'pears', 'peas', 'pens', 'pets', 'phones', 'pianos', 'pigs', 'pillows', 'places', 'planes', 'planets', 'plants', 'plums', 'poems', 'poets', 'points', 'pots', 'pugs', 'pumas', 'queens', 'rabbits', 'radios', 'rats', 'ravens', 'readers', 'regions', 'results', 'rice', 'rings', 'rivers', 'rockets', 'rocks', 'rooms', 'roses', 'rules', 'sails', 'schools', 'seals', 'seas', 'sheep', 'shirts', 'shoes', 'showers', 'shrimps', 'sides', 'signs', 'singers', 'sites', 'sloths', 'snails', 'snakes', 'socks', 'spiders', 'spies', 'spoons', 'squids', 'stamps', 'stars', 'states', 'steaks', 'streets', 'suits', 'suns', 'swans', 'symbols', 'tables', 'taxes', 'taxis', 'teams', 'teeth', 'terms', 'things', 'ties', 'tigers', 'times', 'tips', 'tires', 'toes', 'tools', 'towns', 'toys', 'trains', 'trams', 'trees', 'turkeys', 'turtles', 'vans', 'views', 'walls', 'wasps', 'waves', 'ways', 'webs', 'weeks', 'windows', 'wings', 'wolves', 'wombats', 'words', 'worlds', 'worms', 'yaks', 'years', 'zebras', 'zoos']
8+
9+
const verbs = ['accept', 'act', 'add', 'admire', 'agree', 'allow', 'appear', 'argue', 'arrive', 'ask', 'attack', 'attend', 'bake', 'bathe', 'battle', 'beam', 'beg', 'begin', 'behave', 'bet', 'boil', 'bow', 'brake', 'brush', 'build', 'burn', 'buy', 'call', 'camp', 'care', 'carry', 'change', 'cheat', 'check', 'cheer', 'chew', 'clap', 'clean', 'cough', 'count', 'cover', 'crash', 'create', 'cross', 'cry', 'cut', 'dance', 'decide', 'deny', 'design', 'dig', 'divide', 'do', 'double', 'doubt', 'draw', 'dream', 'dress', 'drive', 'drop', 'drum', 'eat', 'end', 'enjoy', 'enter', 'exist', 'fail', 'fall', 'feel', 'fetch', 'film', 'find', 'fix', 'flash', 'float', 'flow', 'fly', 'fold', 'follow', 'fry', 'give', 'glow', 'go', 'grab', 'greet', 'grin', 'grow', 'guess', 'hammer', 'hang', 'happen', 'heal', 'hear', 'help', 'hide', 'hope', 'hug', 'hunt', 'invent', 'invite', 'itch', 'jam', 'jog', 'join', 'joke', 'judge', 'juggle', 'jump', 'kick', 'kiss', 'kneel', 'knock', 'know', 'laugh', 'lay', 'lead', 'learn', 'leave', 'lick', 'lie', 'like', 'listen', 'live', 'look', 'lose', 'love', 'make', 'march', 'marry', 'mate', 'matter', 'melt', 'mix', 'move', 'nail', 'notice', 'obey', 'occur', 'open', 'own', 'pay', 'peel', 'pick', 'play', 'poke', 'post', 'press', 'prove', 'pull', 'pump', 'punch', 'push', 'raise', 'read', 'refuse', 'relate', 'relax', 'remain', 'repair', 'repeat', 'reply', 'report', 'rescue', 'rest', 'retire', 'return', 'rhyme', 'ring', 'roll', 'rule', 'run', 'rush', 'say', 'scream', 'search', 'see', 'sell', 'send', 'serve', 'shake', 'share', 'shave', 'shine', 'shop', 'shout', 'show', 'sin', 'sing', 'sink', 'sip', 'sit', 'sleep', 'slide', 'smash', 'smell', 'smile', 'smoke', 'sneeze', 'sniff', 'sort', 'speak', 'spend', 'stand', 'stare', 'start', 'stay', 'stick', 'stop', 'strive', 'study', 'swim', 'switch', 'take', 'talk', 'tan', 'tap', 'taste', 'teach', 'tease', 'tell', 'thank', 'think', 'throw', 'tickle', 'tie', 'trade', 'train', 'travel', 'try', 'turn', 'type', 'unite', 'vanish', 'visit', 'wait', 'walk', 'warn', 'wash', 'watch', 'wave', 'wear', 'win', 'wink', 'wish', 'wonder', 'work', 'worry', 'write', 'yawn', 'yell']
10+
11+
function pick(arr: readonly string[]): string {
12+
return arr[(Math.random() * arr.length) | 0]
13+
}
214

315
/**
416
* Generate a human-readable, lowercase, dash-separated random ID
5-
* (e.g. `bright-orange-tiger`).
17+
* (e.g. `bright-orange-tigers-jump`).
618
*/
719
export function humanId(): string {
8-
return humanIdImpl({ separator: '-', capitalize: false })
20+
return `${pick(adjectives)}-${pick(nouns)}-${pick(verbs)}`
921
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { existsSync, readFileSync } from 'node:fs'
2+
import { builtinModules } from 'node:module'
3+
import { dirname, relative, resolve } from 'node:path'
4+
import { fileURLToPath } from 'node:url'
5+
import { describe, expect, it } from 'vitest'
6+
7+
// Entries that must run in any JS runtime (browser, edge, Node).
8+
// Keep in sync with `packages/devframe/package.json` exports — explicit list
9+
// so additions are a conscious choice.
10+
const AGNOSTIC_ENTRIES = [
11+
'client/index.mjs',
12+
'utils/colors.mjs',
13+
'utils/events.mjs',
14+
'utils/hash.mjs',
15+
'utils/human-id.mjs',
16+
'utils/nanoid.mjs',
17+
'utils/promise.mjs',
18+
'utils/shared-state.mjs',
19+
'utils/streaming-channel.mjs',
20+
'utils/structured-clone.mjs',
21+
'utils/when.mjs',
22+
] as const
23+
24+
const distRoot = fileURLToPath(new URL('../dist/', import.meta.url))
25+
26+
const nodeBuiltins = new Set([
27+
...builtinModules,
28+
...builtinModules.map(m => `node:${m}`),
29+
])
30+
31+
const IMPORT_FROM_RE = /(?:import|export)[^'"`;]*?from\s*['"]([^'"]+)['"]/g
32+
const SIDE_EFFECT_IMPORT_RE = /(?:^|[\s;{])import\s*['"]([^'"]+)['"]/g
33+
const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g
34+
const REQUIRE_RE = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g
35+
36+
interface Offender {
37+
importer: string
38+
specifier: string
39+
statement: string
40+
}
41+
42+
function collectImports(src: string): string[] {
43+
const ids: string[] = []
44+
for (const re of [IMPORT_FROM_RE, SIDE_EFFECT_IMPORT_RE, DYNAMIC_IMPORT_RE, REQUIRE_RE]) {
45+
for (const match of src.matchAll(re))
46+
ids.push(match[1])
47+
}
48+
return ids
49+
}
50+
51+
function statementFor(src: string, specifier: string): string {
52+
for (const re of [IMPORT_FROM_RE, SIDE_EFFECT_IMPORT_RE, DYNAMIC_IMPORT_RE, REQUIRE_RE]) {
53+
for (const match of src.matchAll(re)) {
54+
if (match[1] === specifier)
55+
return match[0].trim()
56+
}
57+
}
58+
return ''
59+
}
60+
61+
function scanTransitiveBuiltins(entryAbs: string): Offender[] {
62+
const visited = new Set<string>()
63+
const offenders: Offender[] = []
64+
const queue: string[] = [entryAbs]
65+
66+
while (queue.length) {
67+
const file = queue.shift()!
68+
if (visited.has(file))
69+
continue
70+
visited.add(file)
71+
72+
if (!existsSync(file))
73+
continue
74+
const src = readFileSync(file, 'utf8')
75+
76+
for (const id of collectImports(src)) {
77+
if (nodeBuiltins.has(id)) {
78+
offenders.push({
79+
importer: relative(distRoot, file),
80+
specifier: id,
81+
statement: statementFor(src, id),
82+
})
83+
continue
84+
}
85+
// Relative import — follow it.
86+
if (id.startsWith('./') || id.startsWith('../')) {
87+
const resolved = resolve(dirname(file), id)
88+
queue.push(resolved)
89+
}
90+
// Bare specifiers other than node builtins are treated as resolved
91+
// (they'd be installed-as-dep; not part of this check).
92+
}
93+
}
94+
95+
return offenders
96+
}
97+
98+
describe('runtime-agnostic dist entries', () => {
99+
for (const entry of AGNOSTIC_ENTRIES) {
100+
it(entry, () => {
101+
const filePath = resolve(distRoot, entry)
102+
expect(existsSync(filePath), `Missing ${entry} — run \`pnpm build\` first`).toBe(true)
103+
104+
const offenders = scanTransitiveBuiltins(filePath)
105+
const formatted = offenders.map(o => ` ${o.importer}: ${o.statement}`)
106+
107+
expect(
108+
formatted,
109+
`${entry} (transitively) must not import node builtins`,
110+
).toEqual([])
111+
})
112+
}
113+
})

packages/devframe/tsdown.config.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,11 @@ export default defineConfig({
7070
],
7171
onlyBundle: [
7272
'acorn',
73-
'ansis',
7473
'bundle-name',
7574
'default-browser',
7675
'default-browser-id',
7776
'define-lazy-prop',
7877
'get-port-please',
79-
'human-id',
8078
'immer',
8179
'is-docker',
8280
'is-in-ssh',

pnpm-lock.yaml

Lines changed: 1 addition & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ catalogs:
3333
deps:
3434
'@modelcontextprotocol/sdk': ^1.29.0
3535
'@valibot/to-json-schema': ^1.7.0
36-
ansis: ^4.3.0
3736
birpc: ^4.0.0
3837
cac: ^7.0.0
3938
get-port-please: ^3.2.0
@@ -69,7 +68,6 @@ catalogs:
6968
preact: ^10.29.1
7069
inlined:
7170
'@antfu/utils': ^9.3.0
72-
human-id: ^4.1.3
7371
ua-parser-modern: ^0.1.1
7472
testing:
7573
'@playwright/test': ^1.50.0
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Generated by tsnapi — public API snapshot of `devframe/utils/colors`
33
*/
4-
// #region Other
5-
export { colors }
4+
// #region Variables
5+
export var colors /* const */
66
// #endregion
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Generated by tsnapi — public API snapshot of `devframe/utils/human-id`
33
*/
4-
// #region Other
5-
export { humanId }
4+
// #region Functions
5+
export function humanId() {}
66
// #endregion

0 commit comments

Comments
 (0)