mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
**Stack 1/6** of the TanStack Start migration (#46424), split into reviewable, independently-mergeable PRs. > [!IMPORTANT] > **Next stays the default and only active framework after this PR.** This wires up the Vite/TanStack-Start build pipeline behind the `STUDIO_FRAMEWORK` flag, but there are no TanStack routes yet — so the TanStack build isn't functional or tested until later PRs in the stack. Nothing about the Next build, dev, or deploy changes behaviourally here. ## What's in this PR - **Dispatch:** `dev`/`build`/`start` now go through `scripts/dispatch.js`, which runs the Next variant unless `STUDIO_FRAMEWORK=tanstack`. The original commands are preserved as `dev:next`/`build:next`/`start:next`. - **Build pipeline:** `vite.config.ts`, `serve.js`, `smoke-server.mjs`, vite/tanstack deps, `turbo.jsonc`. - **`tsconfig.json`:** `jsx: react-jsx`, `moduleResolution: Bundler`, `target: ES2022`. Because `include` is `**/*.ts(x)`, this re-typechecks the whole app, so the companion adaptations below land with it. - **Shared adaptations (companions to the tsconfig change):** `BufferSource` casts, `packages/ui` unused-`React` import removals, etc. - **Routing/middleware plumbing:** `next.config.ts` + `redirects.shared.ts` (redirect rules now shared with `vercel.ts`), `proxy.ts`/`start.ts` middleware + `hosted-api-allowlist.ts`. ## Verification Run locally off `master`: frozen install ✓, `studio` typecheck ✓, **Next build ✓** (compiles + generates all routes), lint ratchet ✓ ("some rules improved"), prettier ✓. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a hosted API endpoint allowlist to return 404 for non-supported `/api/*` routes. * Introduced a TanStack route-migration checklist and expanded TanStack Start routing support. * **Improvements** * Enhanced deployment refresh/detection by tightening cookie handling for “latest deployment” updates. * Centralized redirect/maintenance-mode rules for consistent platform vs self-hosted behavior. * Improved production serving with a dedicated static + proxy server and a post-build smoke test. * **Dependencies** * Updated TanStack-related packages and React Table/query tooling versions. * **Documentation / Chores** * Updated formatting and tooling config; added shared build environment parsing utilities. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
426 lines
13 KiB
TypeScript
426 lines
13 KiB
TypeScript
/* eslint-disable turbo/no-undeclared-env-vars */
|
|
/**
|
|
* Ratchet ESLint violations for selected rules.
|
|
*
|
|
* Examples:
|
|
* # Initialize baselines for two rules
|
|
* tsx scripts/ratchet-eslint-rules.ts --init \
|
|
* --rule react-hooks/exhaustive-deps --rule no-console
|
|
*
|
|
* # Compare current counts vs baselines
|
|
* tsx scripts/ratchet-eslint-rules.ts \
|
|
* --rule react-hooks/exhaustive-deps --rule no-console
|
|
*
|
|
# Decrease baselines when improvements occur
|
|
* tsx scripts/ratchet-eslint-rules.ts \
|
|
* --rule react-hooks/exhaustive-deps --rule no-console \
|
|
* --decrease-baselines
|
|
*
|
|
* Flags:
|
|
* --metadata <path> Path to baseline file (default .github/eslint-rule-baselines.json)
|
|
* --init Write current counts for the provided --rule(s) into metadata and exit 0
|
|
* --eslint "<cmd>" ESLint command to run (default "npx eslint"). Do not pass untrusted input.
|
|
* --eslint-args "<...>" Extra args/paths for ESLint (e.g., "."). Do not pass untrusted input.
|
|
* --rule <id>[,<id>...] Rule id(s). Repeat flag or comma-separate. REQUIRED.
|
|
* --decrease-baselines When improvements occur, lower stored baselines to match the new counts.
|
|
*
|
|
* Notes:
|
|
* - Counts occurrences regardless of severity (warn/error).
|
|
* - Fails if any selected rule has currentCount > baselineCount.
|
|
*/
|
|
|
|
import { spawnSync } from 'node:child_process'
|
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
interface Args {
|
|
metadata: string
|
|
init: boolean
|
|
eslint: string
|
|
eslintArgs: string
|
|
decreaseBaselines: boolean
|
|
rules: string[]
|
|
}
|
|
|
|
interface ESLintMessage {
|
|
ruleId?: string | null
|
|
}
|
|
|
|
interface ESLintResult {
|
|
filePath?: string
|
|
messages?: ESLintMessage[]
|
|
}
|
|
|
|
interface ESLintExecutionResult {
|
|
results: ESLintResult[]
|
|
stderr: string
|
|
}
|
|
|
|
interface BaselineData {
|
|
rules: Record<string, number>
|
|
ruleFiles?: Record<string, Record<string, number>>
|
|
}
|
|
|
|
interface RuleSnapshot {
|
|
total: number
|
|
files: Record<string, number>
|
|
}
|
|
|
|
function parseArgs(argv: string[]): Args {
|
|
const args: Args = {
|
|
metadata: '.github/eslint-rule-baselines.json',
|
|
init: false,
|
|
eslint: 'npx eslint',
|
|
eslintArgs: '',
|
|
decreaseBaselines: false,
|
|
rules: [],
|
|
}
|
|
|
|
for (let i = 2; i < argv.length; i += 1) {
|
|
const a = argv[i]
|
|
if (a === '--init') {
|
|
args.init = true
|
|
} else if (a === '--metadata') {
|
|
args.metadata = argv[++i]
|
|
} else if (a === '--eslint') {
|
|
args.eslint = argv[++i]
|
|
} else if (a === '--eslint-args') {
|
|
args.eslintArgs = argv[++i]
|
|
} else if (a === '--rule') {
|
|
const val = (argv[++i] ?? '').trim()
|
|
if (val) {
|
|
args.rules.push(
|
|
...val
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
)
|
|
}
|
|
} else if (a === '--decrease-baselines') {
|
|
args.decreaseBaselines = true
|
|
} else {
|
|
console.warn(`Unknown argument: ${a}`)
|
|
}
|
|
}
|
|
|
|
if (args.rules.length === 0) {
|
|
console.error('Error: You must provide at least one --rule <rule-id>.')
|
|
console.error('Example: --rule exhaustive-deps --rule no-console')
|
|
process.exit(2)
|
|
}
|
|
|
|
const dedupedRules = new Set(args.rules)
|
|
args.rules = Array.from(dedupedRules)
|
|
|
|
return args
|
|
}
|
|
|
|
/**
|
|
* SECURITY:
|
|
* Directly spawns a command from its arguments. Should not be called with
|
|
* untrusted input.
|
|
*/
|
|
function dangerouslyRunEsLint(eslintCmd: string, eslintArgs: string): ESLintExecutionResult {
|
|
const fullCmd = `${eslintCmd} ${eslintArgs || ''} --format json`.trim()
|
|
const proc = spawnSync(fullCmd, {
|
|
shell: true,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: process.env,
|
|
maxBuffer: 128 * 1024 * 1024, // allow large ESLint JSON payloads (the studio repo regularly emits ~35MB+)
|
|
})
|
|
|
|
const stdout = typeof proc.stdout === 'string' ? proc.stdout : ''
|
|
const stderr = typeof proc.stderr === 'string' ? proc.stderr : ''
|
|
|
|
if (!stdout.trim()) {
|
|
console.error('ESLint did not produce JSON output. stderr:\n', stderr)
|
|
process.exit(2)
|
|
}
|
|
|
|
let results: ESLintResult[]
|
|
try {
|
|
results = JSON.parse(stdout) as ESLintResult[]
|
|
} catch (e) {
|
|
console.error('Failed to parse ESLint JSON output:', e)
|
|
console.error('Raw output (truncated to 4k):\n', stdout.slice(0, 4096))
|
|
process.exit(2)
|
|
}
|
|
|
|
return { results, stderr }
|
|
}
|
|
|
|
function normalizeFilePath(filePath?: string | null): string | null {
|
|
if (!filePath) return null
|
|
const rel = path.relative(process.cwd(), filePath)
|
|
const normalized = rel || path.basename(filePath)
|
|
return normalized.split(path.sep).join('/')
|
|
}
|
|
|
|
function collectRuleSnapshots(
|
|
results: ESLintResult[],
|
|
ruleIds: string[]
|
|
): Record<string, RuleSnapshot> {
|
|
const checkedIds = new Set(ruleIds)
|
|
const snapshots: Record<string, RuleSnapshot> = {}
|
|
|
|
for (const id of ruleIds) {
|
|
snapshots[id] = { total: 0, files: {} }
|
|
}
|
|
|
|
for (const file of results) {
|
|
if (!file || !Array.isArray(file.messages)) continue
|
|
const normalizedPath = normalizeFilePath(file.filePath)
|
|
for (const msg of file.messages) {
|
|
const id = msg?.ruleId ?? ''
|
|
if (id && checkedIds.has(id)) {
|
|
const snapshot = snapshots[id] ?? { total: 0, files: {} }
|
|
snapshot.total += 1
|
|
if (normalizedPath) {
|
|
snapshot.files[normalizedPath] = (snapshot.files[normalizedPath] ?? 0) + 1
|
|
}
|
|
snapshots[id] = snapshot
|
|
}
|
|
}
|
|
}
|
|
|
|
return snapshots
|
|
}
|
|
|
|
function readBaselines(fp: string): BaselineData {
|
|
if (!existsSync(fp)) return { rules: {}, ruleFiles: {} }
|
|
try {
|
|
const data = JSON.parse(readFileSync(fp, 'utf8')) as Partial<BaselineData>
|
|
if (data && typeof data === 'object' && data.rules && typeof data.rules === 'object') {
|
|
return { rules: data.rules, ruleFiles: data.ruleFiles ?? {} }
|
|
}
|
|
} catch {
|
|
// ignore invalid metadata files and fall back to blank baselines
|
|
}
|
|
return { rules: {}, ruleFiles: {} }
|
|
}
|
|
|
|
function writeBaselines(fp: string, updates: Record<string, RuleSnapshot>, merge = true): void {
|
|
const dir = path.dirname(fp)
|
|
mkdirSync(dir, { recursive: true })
|
|
|
|
let current: BaselineData = { rules: {}, ruleFiles: {} }
|
|
if (merge && existsSync(fp)) {
|
|
current = readBaselines(fp)
|
|
}
|
|
|
|
const nextRules = merge ? { ...current.rules } : {}
|
|
const nextRuleFiles = merge ? { ...(current.ruleFiles ?? {}) } : {}
|
|
|
|
for (const [rule, snapshot] of Object.entries(updates)) {
|
|
nextRules[rule] = snapshot.total
|
|
nextRuleFiles[rule] = snapshot.files
|
|
}
|
|
|
|
const next: BaselineData = { rules: nextRules, ruleFiles: nextRuleFiles }
|
|
writeFileSync(fp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
|
}
|
|
|
|
function writeSummary(markdown: string): void {
|
|
const summaryFile = process.env.GITHUB_STEP_SUMMARY
|
|
if (summaryFile) {
|
|
try {
|
|
appendFileSync(summaryFile, `${markdown}\n`, 'utf8')
|
|
} catch {
|
|
// ignore summary write errors because they shouldn't block the script
|
|
}
|
|
}
|
|
}
|
|
|
|
export function runRatchet(argv: string[], runEslint = dangerouslyRunEsLint): number {
|
|
const args = parseArgs(argv)
|
|
|
|
// SECURITY:
|
|
// Offloaded to user. Must document that they should not pass untrusted input
|
|
// via --eslint or --eslint-args.
|
|
const { results, stderr } = runEslint(args.eslint, args.eslintArgs)
|
|
|
|
// Filter out test files.
|
|
const filteredResults = results.filter((result) => !result.filePath?.includes('.test.'))
|
|
|
|
const currentSnapshots = collectRuleSnapshots(filteredResults, args.rules)
|
|
const currentCounts: Record<string, number> = {}
|
|
for (const rule of args.rules) {
|
|
currentCounts[rule] = currentSnapshots[rule]?.total ?? 0
|
|
}
|
|
|
|
if (args.init) {
|
|
writeBaselines(args.metadata, currentSnapshots, true)
|
|
|
|
const rows = Object.entries(currentCounts)
|
|
.map(([rule, count]) => `| \`${rule}\` | **${count}** |`)
|
|
.join('\n')
|
|
|
|
writeSummary(
|
|
[
|
|
`### ESLint rule baselines initialized`,
|
|
`Metadata: \`${args.metadata}\``,
|
|
``,
|
|
`| Rule | Baseline |`,
|
|
`| --- | ---: |`,
|
|
rows,
|
|
``,
|
|
].join('\n')
|
|
)
|
|
|
|
console.log(
|
|
`Initialized/updated baselines for: ${args.rules.join(', ')} (saved to ${args.metadata}).`
|
|
)
|
|
return 0
|
|
}
|
|
|
|
const baselineData = readBaselines(args.metadata)
|
|
const baselineRules = baselineData.rules || {}
|
|
const baselineRuleFiles = baselineData.ruleFiles || {}
|
|
|
|
const missing = args.rules.filter((r) => typeof baselineRules[r] !== 'number')
|
|
if (missing.length) {
|
|
const msg = `Missing baselines for: ${missing.join(', ')} in ${args.metadata}. Run with --init to set them.`
|
|
console.error(msg)
|
|
writeSummary(`### ESLint rule ratchet\n${msg}`)
|
|
console.log(`::error title=Missing baselines::${msg}`)
|
|
return 2
|
|
}
|
|
|
|
let failed = false
|
|
const tableRows: string[] = []
|
|
const improvedRules: string[] = []
|
|
const decreasedBaselines: Record<string, { from: number; to: number; snapshot: RuleSnapshot }> =
|
|
{}
|
|
for (const rule of args.rules) {
|
|
const baseline = baselineRules[rule] ?? 0
|
|
const current = currentCounts[rule] ?? 0
|
|
const delta = current - baseline
|
|
const currentSnapshot = currentSnapshots[rule] ?? { total: 0, files: {} }
|
|
const baselineFiles = baselineRuleFiles[rule] ?? {}
|
|
|
|
tableRows.push(
|
|
`| \`${rule}\` | **${baseline}** | **${current}** | ${delta >= 0 ? '+' : '-'}${delta} |`
|
|
)
|
|
|
|
if (current > baseline) {
|
|
failed = true
|
|
const delta = current - baseline
|
|
const baselineHasFiles = Object.hasOwn(baselineRuleFiles, rule)
|
|
const fileSummary = describeFileRegression(
|
|
baselineFiles,
|
|
currentSnapshot.files,
|
|
baselineHasFiles
|
|
)
|
|
const msgParts = [
|
|
`You added ${delta === 1 ? 'a new violation' : `${delta} new violations`} of ${rule}. Please fix it: baseline=${baseline}, current=${current}`,
|
|
]
|
|
if (fileSummary) {
|
|
msgParts.push(
|
|
`Affected files: ${fileSummary}${baselineHasFiles ? '' : ' (baseline missing file breakdown; rerun with --init to capture it)'}`
|
|
)
|
|
}
|
|
const msg = msgParts.join(' ')
|
|
console.error(msg)
|
|
console.log(`::error title=New violations::${msg}`)
|
|
} else if (current < baseline) {
|
|
improvedRules.push(rule)
|
|
if (args.decreaseBaselines) {
|
|
decreasedBaselines[rule] = { from: baseline, to: current, snapshot: currentSnapshot }
|
|
}
|
|
}
|
|
}
|
|
|
|
const summaryLines = [
|
|
`### ESLint rule ratchet`,
|
|
`Metadata: \`${args.metadata}\``,
|
|
``,
|
|
`| Rule | Baseline | Current | Δ |`,
|
|
`| --- | ---: | ---: | ---: |`,
|
|
...tableRows,
|
|
``,
|
|
]
|
|
|
|
if (args.decreaseBaselines && Object.keys(decreasedBaselines).length > 0) {
|
|
const updates: Record<string, RuleSnapshot> = {}
|
|
const details: string[] = []
|
|
const logParts: string[] = []
|
|
for (const [rule, { from, to, snapshot }] of Object.entries(decreasedBaselines)) {
|
|
updates[rule] = snapshot
|
|
details.push(`- \`${rule}\`: ${from} -> ${to}`)
|
|
logParts.push(`${rule}: ${from} -> ${to}`)
|
|
}
|
|
writeBaselines(args.metadata, updates, true)
|
|
summaryLines.push('', 'Baselines decreased for improved rules:', ...details, '')
|
|
console.log(`Baselines decreased for improved rules: ${logParts.join(', ')}`)
|
|
}
|
|
|
|
writeSummary(summaryLines.join('\n'))
|
|
|
|
if (failed) {
|
|
if (stderr && stderr.trim()) console.error('\nESLint stderr:\n', stderr)
|
|
return 1
|
|
} else {
|
|
console.log(
|
|
improvedRules.length > 0
|
|
? 'Nice! Some rules improved.'
|
|
: 'Stable: No regressions for selected rules.'
|
|
)
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function main(): void {
|
|
const exitCode = runRatchet(process.argv, dangerouslyRunEsLint)
|
|
process.exit(exitCode)
|
|
}
|
|
|
|
if (process.argv[1]) {
|
|
const invokedPath = pathToFileURL(path.resolve(process.argv[1])).href
|
|
if (import.meta.url === invokedPath) {
|
|
main()
|
|
}
|
|
}
|
|
|
|
function describeFileRegression(
|
|
baselineFiles: Record<string, number>,
|
|
currentFiles: Record<string, number>,
|
|
baselineHasFiles: boolean
|
|
): string {
|
|
const MAX_FILES = 5
|
|
if (baselineHasFiles) {
|
|
const entries = Object.entries(currentFiles)
|
|
.map(([file, count]) => ({
|
|
file,
|
|
delta: count - (baselineFiles[file] ?? 0),
|
|
}))
|
|
.filter(({ delta }) => delta > 0)
|
|
.sort((a, b) => b.delta - a.delta || a.file.localeCompare(b.file))
|
|
|
|
if (!entries.length) return ''
|
|
|
|
return formatFileList(
|
|
entries.map(({ file, delta }) => `${file} (+${delta})`),
|
|
MAX_FILES
|
|
)
|
|
}
|
|
|
|
const currentEntries = Object.entries(currentFiles)
|
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
.map(([file, count]) => `${file} (${count} current)`)
|
|
|
|
if (!currentEntries.length) return ''
|
|
|
|
return formatFileList(currentEntries, MAX_FILES)
|
|
}
|
|
|
|
function formatFileList(entries: string[], maxFiles: number): string {
|
|
if (entries.length <= maxFiles) {
|
|
return entries.join(', ')
|
|
}
|
|
const remainder = entries.length - maxFiles
|
|
const plural = remainder === 1 ? 'file' : 'files'
|
|
return `${entries.slice(0, maxFiles).join(', ')}, +${remainder} more ${plural}`
|
|
}
|