From 35a0f34eacfcdab0c11a61122b0c98a49c097553 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Tue, 21 Jul 2026 20:57:43 -0500 Subject: [PATCH] refactor: rebase file-system routing onto @solidjs/file-routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes the fs-routing machinery (scanner, export analysis, virtual module serializer, watcher, tree-shake — ~1,250 lines with specs) in favor of the router-neutral @solidjs/file-routes package extracted in solidjs/solid-router#572, per the architecture in solidjs/solid-router#571. Start keeps only its server conventions: SolidStartServerFileRouter now extends PageFileSystemRouter, adding the GET/POST/etc. export handling and the dataOnly flag. SolidStartClientFileRouter is the shared page convention re-exported. The config composes fileRoutes({ routers }) from @solidjs/file-routes/vite, and the virtual module id becomes the neutral solid:file-routes. Blocked on @solidjs/file-routes being published; the deleted specs already live in that package's test suite. Validated with a local file: link: tsc clean, 32/32 remaining tests pass. Co-Authored-By: Claude Fable 5 --- packages/start/package.json | 1 + packages/start/src/config/fs-router.ts | 116 +---- .../start/src/config/fs-routes/fs-watcher.ts | 66 --- packages/start/src/config/fs-routes/index.ts | 136 ----- .../start/src/config/fs-routes/router.spec.ts | 85 ---- packages/start/src/config/fs-routes/router.ts | 181 ------- .../src/config/fs-routes/tree-shake.spec.ts | 289 ----------- .../start/src/config/fs-routes/tree-shake.ts | 475 ------------------ packages/start/src/config/index.ts | 32 +- packages/start/src/server/routes.ts | 2 +- 10 files changed, 39 insertions(+), 1344 deletions(-) delete mode 100644 packages/start/src/config/fs-routes/fs-watcher.ts delete mode 100644 packages/start/src/config/fs-routes/index.ts delete mode 100644 packages/start/src/config/fs-routes/router.spec.ts delete mode 100644 packages/start/src/config/fs-routes/router.ts delete mode 100644 packages/start/src/config/fs-routes/tree-shake.spec.ts delete mode 100644 packages/start/src/config/fs-routes/tree-shake.ts diff --git a/packages/start/package.json b/packages/start/package.json index b13795f37..a7ebbaa68 100644 --- a/packages/start/package.json +++ b/packages/start/package.json @@ -56,6 +56,7 @@ "@babel/core": "^7.29.0", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", + "@solidjs/file-routes": "^0.1.0", "@solidjs/meta": "0.30.0-next.0", "@solidjs/router": "0.17.0-next.6", "@solidjs/web": "2.0.0-beta.21", diff --git a/packages/start/src/config/fs-router.ts b/packages/start/src/config/fs-router.ts index 3bea17361..f957f366a 100644 --- a/packages/start/src/config/fs-router.ts +++ b/packages/start/src/config/fs-router.ts @@ -1,86 +1,23 @@ -import type { StaticExportEntry } from "oxc-parser"; - import { analyzeModule, - BaseFileSystemRouter, - cleanPath, + getExportName, + getLocalExportName, + PageFileSystemRouter, type FileSystemRouterConfig, -} from "./fs-routes/router.ts"; - -export class SolidStartClientFileRouter extends BaseFileSystemRouter { - toPath(src: string) { - const routePath = cleanPath(src, this.config) - // remove the initial slash - .slice(1) - .replace(/index$/, "") - .replace(/\[([^/]+)\]/g, (_, m) => { - if (m.length > 3 && m.startsWith("...")) { - return `*${m.slice(3)}`; - } - if (m.length > 2 && m.startsWith("[") && m.endsWith("]")) { - return `:${m.slice(1, -1)}?`; - } - return `:${m}`; - }); - - return routePath?.length > 0 ? `/${routePath}` : "/"; - } - - toRoute(src: string) { - const path = this.toPath(src); + type ModuleRef, + type RouteManifestEntry, +} from "@solidjs/file-routes"; - if (src.endsWith(".md") || src.endsWith(".mdx")) { - return { - page: true, - $component: { - src: src, - pick: ["$css"], - }, - $$route: undefined, - path, - // filePath: src - }; - } - - const exports = analyzeModule(src); - const exportNames = exports.map(getExportName); - const localExportNames = exports.map(getLocalExportName).filter(name => name !== undefined); - const hasDefault = exportNames.includes("default"); - const hasRouteConfig = exportNames.includes("route"); - if (hasDefault) { - return { - page: true, - $component: { - src: src, - pick: [...localExportNames.filter(name => name !== "route"), "default", "$css"], - }, - $$route: hasRouteConfig - ? { - src: src, - pick: ["route"], - } - : undefined, - path, - // filePath: src - }; - } - } -} +/** + * The client router is the plain page convention from `@solidjs/file-routes`: + * default export = page, optional `route` config export, md/mdx pages. + */ +export { PageFileSystemRouter as SolidStartClientFileRouter }; const HTTP_METHODS = ["HEAD", "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; -function getExportName(entry: StaticExportEntry) { - return entry.exportName.name ?? "default"; -} - -function getLocalExportName(entry: StaticExportEntry) { - const name = getExportName(entry); - if (name === "default") return; - return name === (entry.localName.name ?? entry.importName.name ?? name) ? name : undefined; -} - function createHTTPHandlers(src: string, exports: readonly string[]) { - const handlers: Record = {}; + const handlers: Record = {}; for (const exp of exports) { if (HTTP_METHODS.includes(exp)) { handlers[`$${exp}`] = { @@ -99,33 +36,24 @@ function createHTTPHandlers(src: string, exports: readonly string[]) { return handlers; } -export class SolidStartServerFileRouter extends BaseFileSystemRouter { +/** + * SolidStart's server convention on top of the shared page convention: + * modules may additionally export `GET`, `POST`, etc. API handlers, and + * component refs are omitted entirely when SSR is disabled (`dataOnly`). + */ +export class SolidStartServerFileRouter extends PageFileSystemRouter { declare config: FileSystemRouterConfig & { dataOnly?: boolean }; constructor(config: FileSystemRouterConfig & { dataOnly?: boolean }) { super(config); } - toPath(src: string) { - const routePath = cleanPath(src, this.config) - // remove the initial slash - .slice(1) - .replace(/index$/, "") - .replace(/\[([^/]+)\]/g, (_, m) => { - if (m.length > 3 && m.startsWith("...")) { - return `*${m.slice(3)}`; - } - if (m.length > 2 && m.startsWith("[") && m.endsWith("]")) { - return `:${m.slice(1, -1)}?`; - } - return `:${m}`; - }); + toRoute(src: string): RouteManifestEntry | undefined { + if (this.config.toRoute) return this.config.toRoute(src, this); - return routePath?.length > 0 ? `/${routePath}` : "/"; - } - - toRoute(src: string) { const path = this.toPath(src); + if (path === undefined) return; + if (src.endsWith(".md") || src.endsWith(".mdx")) { return { page: true, diff --git a/packages/start/src/config/fs-routes/fs-watcher.ts b/packages/start/src/config/fs-routes/fs-watcher.ts deleted file mode 100644 index 9ba55b3e7..000000000 --- a/packages/start/src/config/fs-routes/fs-watcher.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { EnvironmentModuleNode, FSWatcher, PluginOption, ViteDevServer } from "vite"; -import { debounce } from "../../utils/debounce.ts"; -import { VITE_ENVIRONMENTS } from "../constants.ts"; -import { moduleId } from "./index.ts"; -import type { BaseFileSystemRouter } from "./router.ts"; - -function setupWatcher(watcher: FSWatcher, routes: BaseFileSystemRouter): void { - watcher.on("unlink", path => routes.removeRoute(path)); - watcher.on("add", path => routes.addRoute(path)); - watcher.on("change", path => routes.updateRoute(path)); -} - -function createRoutesReloader( - server: ViteDevServer, - routes: BaseFileSystemRouter, - environment: "client" | "ssr", -) { - const devEnv = server.environments[environment]!; - if (!devEnv?.moduleGraph) return; - - /** - * Debounce catches multiple route changes in a row - * Short timeout for inexpensive invalidations - */ - const invalidateModule = debounce((mod: EnvironmentModuleNode) => { - devEnv.moduleGraph.invalidateModule(mod); - }, 0); - - /** - * Long debounce timeout for expensive reloads - */ - const reloadModule = debounce((mod: EnvironmentModuleNode) => { - devEnv.reloadModule(mod); - }, 200); - - return routes.on("reload", function handleRoutesReload(evt): void { - const mod = devEnv.moduleGraph.getModuleById(moduleId)!; - if (!mod) { - devEnv.hot.send({ type: "full-reload" }); - return; - } - - if (environment === VITE_ENVIRONMENTS.client && evt.detail.type !== "update") { - // Client has to be reloaded when routes are added or removed - reloadModule(mod); - } else { - invalidateModule(mod); - } - }); -} - -export const fileSystemWatcher = ( - routers: Record<"client" | "ssr", BaseFileSystemRouter>, -): PluginOption => { - const plugin: PluginOption = { - name: "fs-watcher", - async configureServer(server: ViteDevServer) { - for (const environment of [VITE_ENVIRONMENTS.server, VITE_ENVIRONMENTS.client]) { - const router = routers[environment]; - setupWatcher(server.watcher, router); - createRoutesReloader(server, router, environment); - } - }, - }; - return plugin; -}; diff --git a/packages/start/src/config/fs-routes/index.ts b/packages/start/src/config/fs-routes/index.ts deleted file mode 100644 index 80f9ecb1a..000000000 --- a/packages/start/src/config/fs-routes/index.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { relative } from "node:path"; -import { type PluginOption } from "vite"; - -import { fileSystemWatcher } from "./fs-watcher.ts"; -import type { BaseFileSystemRouter } from "./router.ts"; -import { treeShake } from "./tree-shake.ts"; - -export const moduleId = "solid-start:routes"; - -export interface FsRoutesArgs { - routers: Record<"client" | "ssr", BaseFileSystemRouter>; -} - -export function fsRoutes({ routers }: FsRoutesArgs): Array { - (globalThis as any).ROUTERS = routers; - - return [ - { - name: "solid-start-fs-routes", - enforce: "pre", - resolveId(id) { - if (id === moduleId) return id; - }, - async load(id) { - const root = this.environment.config.root; - const isBuild = this.environment.mode === "build"; - - if (id !== moduleId) return; - const js = jsCode(); - - const router = (globalThis as any).ROUTERS[this.environment.name]; - - const routes = await router.getRoutes(); - - let routesCode = JSON.stringify(routes ?? [], (k, v) => { - if (v === undefined) return undefined; - - if (k.startsWith("$$")) { - const buildId = `${v.src}?${v.pick.map((p: any) => `pick=${p}`).join("&")}`; - - /** - * @type {{ [key: string]: string }} - */ - const refs: Record = {}; - for (var pick of v.pick) { - refs[pick] = js.addNamedImport(pick, buildId); - } - return { - require: `_$() => ({ ${Object.entries(refs) - .map(([pick, namedImport]) => `'${pick}': ${namedImport}`) - .join(", ")} })$_`, - // src: isBuild ? relative(root, buildId) : buildId - }; - } else if (k.startsWith("$")) { - const buildId = `${v.src}?${v.pick.map((p: any) => `pick=${p}`).join("&")}`; - return { - src: relative(root, buildId), - build: isBuild ? `_$() => import('${buildId}')$_` : undefined, - import: `_$() => import('${buildId}')$_`, - }; - } - return v; - }); - - routesCode = routesCode.replaceAll('"_$(', "(").replaceAll(')$_"', ")"); - - const code = ` -${js.getImportStatements()} -export default ${routesCode}`; - return code; - }, - }, - treeShake(), - fileSystemWatcher(routers), - ]; -} - -function jsCode() { - const imports = new Map(); - let vars = 0; - - function addImport(p: any) { - let id = imports.get(p); - if (!id) { - id = {}; - imports.set(p, id); - } - - const d = "routeData" + vars++; - id["default"] = d; - return d; - } - - function addNamedImport(name: string | number, p: any) { - let id = imports.get(p); - if (!id) { - id = {}; - imports.set(p, id); - } - - const d = "routeData" + vars++; - id[name] = d; - return d; - } - - const getNamedExport = (p: any) => { - const id = imports.get(p); - - delete id["default"]; - - return Object.keys(id).length > 0 - ? `{ ${Object.keys(id) - .map(k => `${k} as ${id[k]}`) - .join(", ")} }` - : ""; - }; - - const getImportStatements = () => { - return `${[...imports.keys()] - .map( - i => - `import ${ - imports.get(i).default - ? `${imports.get(i).default}${Object.keys(imports.get(i)).length > 1 ? ", " : ""}` - : "" - } ${getNamedExport(i)} from '${i}';`, - ) - .join("\n")}`; - }; - - return { - addImport, - addNamedImport, - getImportStatements, - }; -} diff --git a/packages/start/src/config/fs-routes/router.spec.ts b/packages/start/src/config/fs-routes/router.spec.ts deleted file mode 100644 index ab858d7e6..000000000 --- a/packages/start/src/config/fs-routes/router.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; - -import { SolidStartClientFileRouter } from "../fs-router.ts"; -import { analyzeModule } from "./router.ts"; - -const temporaryDirectories: string[] = []; - -function writeRoute(source: string) { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "solid-start-router-")); - const filename = path.join(directory, "route.tsx"); - temporaryDirectories.push(directory); - fs.writeFileSync(filename, source); - return filename; -} - -afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - fs.rmSync(directory, { recursive: true }); - } -}); - -describe("analyzeModule", () => { - it("returns runtime exports from TSX modules", () => { - const route = writeRoute(` - export type TypeOnly = string; - const local = 1; - export { local, local as renamed }; - export const route = {}; - export function GET() {} - export default function Route() { - return
; - } - `); - - const exports = analyzeModule(route); - - expect( - exports.map(entry => entry.exportName.name ?? entry.exportName.kind.toLowerCase()), - ).toEqual(["local", "renamed", "route", "GET", "default"]); - expect(exports.every(entry => !entry.isType)).toBe(true); - }); - - it("preserves local-name semantics for re-exports", () => { - const route = writeRoute(` - export { external, external as renamedExternal } from "./external.ts"; - export { default as DefaultExport } from "./external.ts"; - export * as namespace from "./external.ts"; - export * from "./external.ts"; - `); - - const exports = analyzeModule(route); - - expect(exports.map(entry => entry.exportName.name)).toEqual([ - "external", - "renamedExternal", - "DefaultExport", - "namespace", - ]); - expect(exports.map(entry => entry.importName.name)).toEqual([ - "external", - "external", - "default", - null, - ]); - }); - - it("throws on invalid route syntax", () => { - const route = writeRoute("export default function Route( {"); - - expect(() => analyzeModule(route)).toThrow(`Failed to parse ${route}`); - }); - - it("does not include an anonymous default export twice", () => { - const route = writeRoute("export default () =>
;"); - const router = new SolidStartClientFileRouter({ - dir: path.dirname(route), - extensions: ["tsx"], - }); - - expect(router.toRoute(route)?.$component.pick).toEqual(["default", "$css"]); - }); -}); diff --git a/packages/start/src/config/fs-routes/router.ts b/packages/start/src/config/fs-routes/router.ts deleted file mode 100644 index bb2e3e21c..000000000 --- a/packages/start/src/config/fs-routes/router.ts +++ /dev/null @@ -1,181 +0,0 @@ -import fg from "fast-glob"; -import fs from "node:fs"; -import micromatch from "micromatch"; -import { posix } from "node:path"; -import { parseSync, type StaticExportEntry } from "oxc-parser"; -import { pathToRegexp } from "path-to-regexp"; - -import { normalizePath } from "vite"; - -export { pathToRegexp }; - -export const glob = (path: string) => fg.sync(path, { absolute: true }); - -export type FileSystemRouterConfig = { dir: string; extensions: string[] }; -type Route = { path: string } & Record; - -export function cleanPath(src: string, config: FileSystemRouterConfig) { - return src - .slice(config.dir.length) - .replace(new RegExp(`\.(${(config.extensions ?? []).join("|")})$`), ""); -} - -export function analyzeModule(src: string): StaticExportEntry[] { - const result = parseSync(src, fs.readFileSync(src, "utf-8"), { lang: "tsx" }); - const error = result.errors[0]; - if (error) throw new SyntaxError(`Failed to parse ${src}:\n${error.codeframe || error.message}`); - - return result.module.staticExports.flatMap(({ entries }) => - entries.filter(entry => !entry.isType && entry.exportName.kind !== "None"), - ); -} - -type RouterEvent = CustomEvent<{ route: string; type: "update" | "remove" | "add" }>; - -export class BaseFileSystemRouter extends EventTarget { - routes: Route[]; - - config: FileSystemRouterConfig; - - /** - * - * @param {} config - */ - constructor(config: FileSystemRouterConfig) { - super(); - this.routes = []; - this.config = config; - } - - glob() { - return ( - posix.join(fg.convertPathToPattern(this.config.dir), "**/*") + - `.{${this.config.extensions.join(",")}}` - ); - } - - async buildRoutes(): Promise { - for (var src of glob(this.glob())) { - await this.addRoute(src); - } - - return this.routes; - } - - isRoute(src: string) { - return Boolean(micromatch(src as any, this.glob())?.length); - } - - toPath(src: string) { - throw new Error("Not implemented"); - } - - toRoute(src: string): Route | undefined { - let path = this.toPath(src); - - if (path === undefined) return; - - const exports = analyzeModule(src); - - if (!exports.find(entry => entry.exportName.kind === "Default")) { - console.warn("No default export", src); - } - - return { - $component: { - src: src, - pick: ["default", "$css"], - }, - path, - // filePath: src - }; - } - - /** - * To be attached by vite plugin to the vite dev server - */ - update = undefined; - - _addRoute(route: Route) { - const idx = this.routes.findIndex(r => r.path === route.path); - if (idx >= 0) this.routes.splice(idx, 1); - this.routes.push(route); - - return idx >= 0; - } - - async addRoute(src: string) { - src = normalizePath(src); - if (this.isRoute(src)) { - try { - const route = this.toRoute(src); - if (route) { - this._addRoute(route); - this.reload(route.path, "add"); - } - } catch (e) { - console.error(e); - } - } - } - - reload(route: string, type: "update" | "remove" | "add") { - this.dispatchEvent( - new CustomEvent("reload", { - detail: { - route, - type, - }, - }), - ); - } - - async updateRoute(src_: string) { - const src = normalizePath(src_); - if (this.isRoute(src)) { - try { - const route = this.toRoute(src); - if (route) { - const updated = this._addRoute(route); - this.reload(route.path, updated ? "update" : "add"); - } else { - this.removeRoute(src_); - } - } catch (e) { - console.error(e); - } - // this.update?.(); - } - } - - removeRoute(src: string) { - src = normalizePath(src); - if (this.isRoute(src)) { - const path = this.toPath(src); - if (path === undefined) { - return; - } - - const idx = this.routes.findIndex(r => r.path === path); - if (idx === -1) return; - - this.routes.splice(idx, 1); - this.reload(path, "remove"); - } - } - - on(type: string, cb: (evt: RouterEvent) => void) { - this.addEventListener(type, cb as any); - return () => this.removeEventListener(type, cb as any); - } - - buildRoutesPromise?: Promise; - - async getRoutes() { - if (!this.buildRoutesPromise) { - this.buildRoutesPromise = this.buildRoutes(); - } - await this.buildRoutesPromise; - return this.routes; - } -} diff --git a/packages/start/src/config/fs-routes/tree-shake.spec.ts b/packages/start/src/config/fs-routes/tree-shake.spec.ts deleted file mode 100644 index 47e94c0bd..000000000 --- a/packages/start/src/config/fs-routes/tree-shake.spec.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { treeShake } from "./tree-shake.ts"; - -async function shake(code: string, pick: string[]) { - const plugin = treeShake() as any; - const id = `/routes/route.ts?${pick.map(p => `pick=${p}`).join("&")}`; - const result = await plugin.transform(code, id); - return result?.code as string | undefined; -} - -describe("treeShake", () => { - it("keeps only the picked export", async () => { - const code = ` - export const GET = () => "get"; - export const POST = () => "post"; - `; - - const output = await shake(code, ["GET"]); - - expect(output).toContain("export const GET"); - expect(output).not.toContain("POST"); - }); - - // https://github.com/solidjs/solid-start/issues/2100 - it("keeps a non-picked export that a picked export references", async () => { - const code = ` - export const hello = () => "hello"; - export const GET = async () => { - return hello(); - }; - `; - - const output = await shake(code, ["GET"]); - - expect(output).toContain(`const hello = () => "hello"`); - expect(output).not.toContain("export const hello"); - expect(output).toContain("export const GET"); - }); - - it("keeps a non-picked exported function that a picked export references", async () => { - const code = ` - export function helper() { - return "helper"; - } - export const GET = () => helper(); - `; - - const output = await shake(code, ["GET"]); - - expect(output).toContain("function helper()"); - expect(output).not.toContain("export function helper"); - expect(output).toContain("export const GET"); - }); - - it("removes a non-picked export that nothing references", async () => { - const code = ` - export const secret = globalThis.createSecret(); - export const GET = () => "ok"; - `; - - const output = await shake(code, ["GET"]); - - expect(output).not.toContain("secret"); - expect(output).toContain("export const GET"); - }); - - // https://github.com/solidjs/solid-start/issues/1659 - it("keeps picked exports declared via specifiers, including aliases", async () => { - const code = ` - const handler = () => "ok"; - export { handler as GET, handler as POST }; - `; - - const output = await shake(code, ["GET"]); - - expect(output).toContain("handler as GET"); - expect(output).not.toContain("POST"); - }); - - it("splits mixed variable declarations while preserving evaluation order", async () => { - const code = ` - export const first = 1, GET = () => first; - `; - - const output = await shake(code, ["GET"]); - - expect(output).toMatch(/const first = 1;\s*export const GET/); - expect(output).not.toContain("export const first"); - }); - - it("keeps a named default export that a picked export references", async () => { - const code = ` - export default function Page() { - return "page"; - } - export const GET = () => Page(); - `; - - const output = await shake(code, ["GET"]); - - expect(output).toContain("function Page()"); - expect(output).not.toContain("export default"); - expect(output).toContain("export const GET"); - }); - - it("removes an unreferenced default export, including anonymous ones", async () => { - const named = await shake( - ` - export default function Page() { return "page"; } - export const GET = () => "ok"; - `, - ["GET"], - ); - expect(named).not.toContain("Page"); - - const anonymous = await shake( - ` - export default function () { return "page"; } - export const GET = () => "ok"; - `, - ["GET"], - ); - expect(anonymous).not.toContain("export default"); - expect(anonymous).toContain("export const GET"); - }); - - it("removes imports only used by removed exports", async () => { - const code = ` - import { db } from "./db.ts"; - export const POST = () => db.write(); - export const GET = () => "ok"; - `; - - const output = await shake(code, ["GET"]); - - expect(output).not.toContain("db"); - expect(output).toContain("export const GET"); - }); - - it("removes unreachable export cycles and their imports", async () => { - const code = ` - import { register } from "./registry.ts"; - export const first = register(() => second); - export const second = register(() => first); - export const GET = () => "ok"; - `; - - const output = await shake(code, ["GET"]); - - expect(output).not.toContain("register"); - expect(output).not.toContain("first"); - expect(output).not.toContain("second"); - expect(output).toContain("export const GET"); - }); - - it("keeps an export cycle reachable from a picked export", async () => { - const code = ` - export const first = () => second(); - export const second = () => first(); - export const GET = () => first(); - `; - - const output = await shake(code, ["GET"]); - - expect(output).toContain("const first"); - expect(output).toContain("const second"); - expect(output).not.toContain("export const first"); - expect(output).not.toContain("export const second"); - }); - - it("removes an unreachable cycle through a non-exported binding", async () => { - const code = ` - import { register } from "./registry.ts"; - export const first = register(() => helper()); - const helper = () => first; - export const GET = () => "ok"; - `; - - const output = await shake(code, ["GET"]); - - expect(output).not.toContain("register"); - expect(output).not.toContain("first"); - expect(output).not.toContain("helper"); - expect(output).toContain("export const GET"); - }); - - it("ignores type-only references when determining reachability", async () => { - const code = ` - import { initializeSecret } from "./secret.ts"; - export const secret = initializeSecret(); - type Secret = typeof secret; - export const GET = (_value: Secret) => "ok"; - `; - - const output = await shake(code, ["GET"]); - - expect(output).not.toContain("initializeSecret"); - expect(output).not.toContain("const secret"); - expect(output).toContain("export const GET"); - }); - - it("keeps runtime references wrapped in TypeScript syntax", async () => { - const code = ` - type Handler = () => string; - export const helper = () => "ok"; - export const GET = () => helper satisfies Handler; - `; - - const output = await shake(code, ["GET"]); - - expect(output).toContain("const helper"); - expect(output).not.toContain("export const helper"); - }); - - it("removes an unreferenced named default class", async () => { - const code = ` - export default class Page { - static { globalThis.initializePage(); } - } - export const GET = () => "ok"; - `; - - const output = await shake(code, ["GET"]); - - expect(output).not.toContain("Page"); - expect(output).not.toContain("initializePage"); - expect(output).toContain("export const GET"); - }); - - it("keeps a class reachable from a picked export", async () => { - const code = ` - export class Helper { - static value = "ok"; - } - export const GET = () => Helper.value; - `; - - const output = await shake(code, ["GET"]); - - expect(output).toContain("class Helper"); - expect(output).not.toContain("export class Helper"); - expect(output).toContain("export const GET"); - }); - - it("removes an unreachable cycle through a non-exported class", async () => { - const code = ` - import { register } from "./registry.ts"; - export const first = register(() => Helper); - class Helper { - value() { return first; } - } - export const GET = () => "ok"; - `; - - const output = await shake(code, ["GET"]); - - expect(output).not.toContain("register"); - expect(output).not.toContain("first"); - expect(output).not.toContain("Helper"); - expect(output).toContain("export const GET"); - }); - - it("preserves declare when splitting mixed variable declarations", async () => { - const code = ` - export declare const helper: string, GET: () => string; - `; - - const output = await shake(code, ["GET"]); - - expect(output).not.toContain("helper"); - expect(output).toContain("export declare const GET"); - }); - - it("removes an unpicked var initializer when the binding is redeclared", async () => { - const code = ` - import { initializeSecret } from "./secret.ts"; - var secret = initializeSecret(); - export var secret; - var secret; - export const GET = () => "ok"; - `; - - const output = await shake(code, ["GET"]); - - expect(output).not.toContain("initializeSecret"); - expect(output).toContain("export const GET"); - }); -}); diff --git a/packages/start/src/config/fs-routes/tree-shake.ts b/packages/start/src/config/fs-routes/tree-shake.ts deleted file mode 100644 index db63a3329..000000000 --- a/packages/start/src/config/fs-routes/tree-shake.ts +++ /dev/null @@ -1,475 +0,0 @@ -// All credit for this work goes to the amazing Next.js team. -// https://github.com/vercel/next.js/blob/canary/packages/next/build/babel/plugins/next-ssg-transform.ts -// This is adapted to work with routeData functions. It can be run in two modes, one which preserves the routeData and the Component in the same file, and one which creates a - -import type * as Babel from "@babel/core"; -import type { NodePath, PluginObj, PluginPass } from "@babel/core"; -import type { Binding } from "@babel/traverse"; -import type { Identifier } from "@babel/types"; -import { basename } from "pathe"; -import type { Plugin } from "vite"; - -type State = Omit & { - opts: { pick: string[] }; - refs: Set; - candidates: Set>; - done: boolean; -}; - -function treeShakeTransform({ types: t }: typeof Babel): PluginObj { - function getIdentifier(path: any) { - const parentPath = path.parentPath; - if (parentPath.type === "VariableDeclarator") { - const pp = parentPath; - const name = pp.get("id"); - return name.node.type === "Identifier" ? name : null; - } - if (parentPath.type === "AssignmentExpression") { - const pp = parentPath; - const name = pp.get("left"); - return name.node.type === "Identifier" ? name : null; - } - if (path.node.type === "ArrowFunctionExpression") { - return null; - } - return path.node.id && path.node.id.type === "Identifier" ? path.get("id") : null; - } - - function isTypeOnlyReference(path: NodePath) { - return path.getAncestry().some(parent => { - if (parent.isTSType()) return true; - if (parent.isExportNamedDeclaration()) return parent.node.exportKind === "type"; - if (parent.isExportSpecifier()) return parent.node.exportKind === "type"; - return false; - }); - } - - function runtimeReferences(binding: Binding) { - return binding.referencePaths.filter(ref => !isTypeOnlyReference(ref)); - } - - function isIdentifierReferenced(ident: any, includeTypes = false) { - const b: Binding | undefined = ident.scope.getBinding(ident.node.name); - if (b) { - const references = b.constantViolations.concat( - includeTypes ? b.referencePaths : runtimeReferences(b), - ); - if (!references.length) return false; - if (b.path.type === "FunctionDeclaration") { - return !references.every(ref => ref.findParent(p => p === b.path)); - } - return true; - } - return false; - } - function markFunction(path: any, state: any) { - const ident = getIdentifier(path); - if (ident && ident.node && isIdentifierReferenced(ident, true)) { - state.refs.add(ident); - } - } - function markImport(path: any, state: any) { - const local = path.get("local"); - if (isIdentifierReferenced(local, true)) { - state.refs.add(local); - } - } - - return { - visitor: { - Program: { - enter(path, state) { - state.refs = new Set(); - state.candidates = new Set(); - state.done = false; - path.traverse( - { - VariableDeclarator(variablePath, variableState: any) { - if (variablePath.node.id.type === "Identifier") { - const local = variablePath.get("id"); - if (isIdentifierReferenced(local, true)) { - variableState.refs.add(local); - } - } else if (variablePath.node.id.type === "ObjectPattern") { - const pattern = variablePath.get("id"); - const properties = pattern.get("properties") as Array; - properties.forEach(p => { - const local = p.get( - p.node.type === "ObjectProperty" - ? "value" - : p.node.type === "RestElement" - ? "argument" - : (() => { - throw new Error("invariant"); - })(), - ); - if (isIdentifierReferenced(local, true)) { - variableState.refs.add(local); - } - }); - } else if (variablePath.node.id.type === "ArrayPattern") { - const pattern = variablePath.get("id"); - const elements = pattern.get("elements") as Array; - elements.forEach(e => { - let local: NodePath; - if (e.node && e.node.type === "Identifier") { - local = e; - } else if (e.node && e.node.type === "RestElement") { - local = e.get("argument"); - } else { - return; - } - if (isIdentifierReferenced(local, true)) { - variableState.refs.add(local); - } - }); - } - }, - ExportDefaultDeclaration(exportNamedPath) { - if (state.opts.pick && !state.opts.pick.includes("default")) { - const decl = exportNamedPath.node.declaration; - // A named function/class declaration creates a module-scope - // binding that picked exports may reference, so only strip - // the `export default`; the sweep below removes the - // declaration again if nothing references it. - if ((t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) && decl.id) { - const [newPath] = exportNamedPath.replaceWith(decl); - const id = (newPath as NodePath).get("id") as NodePath; - state.refs.add(id); - state.candidates.add(id); - } else { - exportNamedPath.remove(); - } - } - }, - ExportNamedDeclaration(exportNamedPath) { - // No pick list means nothing gets pruned. - if (!state.opts.pick) { - return; - } - const specifiers = exportNamedPath.get("specifiers"); - if (specifiers.length) { - specifiers.forEach(s => { - const exported = s.node.exported; - const exportedName = t.isIdentifier(exported) ? exported.name : exported.value; - if (!state.opts.pick.includes(exportedName)) { - s.remove(); - } - }); - if (exportNamedPath.node.specifiers.length < 1) { - exportNamedPath.remove(); - } - return; - } - const decl = exportNamedPath.get("declaration"); - if (decl == null || decl.node == null) { - return; - } - switch (decl.node.type) { - case "FunctionDeclaration": - case "ClassDeclaration": { - const name = decl.node.id?.name; - if (name && state.opts.pick && !state.opts.pick.includes(name)) { - // Keep the declaration in module scope since picked - // exports may reference it; the sweep below removes it - // again if unreferenced. - const [newPath] = exportNamedPath.replaceWith(decl.node); - const id = (newPath as NodePath).get("id") as NodePath; - state.refs.add(id); - state.candidates.add(id); - } - break; - } - case "VariableDeclaration": { - const declNode = decl.node; - // Destructuring declarators are conservatively treated as - // picked (matching the previous behavior of leaving them - // untouched). - const isPicked = (d: (typeof declNode.declarations)[number]) => - d.id.type !== "Identifier" || state.opts.pick.includes(d.id.name); - if (declNode.declarations.every(isPicked)) { - break; - } - // Split into one declaration per declarator (preserving - // evaluation order) and export only the picked ones. - // Unpicked bindings are kept since picked exports may - // reference them; the sweep below removes them again if - // unreferenced. - const statements = declNode.declarations.map(d => { - const single = t.variableDeclaration(declNode.kind, [d]); - single.declare = declNode.declare; - return isPicked(d) ? t.exportNamedDeclaration(single, []) : single; - }); - const newPaths = exportNamedPath.replaceWithMultiple(statements); - for (const p of newPaths) { - if (!p.isVariableDeclaration()) continue; - for (const d of p.get("declarations")) { - if (d.node.id.type === "Identifier") { - const id = d.get("id") as NodePath; - state.refs.add(id); - state.candidates.add(id); - } - } - } - break; - } - default: { - break; - } - } - }, - FunctionDeclaration: markFunction, - FunctionExpression: markFunction, - ArrowFunctionExpression: markFunction, - ClassDeclaration: markFunction, - ClassExpression: markFunction, - ImportSpecifier: markImport, - ImportDefaultSpecifier: markImport, - ImportNamespaceSpecifier: markImport, - ImportDeclaration: (path, state) => { - if ( - path.node.source.value.endsWith(".css") && - state.opts.pick && - !state.opts.pick.includes("$css") - ) { - path.remove(); - } - }, - }, - state, - ); - - // References alone cannot distinguish live bindings from dead cycles. - // Build a graph of localized exports and retain only those reachable - // from runtime code outside that graph. - path.scope.crawl(); - const tracked = new Map>(); - const candidateOwners = new Map>(); - const addOwner = ( - owners: Map>, - binding: Binding, - owner: NodePath, - ) => { - const paths = owners.get(binding) ?? new Set(); - paths.add(owner); - owners.set(binding, paths); - }; - - for (const identifier of state.refs) { - if (identifier.node?.type !== "Identifier" || !identifier.parentPath) continue; - const binding = identifier.scope.getBinding(identifier.node.name); - if (binding) addOwner(tracked, binding, identifier.parentPath); - } - for (const identifier of state.candidates) { - if (!identifier.parentPath) continue; - const binding = identifier.scope.getBinding(identifier.node.name); - if (!binding) continue; - addOwner(tracked, binding, identifier.parentPath); - addOwner(candidateOwners, binding, identifier.parentPath); - } - - const dependencies = new Map>(); - const live = new Set(); - for (const [binding, owners] of tracked) { - dependencies.set(binding, new Set()); - if ( - [...owners].some(owner => - owner.findParent( - parent => - parent.isExportNamedDeclaration() || parent.isExportDefaultDeclaration(), - ), - ) - ) { - live.add(binding); - } - } - - const ownerBindings = new Map(); - for (const [binding, owners] of tracked) { - for (const owner of owners) { - ownerBindings.set(owner, binding); - } - } - const owningBinding = (reference: NodePath) => { - const owner = reference.find(parent => ownerBindings.has(parent)); - return owner && ownerBindings.get(owner); - }; - - for (const target of tracked.keys()) { - const references = target.constantViolations.concat(runtimeReferences(target)); - for (const reference of references) { - const source = owningBinding(reference); - if (source) dependencies.get(source)?.add(target); - else live.add(target); - } - } - - const pending = [...live]; - while (pending.length) { - const source = pending.pop()!; - for (const dependency of dependencies.get(source) ?? []) { - if (live.has(dependency)) continue; - live.add(dependency); - pending.push(dependency); - } - } - - const deadCandidates = new Set( - [...candidateOwners.keys()].filter(candidate => !live.has(candidate)), - ); - path.traverse({ - VariableDeclarator(variablePath) { - if (variablePath.node.id.type !== "Identifier") return; - const binding = variablePath.scope.getBinding(variablePath.node.id.name); - if (binding && deadCandidates.has(binding)) variablePath.remove(); - }, - }); - for (const [candidate, owners] of candidateOwners) { - if (!deadCandidates.has(candidate)) continue; - for (const owner of owners) { - if (!owner.removed && !owner.isVariableDeclarator()) owner.remove(); - } - } - - const refs = state.refs; - const refNodes = new Set([...refs].map(ref => ref.node)); - let count = 0; - const sweepFunction = (sweepPath: any) => { - const ident = getIdentifier(sweepPath); - if (ident && ident.node && refNodes.has(ident.node) && !isIdentifierReferenced(ident)) { - ++count; - if ( - t.isAssignmentExpression(sweepPath.parentPath) || - t.isVariableDeclarator(sweepPath.parentPath) - ) { - sweepPath.parentPath.remove(); - } else { - sweepPath.remove(); - } - } - }; - function sweepImport(sweepPath: any) { - const local = sweepPath.get("local"); - if (refNodes.has(local.node) && !isIdentifierReferenced(local)) { - ++count; - sweepPath.remove(); - if (sweepPath.parent.specifiers.length === 0) { - sweepPath.parentPath.remove(); - } - } - } - do { - path.scope.crawl(); - count = 0; - path.traverse({ - VariableDeclarator(variablePath) { - if (variablePath.node.id.type === "Identifier") { - const local = variablePath.get("id"); - if (refNodes.has(local.node) && !isIdentifierReferenced(local)) { - ++count; - variablePath.remove(); - } - } else if (variablePath.node.id.type === "ObjectPattern") { - const pattern = variablePath.get("id"); - const beforeCount = count; - const properties = pattern.get("properties"); - properties.forEach(p => { - const local = p.get( - p.node.type === "ObjectProperty" - ? "value" - : p.node.type === "RestElement" - ? "argument" - : (() => { - throw new Error("invariant"); - })(), - ); - if (refNodes.has(local.node) && !isIdentifierReferenced(local)) { - ++count; - p.remove(); - } - }); - if (beforeCount !== count && pattern.get("properties").length < 1) { - variablePath.remove(); - } - } else if (variablePath.node.id.type === "ArrayPattern") { - const pattern = variablePath.get("id"); - const beforeCount = count; - const elements = pattern.get("elements"); - elements.forEach(e => { - let local: NodePath | undefined; - if (e.node && e.node.type === "Identifier") { - local = e; - } else if (e.node && e.node.type === "RestElement") { - local = e.get("argument"); - } else { - return; - } - if (refNodes.has(local.node) && !isIdentifierReferenced(local)) { - ++count; - e.remove(); - } - }); - if (beforeCount !== count && pattern.get("elements").length < 1) { - variablePath.remove(); - } - } - }, - FunctionDeclaration: sweepFunction, - FunctionExpression: sweepFunction, - ArrowFunctionExpression: sweepFunction, - ClassDeclaration: sweepFunction, - ClassExpression: sweepFunction, - ImportSpecifier: sweepImport, - ImportDefaultSpecifier: sweepImport, - ImportNamespaceSpecifier: sweepImport, - }); - } while (count); - }, - }, - }, - }; -} - -export function treeShake(): Plugin { - async function transform(id: string, code: string) { - const [path, queryString] = id.split("?"); - const query = new URLSearchParams(queryString); - if (query.has("pick")) { - const babel = await import("@babel/core"); - const transformed = await babel.transformAsync(code, { - plugins: [[treeShakeTransform, { pick: query.getAll("pick") }]], - parserOpts: { - plugins: ["jsx", "typescript"], - }, - filename: basename(id), - ast: false, - sourceMaps: true, - configFile: false, - babelrc: false, - sourceFileName: id, - }); - - return transformed; - } - } - return { - name: "tree-shake", - enforce: "pre", - async transform(code, id) { - const [path, queryString] = id.split("?"); - if (!path) return; - const query = new URLSearchParams(queryString); - const ext = path.split(".").pop(); - if (!ext) return; - if (query.has("pick") && ["js", "jsx", "ts", "tsx"].includes(ext)) { - const transformed = await transform(id, code); - if (!transformed?.code) return; - - return { - code: transformed.code, - map: transformed.map, - }; - } - }, - }; -} diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 6235b8c96..80a6004b8 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -12,9 +12,8 @@ import { boundaryModules } from "./boundary-modules.ts"; import { DEFAULT_EXTENSIONS, VIRTUAL_MODULES, VITE_ENVIRONMENTS } from "./constants.ts"; import { devServer } from "./dev-server.ts"; import { envPlugin, type EnvPluginOptions } from "./env.ts"; +import { fileRoutes } from "@solidjs/file-routes/vite"; import { SolidStartClientFileRouter, SolidStartServerFileRouter } from "./fs-router.ts"; -import { fsRoutes } from "./fs-routes/index.ts"; -import type { BaseFileSystemRouter } from "./fs-routes/router.ts"; import { parseIdQuery } from "./utils.ts"; export interface SolidStartOptions { @@ -57,6 +56,17 @@ export function solidStart(options?: SolidStartOptions): Array { client: `${start.appRoot}/entry-client${entryExtension}`, server: `${start.appRoot}/entry-server${entryExtension}`, }; + const routers = { + client: new SolidStartClientFileRouter({ + dir: absolute(routeDir, root), + extensions, + }), + ssr: new SolidStartServerFileRouter({ + dir: absolute(routeDir, root), + extensions, + dataOnly: !start.ssr, + }), + }; return [ { name: "solid-start:dev-manifest-bridge", @@ -106,7 +116,7 @@ export function solidStart(options?: SolidStartOptions): Array { ? `assets/${basename(handlers.client, entryExtension)}.js` : handlers.client; if (env.command === "build") { - const clientRouter: BaseFileSystemRouter = (globalThis as any).ROUTERS.client; + const clientRouter = routers.client; for (const route of await clientRouter.getRoutes()) { for (const [key, value] of Object.entries(route)) { if (value && key.startsWith("$") && !key.startsWith("$$")) { @@ -219,21 +229,9 @@ export function solidStart(options?: SolidStartOptions): Array { }; }, }, - fsRoutes({ - routers: { - client: new SolidStartClientFileRouter({ - dir: absolute(routeDir, root), - extensions, - }), - ssr: new SolidStartServerFileRouter({ - dir: absolute(routeDir, root), - extensions, - dataOnly: !start.ssr, - }), - }, - }), + fileRoutes({ routers }), envPlugin(options?.env), - // Must be placed after fsRoutes, as treeShake will remove the + // Must be placed after fileRoutes, as treeShake will remove the // server fn exports added in by this plugin serverFunctions({ manifest: VIRTUAL_MODULES.serverFnManifest, diff --git a/packages/start/src/server/routes.ts b/packages/start/src/server/routes.ts index 0b44eb18d..239867862 100644 --- a/packages/start/src/server/routes.ts +++ b/packages/start/src/server/routes.ts @@ -1,5 +1,5 @@ // @ts-expect-error -import fileRoutes from "solid-start:routes"; +import fileRoutes from "solid:file-routes"; import { createRouter } from "radix3"; import type { FetchEvent } from "./types.ts";