Skip to content

Commit 1c6073e

Browse files
committed
fix(utils): drop the .js specifiers Turbopack cannot resolve
Every dev server on staging is currently returning 500 from any route whose module graph reaches the `@sim/utils` barrel: Module not found: Can't resolve './errors.js' > 1 | export { getErrorMessage, getPostgresErrorCode, toError } from './errors.js' Import trace: ./packages/utils/src/index.ts ./apps/sim/lib/embeddings/client.ts ./apps/sim/lib/knowledge/embeddings.ts ./apps/sim/app/api/knowledge/route.ts `packages/utils/src/index.ts` addresses its siblings as `./errors.js` while the files are `./errors.ts`. webpack rewrites that through `resolve.extensionAlias`; Turbopack has no equivalent (vercel/next.js#82945). `next build` is webpack and `next dev` is Turbopack, so this passes CI and breaks every local dev server — #6317 went green. Nothing required the extensions: the repo is on `moduleResolution: "bundler"`, and no other package barrel uses them. Two changes, either of which fixes the symptom; both are here because they fail differently: - `packages/utils/src/index.ts` drops all 12 `.js` specifiers. Fixes the barrel for every current and future consumer. - `apps/sim/lib/embeddings/client.ts` imports `chunkArray` from `@sim/utils/helpers` rather than the barrel. #6317 added the only bare-barrel `@sim/utils` import in the monorepo; the subpath form is the documented convention (CLAUDE.md, "Common Utilities") and resolves to one module instead of pulling twelve. `scripts/check-import-specifiers.ts` fails the build on either shape and runs in CI. Verified it goes red by restoring both halves of the bug. It scans only bundler-compiled source — vitest and standalone `bun run` scripts resolve `.js` -> `.ts` themselves, so flagging their specifiers would be noise. Verified against a real dev server with production env: `/api/knowledge`, `/api/tools/embeddings` and `/api/workflows/[id]/deploy` all go 500 -> 401, `/workspace` renders, and the Turbopack log is free of resolution errors. `tsc --noEmit` clean, `packages/utils` 147/147.
1 parent 2b35a3c commit 1c6073e

5 files changed

Lines changed: 201 additions & 13 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ jobs:
162162
- name: Trigger/block initialization cycle audit
163163
run: bun run check:trigger-block-cycle
164164

165+
# `next build` is webpack and `next dev` is Turbopack, so a specifier only
166+
# webpack can resolve builds green here and breaks every dev server.
167+
- name: Import specifier hygiene audit
168+
run: bun run check:import-specifiers
169+
165170
- name: SQL Date binding audit
166171
run: bun run check:sql-date-binding
167172

apps/sim/lib/embeddings/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createLogger } from '@sim/logger'
2-
import { chunkArray } from '@sim/utils'
2+
import { chunkArray } from '@sim/utils/helpers'
33
import { env, envNumber } from '@/lib/core/config/env'
44
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
55
import {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"check:tool-request-boundary": "bun run scripts/check-tool-request-boundary.ts",
3535
"check:tool-registry-boundary": "bun run scripts/check-tool-registry-boundary.ts",
3636
"check:trigger-block-cycle": "bun run scripts/check-trigger-block-cycle.ts",
37+
"check:import-specifiers": "bun run scripts/check-import-specifiers.ts",
3738
"check:sql-date-binding": "bun run scripts/check-sql-date-binding.ts",
3839
"check:zustand-v5": "bun run scripts/check-zustand-v5-selectors.ts",
3940
"check:react-query": "bun run scripts/check-react-query-patterns.ts --check",

packages/utils/src/index.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { getErrorMessage, getPostgresErrorCode, toError } from './errors.js'
1+
export { getErrorMessage, getPostgresErrorCode, toError } from './errors'
22
export {
33
formatAbsoluteDate,
44
formatCompactTimestamp,
@@ -9,18 +9,18 @@ export {
99
formatTime,
1010
formatTimeWithSeconds,
1111
getTimezoneAbbreviation,
12-
} from './formatting.js'
13-
export { chunkArray, noop, sleep } from './helpers.js'
14-
export { generateId, generateShortId, isValidUuid } from './id.js'
15-
export type { EmbedInfo } from './media-embed.js'
16-
export { getEmbedInfo } from './media-embed.js'
12+
} from './formatting'
13+
export { chunkArray, noop, sleep } from './helpers'
14+
export { generateId, generateShortId, isValidUuid } from './id'
15+
export type { EmbedInfo } from './media-embed'
16+
export { getEmbedInfo } from './media-embed'
1717
export {
1818
filterUndefined,
1919
isPlainRecord,
2020
isRecordLike,
2121
omit,
2222
sortObjectKeysDeep,
23-
} from './object.js'
23+
} from './object'
2424
export {
2525
generateRandomBytes,
2626
generateRandomHex,
@@ -29,14 +29,14 @@ export {
2929
randomFloat,
3030
randomInt,
3131
randomItem,
32-
} from './random.js'
33-
export type { BackoffOptions } from './retry.js'
34-
export { backoffWithJitter, parseRetryAfter } from './retry.js'
35-
export { normalizeSSODomain } from './sso-domain.js'
32+
} from './random'
33+
export type { BackoffOptions } from './retry'
34+
export { backoffWithJitter, parseRetryAfter } from './retry'
35+
export { normalizeSSODomain } from './sso-domain'
3636
export {
3737
isValidEmailSyntax,
3838
normalizeEmail,
3939
sanitizeForJsonb,
4040
sanitizeValueForJsonb,
4141
truncate,
42-
} from './string.js'
42+
} from './string'

scripts/check-import-specifiers.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Import-specifier hygiene for the two shapes that break Turbopack dev while passing
4+
* the webpack production build — a divergence CI cannot see, because `next dev` runs
5+
* Turbopack and `next build` runs webpack.
6+
*
7+
* 1. `.js` extension specifiers in TypeScript source.
8+
*
9+
* webpack maps `./errors.js` -> `./errors.ts` via `resolve.extensionAlias`.
10+
* Turbopack has no equivalent (vercel/next.js#82945), so the same import is a hard
11+
* `Module not found: Can't resolve './errors.js'`. `packages/utils/src/index.ts`
12+
* shipped 12 of these; every route whose graph reached the `@sim/utils` barrel 500'd
13+
* in dev while CI stayed green. The repo is on `moduleResolution: "bundler"`, so the
14+
* extensions were never required in the first place.
15+
*
16+
* 2. Bare `@sim/<pkg>` barrel imports when the package publishes a subpath export.
17+
*
18+
* `@sim/utils/helpers` resolves straight to one module. `@sim/utils` pulls the barrel
19+
* and everything it re-exports — which is how a single `chunkArray` import reached
20+
* the broken specifiers above. Subpath imports are already the documented convention
21+
* (CLAUDE.md, "Common Utilities"); this makes the convention enforceable.
22+
*
23+
* Usage:
24+
* bun run scripts/check-import-specifiers.ts
25+
* bun run scripts/check-import-specifiers.ts --verbose
26+
*/
27+
import { readdirSync, readFileSync } from 'node:fs'
28+
import { dirname, join, relative, resolve } from 'node:path'
29+
import { fileURLToPath } from 'node:url'
30+
31+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
32+
const ROOT = resolve(SCRIPT_DIR, '..')
33+
const SCAN_DIRS = ['apps/sim', 'apps/realtime', 'packages']
34+
const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', 'generated', '.turbo'])
35+
36+
/** Any specifier ending in `.js`/`.jsx`/`.mjs` — relative or aliased. */
37+
const JS_SPECIFIER_RE =
38+
/(?:^|\n)\s*(?:import|export)[\s\S]*?from\s*['"]((?:\.|@\/|@sim\/)[^'"]*\.(?:js|jsx|mjs))['"]/g
39+
/** `import ... from '@sim/pkg'` with no subpath. */
40+
const BARE_SIM_BARREL_RE = /(?:^|\n)\s*import[\s\S]*?from\s*['"](@sim\/[a-z0-9-]+)['"]/g
41+
42+
/**
43+
* Packages that must be imported by subpath. Opt-in rather than opt-out: `@sim/emcn`,
44+
* `@sim/desktop-bridge` and friends are barrel-first by design, and flagging them would
45+
* bury the one rule that matters. `@sim/utils` is subpath-only by documented convention
46+
* (CLAUDE.md, "Common Utilities") and is the package whose barrel took prod routes down.
47+
*/
48+
const SUBPATH_REQUIRED = new Set(['@sim/utils'])
49+
50+
/**
51+
* Only source a bundler will compile. Vitest and standalone `bun run` scripts resolve
52+
* `.js` -> `.ts` on their own, so flagging their specifiers is noise — and a check that
53+
* cries wolf is a check someone deletes.
54+
*/
55+
function isCompiledSource(full: string, name: string): boolean {
56+
if (!/\.(ts|tsx)$/.test(name) || name.endsWith('.d.ts')) return false
57+
if (/\.(test|spec)\.tsx?$/.test(name)) return false
58+
const rel = relative(ROOT, full)
59+
return !rel.startsWith('apps/sim/scripts/') && !rel.startsWith('apps/realtime/scripts/')
60+
}
61+
62+
function walk(dir: string, acc: string[] = []): string[] {
63+
let entries
64+
try {
65+
entries = readdirSync(dir, { withFileTypes: true })
66+
} catch {
67+
return acc
68+
}
69+
for (const e of entries) {
70+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue
71+
const full = join(dir, e.name)
72+
if (e.isDirectory()) walk(full, acc)
73+
else if (isCompiledSource(full, e.name)) acc.push(full)
74+
}
75+
return acc
76+
}
77+
78+
/** Subpath exports a package publishes, e.g. `@sim/utils` -> Set{'id','helpers',...}. */
79+
function subpathExports(pkg: string): Set<string> {
80+
const name = pkg.replace('@sim/', '')
81+
const out = new Set<string>()
82+
try {
83+
const json = JSON.parse(readFileSync(join(ROOT, 'packages', name, 'package.json'), 'utf8'))
84+
for (const key of Object.keys(json.exports ?? {})) {
85+
if (key.startsWith('./')) out.add(key.slice(2))
86+
}
87+
} catch {
88+
/* not a workspace package we can inspect */
89+
}
90+
return out
91+
}
92+
93+
interface Violation {
94+
file: string
95+
line: number
96+
specifier: string
97+
kind: 'js-extension' | 'bare-barrel'
98+
hint: string
99+
}
100+
101+
const files = SCAN_DIRS.flatMap((d) => walk(join(ROOT, d)))
102+
const violations: Violation[] = []
103+
const subpathCache = new Map<string, Set<string>>()
104+
105+
for (const file of files) {
106+
const src = readFileSync(file, 'utf8')
107+
const lineAt = (idx: number) => src.slice(0, idx).split('\n').length
108+
109+
JS_SPECIFIER_RE.lastIndex = 0
110+
let m = JS_SPECIFIER_RE.exec(src)
111+
while (m !== null) {
112+
violations.push({
113+
file: relative(ROOT, file),
114+
line: lineAt(m.index),
115+
specifier: m[1],
116+
kind: 'js-extension',
117+
hint: `drop the extension: '${m[1].replace(/\.(js|jsx|mjs)$/, '')}'`,
118+
})
119+
m = JS_SPECIFIER_RE.exec(src)
120+
}
121+
122+
BARE_SIM_BARREL_RE.lastIndex = 0
123+
m = BARE_SIM_BARREL_RE.exec(src)
124+
while (m !== null) {
125+
const pkg = m[1]
126+
if (SUBPATH_REQUIRED.has(pkg)) {
127+
if (!subpathCache.has(pkg)) subpathCache.set(pkg, subpathExports(pkg))
128+
const subs = subpathCache.get(pkg) as Set<string>
129+
if (subs.size > 0) {
130+
violations.push({
131+
file: relative(ROOT, file),
132+
line: lineAt(m.index),
133+
specifier: pkg,
134+
kind: 'bare-barrel',
135+
hint: `import from a subpath instead, e.g. '${pkg}/${[...subs].sort()[0]}'`,
136+
})
137+
}
138+
}
139+
m = BARE_SIM_BARREL_RE.exec(src)
140+
}
141+
}
142+
143+
const verbose = process.argv.includes('--verbose')
144+
145+
if (violations.length === 0) {
146+
console.log(`✓ check-import-specifiers: ${files.length} files, no risky specifiers`)
147+
process.exit(0)
148+
}
149+
150+
const byKind = {
151+
'js-extension': violations.filter((v) => v.kind === 'js-extension'),
152+
'bare-barrel': violations.filter((v) => v.kind === 'bare-barrel'),
153+
}
154+
155+
if (byKind['js-extension'].length) {
156+
console.error(`\n✗ ${byKind['js-extension'].length} '.js' specifier(s) in TypeScript source:\n`)
157+
for (const v of byKind['js-extension']) {
158+
console.error(` ${v.file}:${v.line} '${v.specifier}'`)
159+
console.error(` ${v.hint}`)
160+
}
161+
console.error(
162+
"\n webpack rewrites '.js' -> '.ts' via resolve.extensionAlias; Turbopack does not\n" +
163+
" (vercel/next.js#82945). So these build green in CI ('next build' = webpack) and\n" +
164+
" hard-fail every dev server ('next dev' = Turbopack) with Module not found.\n" +
165+
' This repo uses moduleResolution: "bundler" — the extension is never needed.\n'
166+
)
167+
}
168+
169+
if (byKind['bare-barrel'].length) {
170+
console.error(`\n✗ ${byKind['bare-barrel'].length} bare @sim/* barrel import(s):\n`)
171+
for (const v of byKind['bare-barrel']) {
172+
console.error(` ${v.file}:${v.line} '${v.specifier}'`)
173+
console.error(` ${v.hint}`)
174+
}
175+
console.error(
176+
'\n A barrel import pulls every module the barrel re-exports, so one helper drags in\n' +
177+
' the whole package — and any single bad specifier inside it takes the importer down.\n'
178+
)
179+
}
180+
181+
if (verbose) console.error(`\nscanned ${files.length} files`)
182+
process.exit(1)

0 commit comments

Comments
 (0)