Files
simstudioai--sim/scripts/check-client-boundary-imports.ts
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

307 lines
12 KiB
TypeScript

#!/usr/bin/env bun
/**
* Guards the two Next.js boundary directives: `'use client'` imports and any
* `'use server'` module.
*
* ## `'use server'`
*
* A single `'use server'` module anywhere in the graph flips Next's
* `hasServerActions()` to true, which removes the early 404 for Server Action
* requests. Next classifies a request as a Server Action from HEADERS ALONE —
* no body inspection, no auth — so once actions exist, ANY unauthenticated
* `POST` with `Content-Type: multipart/form-data` to ANY App Router path takes
* the non-fetch action path, which bare-`throw`s and surfaces as an HTTP 500.
* A trickle of such requests is enough to trip the ALB 5xx alarm. Every export
* of a `'use server'` module is also a remotely invocable, unauthenticated
* endpoint.
*
* Sim has no Server Actions — server-only modules use the `.server.ts` suffix
* and are called directly from route handlers. If you genuinely need a Server
* Action, remove this check deliberately and wrap every export in auth.
*
* ## `'use client'`
*
* Next.js rewrites EVERY export of a `'use client'` module into a client
* reference in the server bundle. Server-evaluated code can only *render* such
* an export as a component or pass it as a prop — *calling* one throws at
* runtime ("Attempted to call X from the server but X is on the client"). The
* crash for an object export looks like `tableKeys.list is not a function`.
* `next build` does NOT catch this; only SSR/runtime does.
*
* This script flags any **value** import (not `import type`) that resolves to a
* `'use client'` module from a server-evaluated, non-JSX surface — the places
* that never legitimately render a client component and so only ever import a
* client module to (illegally) call its values:
*
* - `apps/sim/app/** /prefetch*.ts` (RSC server prefetch)
* - `apps/sim/app/api/** /route.ts(x)` (route handlers)
* - `apps/sim/triggers/**` (trigger.dev tasks/pollers/webhooks)
* - `apps/sim/blocks/**` (block definitions — evaluated server-side)
*
* Fix: move the imported query-key factory / standalone fetcher / mapper /
* constant into a non-`'use client'` module (e.g. `hooks/queries/utils/*-keys.ts`
* or `hooks/queries/utils/fetch-*.ts`) and import it from there. See the rule in
* `.claude/rules/sim-queries.md`.
*
* Escape hatch: `// client-boundary-allow: <reason>` on the line directly above
* the import (reason required). Use only for a genuinely browser-only code path.
*
* Usage:
* bun run scripts/check-client-boundary-imports.ts # report
* bun run scripts/check-client-boundary-imports.ts --check # CI gate (fail on any)
*/
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dir, '..')
const APP_DIR = path.join(ROOT, 'apps/sim')
/** Everything Next compiles into the app's module graph. */
const DIRECTIVE_SCAN_DIRS = [path.join(ROOT, 'apps'), path.join(ROOT, 'packages')]
/** Server-evaluated, non-JSX surfaces. A file matches if its path passes one. */
function isServerSurface(rel: string): boolean {
if (/(^|\/)prefetch[^/]*\.ts$/.test(rel)) return true
if (/^app\/api\/.+\/route\.tsx?$/.test(rel)) return true
if (/^triggers\//.test(rel)) return true
if (/^blocks\//.test(rel)) return true
return false
}
const SOURCE_EXTENSIONS = ['.ts', '.tsx']
const ALLOW_DIRECTIVE = 'client-boundary-allow'
async function listFiles(dir: string): Promise<string[]> {
const out: string[] = []
let entries: Awaited<ReturnType<typeof readdir>>
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
return out
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '.next') continue
out.push(...(await listFiles(full)))
} else if (SOURCE_EXTENSIONS.includes(path.extname(entry.name))) {
out.push(full)
}
}
return out
}
/**
* Drops a trailing `//` or `/* *\/` comment from an already-trimmed line. A
* directive keeps its meaning when a note follows it on the same line, so the
* comment has to come off before the directive is matched.
*/
function stripTrailingComment(line: string): string {
return line.replace(/(?:\/\/.*|\/\*.*?\*\/)\s*$/, '').trim()
}
/** A lone directive statement, e.g. `'use server'` or `"use client";`. */
const DIRECTIVE_STATEMENT = /^(['"])(use [a-z-]+)\1\s*;?$/
/**
* Returns the module's leading directive prologue string, if any. A directive
* must be the first statement; comments and blank lines may precede it.
*/
function leadingDirective(content: string): string | null {
for (const raw of content.split('\n')) {
const line = raw.trim()
if (line === '' || line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) {
continue
}
const match = DIRECTIVE_STATEMENT.exec(stripTrailingComment(line))
return match ? match[2] : null
}
return null
}
const useClientCache = new Map<string, boolean>()
async function isUseClientModule(absFile: string): Promise<boolean> {
const cached = useClientCache.get(absFile)
if (cached !== undefined) return cached
let isClient = false
try {
isClient = leadingDirective(await readFile(absFile, 'utf8')) === 'use client'
} catch {}
useClientCache.set(absFile, isClient)
return isClient
}
/**
* Locations declaring `'use server'` — module prologue or inline in a function
* body. Either form registers Server Actions app-wide.
*/
async function findUseServerDirectives(): Promise<string[]> {
const found: string[] = []
for (const dir of DIRECTIVE_SCAN_DIRS) {
for (const absFile of await listFiles(dir)) {
const lines = (await readFile(absFile, 'utf8')).split('\n')
for (let i = 0; i < lines.length; i++) {
const match = DIRECTIVE_STATEMENT.exec(stripTrailingComment(lines[i].trim()))
if (match?.[2] === 'use server') {
found.push(`${path.relative(ROOT, absFile)}:${i + 1}`)
}
}
}
}
return found
}
/** Resolve an import specifier to an absolute source file, or null if external/unresolved. */
async function resolveSpecifier(spec: string, fromFile: string): Promise<string | null> {
let base: string
if (spec.startsWith('@/')) {
base = path.join(APP_DIR, spec.slice(2))
} else if (spec.startsWith('./') || spec.startsWith('../')) {
base = path.resolve(path.dirname(fromFile), spec)
} else {
return null // external package
}
const candidates = [
base,
...SOURCE_EXTENSIONS.map((ext) => base + ext),
...SOURCE_EXTENSIONS.map((ext) => path.join(base, `index${ext}`)),
]
for (const candidate of candidates) {
if (!SOURCE_EXTENSIONS.includes(path.extname(candidate))) continue
try {
await readFile(candidate, 'utf8')
return candidate
} catch {}
}
return null
}
interface ImportInfo {
line: number
specifier: string
clause: string
}
/** Parse `import ... from '...'` statements, skipping side-effect-only imports. */
function parseImports(content: string): ImportInfo[] {
const lines = content.split('\n')
const imports: ImportInfo[] = []
const re = /^\s*import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/
for (let i = 0; i < lines.length; i++) {
if (!/^\s*import\b/.test(lines[i]) || !lines[i].includes('import')) continue
// Join up to 12 following lines to capture multi-line import clauses.
const block = lines.slice(i, i + 12).join('\n')
const match = re.exec(block)
if (!match) continue
imports.push({ line: i + 1, clause: match[1], specifier: match[2] })
}
return imports
}
/** True when the import brings in at least one runtime VALUE (not purely types). */
function importsAValue(clause: string): boolean {
const trimmed = clause.trim()
if (trimmed.startsWith('type ')) return false // `import type { ... }` / `import type X`
const braceStart = trimmed.indexOf('{')
// A default or namespace binding outside the braces is always a value.
const beforeBrace = braceStart === -1 ? trimmed : trimmed.slice(0, braceStart)
if (beforeBrace.replace(/[,\s]/g, '').length > 0) return true
if (braceStart === -1) return true
const inner = trimmed.slice(braceStart + 1, trimmed.lastIndexOf('}'))
// A named import is a value unless every member is `type`-prefixed.
return inner
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.some((member) => !member.startsWith('type '))
}
function hasAllowDirective(content: string, importLine: number): boolean {
const lines = content.split('\n')
for (let i = importLine - 2; i >= 0 && i >= importLine - 5; i--) {
const line = lines[i]?.trim() ?? ''
if (line === '' || line.startsWith('//') || line.startsWith('*') || line.startsWith('/*')) {
if (line.includes(ALLOW_DIRECTIVE)) {
const reason =
line
.split(ALLOW_DIRECTIVE)[1]
?.replace(/^[:\s]+/, '')
.trim() ?? ''
return reason.length > 0
}
continue
}
break
}
return false
}
interface Violation {
file: string
line: number
specifier: string
}
async function main() {
const checkMode = process.argv.includes('--check')
let failed = false
const serverDirectives = await findUseServerDirectives()
if (serverDirectives.length === 0) {
console.log("✓ No 'use server' directives (Server Actions stay disabled).")
} else {
failed = true
console.error(
`\n✗ ${serverDirectives.length} 'use server' directive(s) found.\n` +
` These enable Next's Server Action handling app-wide, which turns any unauthenticated\n` +
` multipart/form-data POST to any App Router path into a 500, and exposes every export\n` +
` as an unauthenticated endpoint. Use a '.server.ts' module called from a route handler.\n`
)
for (const location of serverDirectives) console.error(` ${location}`)
}
const allFiles = await listFiles(APP_DIR)
const violations: Violation[] = []
for (const absFile of allFiles) {
const rel = path.relative(APP_DIR, absFile)
if (!isServerSurface(rel)) continue
// A server file that is itself `'use client'` is a client component — out of scope.
if (await isUseClientModule(absFile)) continue
const content = await readFile(absFile, 'utf8')
for (const imp of parseImports(content)) {
if (!importsAValue(imp.clause)) continue
const resolved = await resolveSpecifier(imp.specifier, absFile)
if (!resolved) continue
if (!(await isUseClientModule(resolved))) continue
if (hasAllowDirective(content, imp.line)) continue
violations.push({ file: rel, line: imp.line, specifier: imp.specifier })
}
}
if (violations.length === 0) {
console.log(
"✓ Client-boundary import check passed (no server file imports a value from a 'use client' module)."
)
} else {
failed = true
console.error(
`\n✗ ${violations.length} server file(s) import a runtime value from a 'use client' module.\n` +
` On the server these resolve to client-reference stubs and throw when called (e.g. 'X.list is not a function').\n` +
` Move the imported factory/fetcher/constant into a non-'use client' module (hooks/queries/utils/*-keys.ts or fetch-*.ts).\n` +
` See .claude/rules/sim-queries.md. Escape hatch: // ${ALLOW_DIRECTIVE}: <reason> above the import.\n`
)
for (const v of violations) {
console.error(` ${v.file}:${v.line} imports from '${v.specifier}'`)
}
}
if (failed && checkMode) process.exit(1)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})