diff --git a/e2e/react-start/basic/package.json b/e2e/react-start/basic/package.json index 883627bb4f7..2e4d63558e2 100644 --- a/e2e/react-start/basic/package.json +++ b/e2e/react-start/basic/package.json @@ -4,12 +4,12 @@ "sideEffects": false, "type": "module", "scripts": { - "dev": "vite dev --port 3000", - "dev:e2e": "vite dev", - "build": "vite build && tsc --noEmit", - "build:spa": "MODE=spa vite build && tsc --noEmit", - "build:prerender": "MODE=prerender vite build && tsc --noEmit", - "preview": "vite preview", + "dev": "node scripts/run-bundler.mjs dev --port 3000", + "dev:e2e": "node scripts/run-bundler.mjs dev", + "build": "node scripts/run-bundler.mjs build", + "build:spa": "MODE=spa node scripts/run-bundler.mjs build", + "build:prerender": "MODE=prerender node scripts/run-bundler.mjs build", + "preview": "node scripts/run-bundler.mjs preview", "start": "node server.js", "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &", "test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'", @@ -33,6 +33,8 @@ }, "devDependencies": { "@playwright/test": "^1.50.1", + "@rsbuild/core": "^1.2.4", + "@rsbuild/plugin-react": "^1.1.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/router-e2e-utils": "workspace:^", "@types/js-cookie": "^3.0.6", diff --git a/e2e/react-start/basic/rsbuild.config.ts b/e2e/react-start/basic/rsbuild.config.ts new file mode 100644 index 00000000000..bd9be5efbd0 --- /dev/null +++ b/e2e/react-start/basic/rsbuild.config.ts @@ -0,0 +1,49 @@ +import { defineConfig } from '@rsbuild/core' +import { pluginReact } from '@rsbuild/plugin-react' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' +import { isSpaMode } from './tests/utils/isSpaMode' +import { isPrerender } from './tests/utils/isPrerender' + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) + +const spaModeConfiguration = { + enabled: true, + prerender: { + outputPath: 'index.html', + }, +} + +const prerenderConfiguration = { + enabled: true, + filter: (page: { path: string }) => + ![ + '/this-route-does-not-exist', + '/redirect', + '/i-do-not-exist', + '/not-found/via-beforeLoad', + '/not-found/via-loader', + '/specialChars/search', + '/specialChars/hash', + '/specialChars/malformed', + '/users', + ].some((p) => page.path.includes(p)), + maxRedirects: 100, +} + +export default defineConfig({ + plugins: [ + pluginReact(), + ...tanstackStart({ + spa: isSpaMode ? spaModeConfiguration : undefined, + prerender: isPrerender ? prerenderConfiguration : undefined, + }), + ], + tools: {}, + source: { + alias: { + '~': path.resolve(currentDir, 'src'), + }, + }, +}) diff --git a/e2e/react-start/basic/scripts/run-bundler.mjs b/e2e/react-start/basic/scripts/run-bundler.mjs new file mode 100644 index 00000000000..eae38814959 --- /dev/null +++ b/e2e/react-start/basic/scripts/run-bundler.mjs @@ -0,0 +1,54 @@ +import { spawn } from 'node:child_process' + +const command = process.argv[2] +const args = process.argv.slice(3) + +if (!command) { + console.error('Missing bundler command') + process.exit(1) +} + +const bundler = process.env.BUNDLER === 'rsbuild' ? 'rsbuild' : 'vite' + +const extractPort = (args) => { + const portIndex = args.indexOf('--port') + if (portIndex >= 0 && args[portIndex + 1]) { + return args[portIndex + 1] + } + return null +} + +const run = (cmd, cmdArgs) => + new Promise((resolve, reject) => { + const child = spawn(cmd, cmdArgs, { + stdio: 'inherit', + env: process.env, + shell: process.platform === 'win32', + }) + child.on('close', (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`${cmd} exited with code ${code}`)) + } + }) + }) + +try { + if (bundler === 'rsbuild' && command === 'preview') { + const port = extractPort(args) + if (port) { + process.env.PORT = port + } + await run('node', ['server.js']) + } else { + await run(bundler, [command, ...args]) + + if (command === 'build') { + await run('tsc', ['--noEmit']) + } + } +} catch (error) { + console.error(error) + process.exit(1) +} diff --git a/e2e/react-start/basic/server.js b/e2e/react-start/basic/server.js index 83f5ff0079c..3c0a6668935 100644 --- a/e2e/react-start/basic/server.js +++ b/e2e/react-start/basic/server.js @@ -18,7 +18,12 @@ export async function createStartServer() { // to keep testing uniform stop express from redirecting /posts to /posts/ // when serving pre-rendered pages - app.use(express.static('./dist/client', { redirect: !isPrerender })) + app.use( + express.static('./dist/client', { + redirect: !isPrerender, + ...(isPrerender ? {} : { index: false }), + }), + ) app.use(async (req, res, next) => { try { diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index b5ecc4a50ff..48eaf8d7ca5 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -1,9 +1,3 @@ -/* eslint-disable */ - -// @ts-nocheck - -// noinspection JSUnusedGlobalSymbols - // This file was automatically generated by TanStack Router. // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. @@ -1365,6 +1359,7 @@ export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + import type { getRouter } from './router.tsx' import type { createStart } from '@tanstack/react-start' declare module '@tanstack/react-start' { diff --git a/e2e/react-start/basic/src/routes/__root.tsx b/e2e/react-start/basic/src/routes/__root.tsx index e1862b499c6..7bf17f27c93 100644 --- a/e2e/react-start/basic/src/routes/__root.tsx +++ b/e2e/react-start/basic/src/routes/__root.tsx @@ -10,7 +10,7 @@ import { import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' import { NotFound } from '~/components/NotFound' -import appCss from '~/styles/app.css?url' +import '~/styles/app.css' import { seo } from '~/utils/seo' export const Route = createRootRoute({ @@ -30,7 +30,6 @@ export const Route = createRootRoute({ }), ], links: [ - { rel: 'stylesheet', href: appCss }, { rel: 'apple-touch-icon', sizes: '180x180', diff --git a/e2e/react-start/basic/src/styles/app.css b/e2e/react-start/basic/src/styles/app.css index 37c1f5a6e2d..126fd9a937c 100644 --- a/e2e/react-start/basic/src/styles/app.css +++ b/e2e/react-start/basic/src/styles/app.css @@ -1,30 +1,52 @@ -@import 'tailwindcss' source('../'); - -@layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } -} - -@layer base { - html { - color-scheme: light dark; - } - - * { - @apply border-gray-200 dark:border-gray-800; - } - - html, - body { - @apply text-gray-900 bg-gray-50 dark:bg-gray-950 dark:text-gray-200; - } - - .using-mouse * { - outline: none !important; - } +*, +*::before, +*::after, +::backdrop, +::file-selector-button { + box-sizing: border-box; + border-color: #e5e7eb; +} + +html { + color-scheme: light dark; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background-color: #f9fafb; + color: #111827; +} + +.using-mouse * { + outline: none !important; +} + +.p-2 { + padding: 0.5rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.flex { + display: flex; +} + +.gap-2 { + gap: 0.5rem; +} + +.text-lg { + font-size: 1.125rem; +} + +.font-bold { + font-weight: 700; +} + +.italic { + font-style: italic; } diff --git a/packages/react-start/package.json b/packages/react-start/package.json index 24bc307f3fa..0b658714f81 100644 --- a/packages/react-start/package.json +++ b/packages/react-start/package.json @@ -74,6 +74,12 @@ "default": "./dist/esm/plugin/vite.js" } }, + "./plugin/rsbuild": { + "import": { + "types": "./dist/esm/plugin/rsbuild.d.ts", + "default": "./dist/esm/plugin/rsbuild.js" + } + }, "./server-entry": { "import": { "types": "./dist/default-entry/esm/server.d.ts", @@ -101,8 +107,14 @@ "pathe": "^2.0.3" }, "peerDependencies": { + "@rsbuild/core": ">=1.0.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + } } } diff --git a/packages/react-start/src/plugin/rsbuild.ts b/packages/react-start/src/plugin/rsbuild.ts new file mode 100644 index 00000000000..f02e273765a --- /dev/null +++ b/packages/react-start/src/plugin/rsbuild.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from 'node:url' +import path from 'pathe' +import { TanStackStartRsbuildPluginCore } from '@tanstack/start-plugin-core/rsbuild' +import type { TanStackStartInputConfig } from '@tanstack/start-plugin-core' + +type RsbuildPlugin = { + name: string + setup: (api: any) => void +} + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) +const defaultEntryDir = path.resolve( + currentDir, + '..', + '..', + 'plugin', + 'default-entry', +) +const defaultEntryPaths = { + client: path.resolve(defaultEntryDir, 'client.tsx'), + server: path.resolve(defaultEntryDir, 'server.ts'), + start: path.resolve(defaultEntryDir, 'start.ts'), +} + +export function tanstackStart( + options?: TanStackStartInputConfig, +): Array { + return TanStackStartRsbuildPluginCore( + { + framework: 'react', + defaultEntryPaths, + }, + options, + ) +} diff --git a/packages/react-start/vite.config.ts b/packages/react-start/vite.config.ts index d7ab699a07b..129669ced14 100644 --- a/packages/react-start/vite.config.ts +++ b/packages/react-start/vite.config.ts @@ -31,6 +31,7 @@ export default mergeConfig( './src/server-rpc.ts', './src/ssr-rpc.ts', './src/plugin/vite.ts', + './src/plugin/rsbuild.ts', ], externalDeps: [ '@tanstack/react-start-client', diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index d677f5530ea..6e6a8297d88 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2048,7 +2048,7 @@ export class RouterCore< * Commit a previously built location to history (push/replace), optionally * using view transitions and scroll restoration options. */ - commitLocation: CommitLocationFn = async ({ + commitLocation: CommitLocationFn = ({ viewTransition, ignoreBlocker, ...next @@ -2379,7 +2379,7 @@ export class RouterCore< onReady: async () => { // Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition) this.startTransition(() => { - this.startViewTransition(async () => { + this.startViewTransition(() => { // this.viewTransitionPromise = createControlledPromise() // Commit the pending matches. If a previous match was @@ -2442,6 +2442,8 @@ export class RouterCore< ) }) }) + + return Promise.resolve() }) }) }, diff --git a/packages/router-plugin/src/rspack.ts b/packages/router-plugin/src/rspack.ts index 9eef9f2221c..ab31067d68c 100644 --- a/packages/router-plugin/src/rspack.ts +++ b/packages/router-plugin/src/rspack.ts @@ -4,6 +4,7 @@ import { configSchema } from './core/config' import { unpluginRouterCodeSplitterFactory } from './core/router-code-splitter-plugin' import { unpluginRouterGeneratorFactory } from './core/router-generator-plugin' import { unpluginRouterComposedFactory } from './core/router-composed-plugin' +import { unpluginRouteAutoImportFactory } from './core/route-autoimport-plugin' import type { CodeSplittingOptions, Config } from './core/config' /** @@ -40,6 +41,13 @@ const TanStackRouterCodeSplitterRspack = /* #__PURE__ */ createRspackPlugin( unpluginRouterCodeSplitterFactory, ) +const tanstackRouterGenerator = TanStackRouterGeneratorRspack +const tanstackRouterCodeSplitter = TanStackRouterCodeSplitterRspack + +const TanStackRouterAutoImportRspack = /* #__PURE__ */ createRspackPlugin( + unpluginRouteAutoImportFactory, +) + /** * @example * ```ts @@ -57,12 +65,17 @@ const TanStackRouterRspack = /* #__PURE__ */ createRspackPlugin( unpluginRouterComposedFactory, ) const tanstackRouter = TanStackRouterRspack +const tanstackRouterAutoImport = TanStackRouterAutoImportRspack export default TanStackRouterRspack export { configSchema, TanStackRouterRspack, TanStackRouterGeneratorRspack, TanStackRouterCodeSplitterRspack, + TanStackRouterAutoImportRspack, + tanstackRouterGenerator, + tanstackRouterCodeSplitter, + tanstackRouterAutoImport, tanstackRouter, } export type { Config, CodeSplittingOptions } diff --git a/packages/solid-start/package.json b/packages/solid-start/package.json index 375fa862804..8712b09c40f 100644 --- a/packages/solid-start/package.json +++ b/packages/solid-start/package.json @@ -74,6 +74,12 @@ "default": "./dist/esm/plugin/vite.js" } }, + "./plugin/rsbuild": { + "import": { + "types": "./dist/esm/plugin/rsbuild.d.ts", + "default": "./dist/esm/plugin/rsbuild.js" + } + }, "./server-entry": { "import": { "types": "./dist/default-entry/esm/server.d.ts", @@ -104,7 +110,13 @@ "vite": "^7.3.1" }, "peerDependencies": { + "@rsbuild/core": ">=1.0.0", "solid-js": ">=1.0.0", "vite": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + } } } diff --git a/packages/solid-start/src/plugin/rsbuild.ts b/packages/solid-start/src/plugin/rsbuild.ts new file mode 100644 index 00000000000..34bbc6542d5 --- /dev/null +++ b/packages/solid-start/src/plugin/rsbuild.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from 'node:url' +import path from 'pathe' +import { TanStackStartRsbuildPluginCore } from '@tanstack/start-plugin-core/rsbuild' +import type { TanStackStartInputConfig } from '@tanstack/start-plugin-core' + +type RsbuildPlugin = { + name: string + setup: (api: any) => void +} + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) +const defaultEntryDir = path.resolve( + currentDir, + '..', + '..', + 'plugin', + 'default-entry', +) +const defaultEntryPaths = { + client: path.resolve(defaultEntryDir, 'client.tsx'), + server: path.resolve(defaultEntryDir, 'server.ts'), + start: path.resolve(defaultEntryDir, 'start.ts'), +} + +export function tanstackStart( + options?: TanStackStartInputConfig, +): Array { + return TanStackStartRsbuildPluginCore( + { + framework: 'solid', + defaultEntryPaths, + }, + options, + ) +} diff --git a/packages/solid-start/vite.config.ts b/packages/solid-start/vite.config.ts index 4004316d919..519844c729d 100644 --- a/packages/solid-start/vite.config.ts +++ b/packages/solid-start/vite.config.ts @@ -31,6 +31,7 @@ export default mergeConfig( './src/server-rpc.ts', './src/server.tsx', './src/plugin/vite.ts', + './src/plugin/rsbuild.ts', ], externalDeps: [ '@tanstack/solid-start-client', diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 7d543b1df07..304427cc3d0 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -3,6 +3,7 @@ import { encode, isNotFound, parseRedirect, + redirect, } from '@tanstack/router-core' import { fromCrossJSON, toJSONAsync } from 'seroval' import invariant from 'tiny-invariant' @@ -33,6 +34,36 @@ function hasOwnProperties(obj: object): boolean { } return false } + +function isResponseLike(value: unknown): value is Response { + if (value instanceof Response) { + return true + } + if (value === null || typeof value !== 'object') { + return false + } + if (!('status' in value) || !('headers' in value)) { + return false + } + const headers = (value as { headers?: { get?: unknown } }).headers + return typeof headers?.get === 'function' +} + +function parseRedirectFallback(payload: unknown) { + if (!payload || typeof payload !== 'object') { + return undefined + } + if (!('isSerializedRedirect' in payload)) { + return undefined + } + if ( + (payload as { isSerializedRedirect?: boolean }).isSerializedRedirect !== + true + ) { + return undefined + } + return redirect(payload as any) +} // caller => // serverFnFetcher => // client => @@ -172,7 +203,7 @@ async function getResponse(fn: () => Promise) { try { response = await fn() // client => server => fn => server => client } catch (error) { - if (error instanceof Response) { + if (isResponseLike(error)) { response = error } else { console.log(error) @@ -240,6 +271,14 @@ async function getResponse(fn: () => Promise) { } invariant(result, 'expected result to be resolved') + const serializedRedirect = + parseRedirect(result) ?? parseRedirectFallback(result) + if (serializedRedirect) { + throw serializedRedirect + } + if (isNotFound(result)) { + throw result + } if (result instanceof Error) { throw result } @@ -251,9 +290,10 @@ async function getResponse(fn: () => Promise) { // if it's JSON if (contentType.includes('application/json')) { const jsonPayload = await response.json() - const redirect = parseRedirect(jsonPayload) - if (redirect) { - throw redirect + const redirectResult = + parseRedirect(jsonPayload) ?? parseRedirectFallback(jsonPayload) + if (redirectResult) { + throw redirectResult } if (isNotFound(jsonPayload)) { throw jsonPayload diff --git a/packages/start-plugin-core/package.json b/packages/start-plugin-core/package.json index fa51a8b842e..89761e9567e 100644 --- a/packages/start-plugin-core/package.json +++ b/packages/start-plugin-core/package.json @@ -50,6 +50,12 @@ "default": "./dist/esm/index.js" } }, + "./rsbuild": { + "import": { + "types": "./dist/esm/rsbuild/index.d.ts", + "default": "./dist/esm/rsbuild/index.js" + } + }, "./package.json": "./package.json" }, "sideEffects": false, @@ -77,6 +83,7 @@ "srvx": "^0.11.2", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", + "unplugin": "^2.3.11", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" @@ -87,6 +94,12 @@ "vite": "^7.3.1" }, "peerDependencies": { + "@rsbuild/core": ">=1.0.0", "vite": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + } } } diff --git a/packages/start-plugin-core/src/dev-server-plugin/plugin.ts b/packages/start-plugin-core/src/dev-server-plugin/plugin.ts index 533e4f634a2..9a72472da79 100644 --- a/packages/start-plugin-core/src/dev-server-plugin/plugin.ts +++ b/packages/start-plugin-core/src/dev-server-plugin/plugin.ts @@ -181,7 +181,7 @@ export function devServerPlugin({ console.error(e) try { viteDevServer.ssrFixStacktrace(e as Error) - } catch (_e) {} + } catch {} if ( webReq.headers.get('content-type')?.includes('application/json') diff --git a/packages/start-plugin-core/src/index.ts b/packages/start-plugin-core/src/index.ts index df946192ae1..777c7efeb11 100644 --- a/packages/start-plugin-core/src/index.ts +++ b/packages/start-plugin-core/src/index.ts @@ -1,6 +1,7 @@ export type { TanStackStartInputConfig } from './schema' export { TanStackStartVitePluginCore } from './plugin' +export { TanStackStartRsbuildPluginCore } from './rsbuild/plugin' export { resolveViteId } from './utils' export { VITE_ENVIRONMENT_NAMES } from './constants' diff --git a/packages/start-plugin-core/src/rsbuild/index.ts b/packages/start-plugin-core/src/rsbuild/index.ts new file mode 100644 index 00000000000..0bf18566976 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/index.ts @@ -0,0 +1,3 @@ +export { TanStackStartRsbuildPluginCore } from './plugin' +export { VITE_ENVIRONMENT_NAMES } from '../constants' +export { resolveViteId } from '../utils' diff --git a/packages/start-plugin-core/src/rsbuild/injected-head-scripts-plugin.ts b/packages/start-plugin-core/src/rsbuild/injected-head-scripts-plugin.ts new file mode 100644 index 00000000000..777f171edb1 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/injected-head-scripts-plugin.ts @@ -0,0 +1,19 @@ +import { createRspackPlugin } from 'unplugin' +import { VIRTUAL_MODULES } from '@tanstack/start-server-core' + +export function createInjectedHeadScriptsPlugin() { + const pluginFactory = createRspackPlugin(() => ({ + name: 'tanstack-start:injected-head-scripts', + resolveId(id) { + if (id === VIRTUAL_MODULES.injectedHeadScripts) { + return id + } + return null + }, + load(id) { + if (id !== VIRTUAL_MODULES.injectedHeadScripts) return null + return `export const injectedHeadScripts = undefined` + }, + })) + return pluginFactory() +} diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts new file mode 100644 index 00000000000..79ab11ed90a --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -0,0 +1,758 @@ +import fs from 'node:fs' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { joinPaths } from '@tanstack/router-core' +import { NodeRequest, sendNodeResponse } from 'srvx/node' +import path from 'pathe' +import { joinURL } from 'ufo' +import { ENTRY_POINTS, VITE_ENVIRONMENT_NAMES } from '../constants' +import { resolveEntry } from '../resolve-entries' +import { parseStartConfig } from '../schema' +import { createInjectedHeadScriptsPlugin } from './injected-head-scripts-plugin' +import { + SERVER_FN_MANIFEST_TEMP_FILE, + createServerFnManifestRspackPlugin, + createServerFnResolverPlugin, +} from './start-compiler-plugin' +import { + createStartManifestRspackPlugin, + createStartManifestVirtualModulePlugin, +} from './start-manifest-plugin' +import { postServerBuildRsbuild } from './post-server-build' +import { tanStackStartRouterRsbuild } from './start-router-plugin' +import type { TanStackStartInputConfig } from '../schema' +import type { + GetConfigFn, + ResolvedStartConfig, + TanStackStartVitePluginCoreOptions, +} from '../types' + +type RsbuildPlugin = { + name: string + setup: (api: any) => void +} + +function isFullUrl(str: string): boolean { + try { + new URL(str) + return true + } catch { + return false + } +} + +function buildRouteTreeModuleDeclaration(opts: { + generatedRouteTreePath: string + routerFilePath: string + startFilePath?: string + framework: string +}) { + const getImportPath = (absolutePath: string) => { + let relativePath = path.relative( + path.dirname(opts.generatedRouteTreePath), + absolutePath, + ) + if (!relativePath.startsWith('.')) { + relativePath = `./${relativePath}` + } + return relativePath.split(path.sep).join('/') + } + + const result: Array = [ + `import type { getRouter } from '${getImportPath(opts.routerFilePath)}'`, + ] + if (opts.startFilePath) { + result.push( + `import type { startInstance } from '${getImportPath(opts.startFilePath)}'`, + ) + } else { + result.push( + `import type { createStart } from '@tanstack/${opts.framework}-start'`, + ) + } + result.push( + `declare module '@tanstack/${opts.framework}-start' { + interface Register { + ssr: true + router: Awaited>`, + ) + if (opts.startFilePath) { + result.push( + ` config: Awaited>`, + ) + } + result.push(` } +}`) + + return result.join('\n') +} + +function defineReplaceEnv( + key: TKey, + value: TValue, +): { [P in `process.env.${TKey}` | `import.meta.env.${TKey}`]: TValue } { + return { + [`process.env.${key}`]: JSON.stringify(value), + [`import.meta.env.${key}`]: JSON.stringify(value), + } as { [P in `process.env.${TKey}` | `import.meta.env.${TKey}`]: TValue } +} + +function mergeRspackConfig(base: any, next: any) { + return { + ...base, + ...next, + plugins: [...(base?.plugins ?? []), ...(next?.plugins ?? [])], + module: { + ...base?.module, + ...next?.module, + rules: [...(base?.module?.rules ?? []), ...(next?.module?.rules ?? [])], + }, + resolve: { + ...base?.resolve, + ...next?.resolve, + alias: { + ...(base?.resolve?.alias ?? {}), + ...(next?.resolve?.alias ?? {}), + }, + }, + } +} + +function mergeEnvConfig(base: any, next: any) { + return { + ...base, + ...next, + source: { + ...base?.source, + ...next?.source, + alias: { + ...(base?.source?.alias ?? {}), + ...(next?.source?.alias ?? {}), + }, + define: { + ...(base?.source?.define ?? {}), + ...(next?.source?.define ?? {}), + }, + }, + output: { + ...base?.output, + ...next?.output, + distPath: { + ...(base?.output?.distPath ?? {}), + ...(next?.output?.distPath ?? {}), + }, + }, + tools: { + ...base?.tools, + ...next?.tools, + rspack: mergeRspackConfig(base?.tools?.rspack, next?.tools?.rspack), + }, + } +} + +function getOutputDirectory( + root: string, + config: any, + environmentName: string, + directoryName: string, +) { + const envDistPath = + config.environments?.[environmentName]?.output?.distPath?.root + if (envDistPath) { + return path.resolve(root, envDistPath) + } + const rootDistPath = config.output?.distPath?.root ?? 'dist' + return path.resolve(root, rootDistPath, directoryName) +} + +function toPluginArray(plugin: any) { + if (!plugin) return [] + return Array.isArray(plugin) ? plugin : [plugin] +} + +function resolveLoaderPath(relativePath: string) { + const currentDir = path.dirname(fileURLToPath(import.meta.url)) + const basePath = path.resolve(currentDir, relativePath) + const jsPath = `${basePath}.js` + const tsPath = `${basePath}.ts` + if (fs.existsSync(jsPath)) return jsPath + if (fs.existsSync(tsPath)) return tsPath + return jsPath +} + +export function TanStackStartRsbuildPluginCore( + corePluginOpts: TanStackStartVitePluginCoreOptions, + startPluginOpts: TanStackStartInputConfig, +): Array { + const serverFnProviderEnv = + corePluginOpts.serverFn?.providerEnv || VITE_ENVIRONMENT_NAMES.server + const ssrIsProvider = serverFnProviderEnv === VITE_ENVIRONMENT_NAMES.server + + const resolvedStartConfig: ResolvedStartConfig = { + root: '', + startFilePath: undefined, + routerFilePath: '', + srcDirectory: '', + viteAppBase: '', + serverFnProviderEnv, + } + + let startConfig: ReturnType | null = null + const getConfig: GetConfigFn = () => { + if (!resolvedStartConfig.root) { + throw new Error(`Cannot get config before root is resolved`) + } + if (!startConfig) { + startConfig = parseStartConfig( + startPluginOpts, + corePluginOpts, + resolvedStartConfig.root, + ) + } + return { startConfig, resolvedStartConfig, corePluginOpts } + } + + let resolvedServerEntryPath: string | undefined + let resolvedServerOutputDir: string | undefined + let resolvedClientOutputDir: string | undefined + let routeTreeModuleDeclaration: string | null = null + let routeTreeGeneratedPath: string | null = null + + return [ + { + name: 'tanstack-start-core:rsbuild-config', + setup(api) { + api.modifyRsbuildConfig((config: any) => { + const root = config.root || process.cwd() + resolvedStartConfig.root = root + + const { startConfig } = getConfig() + const assetPrefix = config.output?.assetPrefix ?? '/' + resolvedStartConfig.viteAppBase = assetPrefix + if (!isFullUrl(resolvedStartConfig.viteAppBase)) { + resolvedStartConfig.viteAppBase = joinPaths([ + '/', + resolvedStartConfig.viteAppBase, + '/', + ]) + } + + if (startConfig.router.basepath === undefined) { + if (!isFullUrl(resolvedStartConfig.viteAppBase)) { + startConfig.router.basepath = + resolvedStartConfig.viteAppBase.replace(/^\/|\/$/g, '') + } else { + startConfig.router.basepath = '/' + } + } + + const TSS_SERVER_FN_BASE = joinPaths([ + '/', + startConfig.router.basepath, + startConfig.serverFns.base, + '/', + ]) + + const resolvedSrcDirectory = path.join(root, startConfig.srcDirectory) + resolvedStartConfig.srcDirectory = resolvedSrcDirectory + + const startFilePath = resolveEntry({ + type: 'start entry', + configuredEntry: startConfig.start.entry, + defaultEntry: 'start', + resolvedSrcDirectory, + required: false, + }) + resolvedStartConfig.startFilePath = startFilePath + + const routerFilePath = resolveEntry({ + type: 'router entry', + configuredEntry: startConfig.router.entry, + defaultEntry: 'router', + resolvedSrcDirectory, + required: true, + }) + resolvedStartConfig.routerFilePath = routerFilePath + + const clientEntryPath = resolveEntry({ + type: 'client entry', + configuredEntry: startConfig.client.entry, + defaultEntry: 'client', + resolvedSrcDirectory, + required: false, + }) + + const serverEntryPath = resolveEntry({ + type: 'server entry', + configuredEntry: startConfig.server.entry, + defaultEntry: 'server', + resolvedSrcDirectory, + required: false, + }) + resolvedServerEntryPath = + serverEntryPath ?? corePluginOpts.defaultEntryPaths.server + + const entryAliasConfiguration: Record< + (typeof ENTRY_POINTS)[keyof typeof ENTRY_POINTS], + string + > = { + [ENTRY_POINTS.client]: + clientEntryPath ?? corePluginOpts.defaultEntryPaths.client, + [ENTRY_POINTS.server]: + serverEntryPath ?? corePluginOpts.defaultEntryPaths.server, + [ENTRY_POINTS.start]: + startFilePath ?? corePluginOpts.defaultEntryPaths.start, + [ENTRY_POINTS.router]: routerFilePath, + } + const resolvedClientEntry = entryAliasConfiguration[ENTRY_POINTS.client] + const resolvedServerEntry = entryAliasConfiguration[ENTRY_POINTS.server] + + const clientOutputDir = getOutputDirectory( + root, + config, + VITE_ENVIRONMENT_NAMES.client, + 'client', + ) + resolvedClientOutputDir = clientOutputDir + const serverOutputDir = getOutputDirectory( + root, + config, + VITE_ENVIRONMENT_NAMES.server, + 'server', + ) + resolvedServerOutputDir = serverOutputDir + const serverFnManifestTempPath = path.join( + serverOutputDir, + SERVER_FN_MANIFEST_TEMP_FILE, + ) + + const isDev = api.context?.command === 'serve' + const defineViteEnv = (key: string, fallback = '') => { + const value = process.env[key] ?? fallback + return defineReplaceEnv(key, value) + } + const defineValues = { + ...defineReplaceEnv('TSS_SERVER_FN_BASE', TSS_SERVER_FN_BASE), + ...defineReplaceEnv('TSS_CLIENT_OUTPUT_DIR', clientOutputDir), + ...defineReplaceEnv( + 'TSS_ROUTER_BASEPATH', + startConfig.router.basepath, + ), + ...defineReplaceEnv('TSS_BUNDLER', 'rsbuild'), + ...defineReplaceEnv('TSS_DEV_SERVER', isDev ? 'true' : 'false'), + ...(isDev + ? defineReplaceEnv( + 'TSS_SHELL', + startConfig.spa?.enabled ? 'true' : 'false', + ) + : {}), + ...defineViteEnv('VITE_NODE_ENV', 'production'), + ...defineViteEnv('VITE_EXTERNAL_PORT', ''), + } + + const routerPlugins = tanStackStartRouterRsbuild( + startPluginOpts, + getConfig, + corePluginOpts, + ) + const clientRouterConfig = { + ...startConfig.router, + routeTreeFileHeader: [], + routeTreeFileFooter: [], + plugins: [], + } + const generatedRouteTreePath = routerPlugins.getGeneratedRouteTreePath() + const routeTreeModuleDeclarationValue = buildRouteTreeModuleDeclaration({ + generatedRouteTreePath, + routerFilePath: resolvedStartConfig.routerFilePath, + startFilePath: resolvedStartConfig.startFilePath, + framework: corePluginOpts.framework, + }) + routeTreeModuleDeclaration = routeTreeModuleDeclarationValue + routeTreeGeneratedPath = generatedRouteTreePath + const registerDeclaration = `declare module '@tanstack/${corePluginOpts.framework}-start'` + if (fs.existsSync(generatedRouteTreePath)) { + const existingTree = fs.readFileSync( + generatedRouteTreePath, + 'utf-8', + ) + if (!existingTree.includes(registerDeclaration)) { + fs.rmSync(generatedRouteTreePath) + } + } + + const startCompilerLoaderPath = resolveLoaderPath( + './start-compiler-loader', + ) + const startStorageContextStubPath = resolveLoaderPath( + './start-storage-context-stub', + ) + const clientAliasOverrides = { + '@tanstack/start-storage-context': startStorageContextStubPath, + } + + const startClientCoreDistPath = path.resolve( + root, + 'packages/start-client-core/dist/esm', + ) + const startClientCoreDistPattern = + /[\\/]start-client-core[\\/]dist[\\/]esm[\\/]/ + const loaderIncludePaths: Array = [ + resolvedStartConfig.srcDirectory, + ] + if (fs.existsSync(startClientCoreDistPath)) { + loaderIncludePaths.push(startClientCoreDistPath) + } + loaderIncludePaths.push(startClientCoreDistPattern) + + const loaderRule = ( + env: 'client' | 'server', + envName: string, + manifestPath?: string, + ) => ({ + test: /\.[cm]?[jt]sx?$/, + include: loaderIncludePaths, + enforce: 'pre', + use: [ + { + loader: startCompilerLoaderPath, + options: { + env, + envName, + root, + framework: corePluginOpts.framework, + providerEnvName: serverFnProviderEnv, + generateFunctionId: startPluginOpts?.serverFns?.generateFunctionId, + manifestPath, + }, + }, + ], + }) + + const autoImportPlugins = toPluginArray(routerPlugins.autoImport) + + const clientEnvConfig = { + source: { + entry: { index: resolvedClientEntry }, + alias: { + ...entryAliasConfiguration, + ...clientAliasOverrides, + }, + define: defineValues, + }, + output: { + target: 'web', + distPath: { + root: path.relative(root, clientOutputDir), + }, + }, + tools: { + rspack: { + plugins: [ + routerPlugins.generatorPlugin, + routerPlugins.clientCodeSplitter, + ...autoImportPlugins, + createStartManifestRspackPlugin({ + basePath: resolvedStartConfig.viteAppBase, + clientOutputDir, + }), + ], + module: { + rules: [ + loaderRule('client', VITE_ENVIRONMENT_NAMES.client), + { + include: [routerPlugins.getGeneratedRouteTreePath()], + use: [ + { + loader: routerPlugins.routeTreeLoaderPath, + options: { + routerConfig: clientRouterConfig, + root, + }, + }, + ], + }, + ], + }, + resolve: { + alias: { + ...entryAliasConfiguration, + ...clientAliasOverrides, + }, + }, + }, + }, + } + + const serverEnvConfig = { + source: { + entry: { server: resolvedServerEntry }, + alias: entryAliasConfiguration, + define: defineValues, + }, + output: { + target: 'node', + distPath: { + root: path.relative(root, serverOutputDir), + }, + }, + tools: { + rspack: { + experiments: { + outputModule: true, + }, + output: { + module: true, + chunkFormat: 'module', + chunkLoading: 'import', + library: { + type: 'module', + }, + }, + plugins: [ + routerPlugins.generatorPlugin, + routerPlugins.serverCodeSplitter, + ...autoImportPlugins, + createServerFnResolverPlugin({ + environmentName: VITE_ENVIRONMENT_NAMES.server, + providerEnvName: serverFnProviderEnv, + serverOutputDir, + }), + createServerFnManifestRspackPlugin({ + serverOutputDir, + }), + createInjectedHeadScriptsPlugin(), + createStartManifestVirtualModulePlugin({ + clientOutputDir, + }), + ], + module: { + rules: [ + loaderRule( + 'server', + VITE_ENVIRONMENT_NAMES.server, + serverFnManifestTempPath, + ), + ], + }, + resolve: { + alias: entryAliasConfiguration, + }, + }, + }, + } + + const setupMiddlewares = ( + middlewares: Array, + context: { environments?: Record }, + ) => { + if (startConfig.vite?.installDevServerMiddleware === false) { + return + } + const serverEnv = context.environments?.[ + VITE_ENVIRONMENT_NAMES.server + ] + middlewares.push(async (req: any, res: any, next: any) => { + if (res.headersSent || res.writableEnded) { + return next() + } + if (!serverEnv?.loadBundle) { + return next() + } + try { + const serverBundle = await serverEnv.loadBundle() + const serverBuild = serverBundle?.default ?? serverBundle + if (!serverBuild?.fetch) { + return next() + } + req.url = joinURL(resolvedStartConfig.viteAppBase, req.url ?? '/') + const webReq = new NodeRequest({ req, res }) + const webRes = await serverBuild.fetch(webReq) + return sendNodeResponse(res, webRes) + } catch (error) { + return next(error) + } + }) + } + + const existingSetupMiddlewares = config.dev?.setupMiddlewares + const mergedSetupMiddlewares = ( + middlewares: Array, + context: { environments?: Record }, + ) => { + if (typeof existingSetupMiddlewares === 'function') { + existingSetupMiddlewares(middlewares, context) + } else if (Array.isArray(existingSetupMiddlewares)) { + existingSetupMiddlewares.forEach((fn: any) => + fn(middlewares, context), + ) + } + setupMiddlewares(middlewares, context) + } + + const nextConfig = { + ...config, + environments: { + ...config.environments, + [VITE_ENVIRONMENT_NAMES.client]: mergeEnvConfig( + config.environments?.[VITE_ENVIRONMENT_NAMES.client], + clientEnvConfig, + ), + [VITE_ENVIRONMENT_NAMES.server]: mergeEnvConfig( + config.environments?.[VITE_ENVIRONMENT_NAMES.server], + serverEnvConfig, + ), + }, + dev: { + ...config.dev, + setupMiddlewares: mergedSetupMiddlewares, + }, + } + + if (!ssrIsProvider) { + const providerOutputDir = getOutputDirectory( + root, + config, + serverFnProviderEnv, + serverFnProviderEnv, + ) + const providerManifestTempPath = path.join( + providerOutputDir, + SERVER_FN_MANIFEST_TEMP_FILE, + ) + nextConfig.environments = { + ...nextConfig.environments, + [serverFnProviderEnv]: mergeEnvConfig( + config.environments?.[serverFnProviderEnv], + { + source: { + entry: { provider: resolvedServerEntry }, + alias: entryAliasConfiguration, + define: defineValues, + }, + output: { + target: 'node', + distPath: { + root: path.relative(root, providerOutputDir), + }, + }, + tools: { + rspack: { + experiments: { + outputModule: true, + }, + output: { + module: true, + chunkFormat: 'module', + chunkLoading: 'import', + library: { + type: 'module', + }, + }, + plugins: [ + createServerFnResolverPlugin({ + environmentName: serverFnProviderEnv, + providerEnvName: serverFnProviderEnv, + serverOutputDir: providerOutputDir, + }), + createServerFnManifestRspackPlugin({ + serverOutputDir: providerOutputDir, + }), + createInjectedHeadScriptsPlugin(), + ], + module: { + rules: [ + loaderRule( + 'server', + serverFnProviderEnv, + providerManifestTempPath, + ), + ], + }, + resolve: { + alias: entryAliasConfiguration, + }, + }, + }, + }, + ), + } + } + + return nextConfig + }) + + api.onAfterStartProdServer?.(({ server }: { server: any }) => { + const serverOutputDir = resolvedServerOutputDir + if (!server?.middlewares?.use || !serverOutputDir) { + return + } + + let serverBuild: any = null + server.middlewares.use(async (req: any, res: any, next: any) => { + try { + if (res.headersSent || res.writableEnded) { + return next() + } + if (!resolvedServerEntryPath) { + return next() + } + + if (!serverBuild) { + const outputFilename = 'server.js' + const serverEntryPath = path.join( + serverOutputDir, + outputFilename, + ) + const imported = await import( + pathToFileURL(serverEntryPath).toString() + ) + serverBuild = imported.default ?? imported + } + + if (!serverBuild?.fetch) { + return next() + } + + req.url = joinURL(resolvedStartConfig.viteAppBase, req.url ?? '/') + + const webReq = new NodeRequest({ req, res }) + const webRes: Response = await serverBuild.fetch(webReq) + return sendNodeResponse(res, webRes) + } catch (error) { + next(error) + } + }) + }) + + api.onAfterBuild?.(async () => { + const { startConfig } = getConfig() + const clientOutputDir = resolvedClientOutputDir + const serverOutputDir = resolvedServerOutputDir + if (!clientOutputDir || !serverOutputDir) { + return + } + await postServerBuildRsbuild({ + startConfig, + clientOutputDir, + serverOutputDir, + }) + if (routeTreeGeneratedPath && routeTreeModuleDeclaration) { + if (fs.existsSync(routeTreeGeneratedPath)) { + const existingTree = fs.readFileSync( + routeTreeGeneratedPath, + 'utf-8', + ) + if (!existingTree.includes(routeTreeModuleDeclaration)) { + fs.appendFileSync( + routeTreeGeneratedPath, + `\n\n${routeTreeModuleDeclaration}\n`, + ) + } + } + } + }) + }, + }, + ] +} diff --git a/packages/start-plugin-core/src/rsbuild/post-server-build.ts b/packages/start-plugin-core/src/rsbuild/post-server-build.ts new file mode 100644 index 00000000000..5aa1844a296 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/post-server-build.ts @@ -0,0 +1,68 @@ +import { HEADERS } from '@tanstack/start-server-core' +import path from 'pathe' +import { buildSitemap } from '../build-sitemap' +import { prerender } from './prerender' +import type { TanStackStartOutputConfig } from '../schema' + +export async function postServerBuildRsbuild({ + startConfig, + clientOutputDir, + serverOutputDir, +}: { + startConfig: TanStackStartOutputConfig + clientOutputDir: string + serverOutputDir: string +}) { + if (startConfig.prerender?.enabled !== false) { + startConfig.prerender = { + ...startConfig.prerender, + enabled: + startConfig.prerender?.enabled ?? + startConfig.pages.some((d) => + typeof d === 'string' ? false : !!d.prerender?.enabled, + ), + } + } + + if (startConfig.spa?.enabled) { + startConfig.prerender = { + ...startConfig.prerender, + enabled: true, + } + + const maskUrl = new URL(startConfig.spa.maskPath, 'http://localhost') + if (maskUrl.origin !== 'http://localhost') { + throw new Error('spa.maskPath must be a path (no protocol/host)') + } + + startConfig.pages.push({ + path: maskUrl.toString().replace('http://localhost', ''), + prerender: { + ...startConfig.spa.prerender, + headers: { + ...startConfig.spa.prerender.headers, + [HEADERS.TSS_SHELL]: 'true', + }, + }, + sitemap: { + exclude: true, + }, + }) + } + + if (startConfig.prerender.enabled) { + const serverEntryPath = path.join(serverOutputDir, 'server.js') + await prerender({ + startConfig, + clientOutputDir, + serverEntryPath, + }) + } + + if (startConfig.sitemap?.enabled) { + buildSitemap({ + startConfig, + publicDir: clientOutputDir, + }) + } +} diff --git a/packages/start-plugin-core/src/rsbuild/prerender.ts b/packages/start-plugin-core/src/rsbuild/prerender.ts new file mode 100644 index 00000000000..0669c90e9db --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/prerender.ts @@ -0,0 +1,277 @@ +import { pathToFileURL } from 'node:url' +import { promises as fsp } from 'node:fs' +import os from 'node:os' + +import path from 'pathe' +import { joinURL, withBase, withTrailingSlash, withoutBase } from 'ufo' +import { Queue } from '../queue' +import { createLogger } from '../utils' +import type { Page, TanStackStartOutputConfig } from '../schema' + +export async function prerender({ + startConfig, + clientOutputDir, + serverEntryPath, +}: { + startConfig: TanStackStartOutputConfig + clientOutputDir: string + serverEntryPath: string +}) { + const logger = createLogger('prerender') + logger.info('Prerendering pages...') + + if (startConfig.prerender?.enabled) { + let pages = startConfig.pages.length ? startConfig.pages : [{ path: '/' }] + + if (startConfig.prerender.autoStaticPathsDiscovery ?? true) { + const pagesMap = new Map(pages.map((item) => [item.path, item])) + const discoveredPages = globalThis.TSS_PRERENDABLE_PATHS || [] + + for (const page of discoveredPages) { + if (!pagesMap.has(page.path)) { + pagesMap.set(page.path, page) + } + } + + pages = Array.from(pagesMap.values()) + } + + startConfig.pages = pages + } + + const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') + const routerBaseUrl = new URL(routerBasePath, 'http://localhost') + + startConfig.pages = validateAndNormalizePrerenderPages( + startConfig.pages, + routerBaseUrl, + ) + + process.env.TSS_PRERENDERING = 'true' + + const serverBuild = await import(pathToFileURL(serverEntryPath).toString()) + const fetchHandler = serverBuild.default?.fetch ?? serverBuild.fetch + if (!fetchHandler) { + throw new Error('Server build does not export a fetch handler') + } + + const baseUrl = new URL('http://localhost') + + const isRedirectResponse = (res: Response) => { + return res.status >= 300 && res.status < 400 && res.headers.get('location') + } + + async function localFetch( + path: string, + options?: RequestInit, + maxRedirects: number = 5, + ): Promise { + const url = new URL(path, baseUrl) + const request = new Request(url, options) + const response = await fetchHandler(request) + + if (isRedirectResponse(response) && maxRedirects > 0) { + const location = response.headers.get('location')! + if (location.startsWith('http://localhost') || location.startsWith('/')) { + const newUrl = location.replace('http://localhost', '') + return localFetch(newUrl, options, maxRedirects - 1) + } else { + logger.warn(`Skipping redirect to external location: ${location}`) + } + } + + return response + } + + try { + const pages = await prerenderPages({ outputDir: clientOutputDir }) + + logger.info(`Prerendered ${pages.length} pages:`) + pages.forEach((page) => { + logger.info(`- ${page}`) + }) + } catch (error) { + logger.error(error) + } + + function extractLinks(html: string): Array { + const linkRegex = /]+href=["']([^"']+)["'][^>]*>/g + const links: Array = [] + let match + + while ((match = linkRegex.exec(html)) !== null) { + const href = match[1] + if (href && (href.startsWith('/') || href.startsWith('./'))) { + links.push(href) + } + } + + return links + } + + async function prerenderPages({ outputDir }: { outputDir: string }) { + const seen = new Set() + const prerendered = new Set() + const retriesByPath = new Map() + const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length + logger.info(`Concurrency: ${concurrency}`) + const queue = new Queue({ concurrency }) + const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') + + const routerBaseUrl = new URL(routerBasePath, 'http://localhost') + startConfig.pages = validateAndNormalizePrerenderPages( + startConfig.pages, + routerBaseUrl, + ) + + startConfig.pages.forEach((page) => addCrawlPageTask(page)) + + await queue.start() + + return Array.from(prerendered) + + function addCrawlPageTask(page: Page) { + if (seen.has(page.path)) return + + seen.add(page.path) + + if (page.fromCrawl) { + startConfig.pages.push(page) + } + + if (!(page.prerender?.enabled ?? true)) return + + if (startConfig.prerender?.filter && !startConfig.prerender.filter(page)) + return + + const prerenderOptions = { + ...startConfig.prerender, + ...page.prerender, + } + + queue.add(async () => { + logger.info(`Crawling: ${page.path}`) + const retries = retriesByPath.get(page.path) || 0 + try { + const res = await localFetch( + withTrailingSlash(withBase(page.path, routerBasePath)), + { + headers: { + ...(prerenderOptions.headers ?? {}), + }, + }, + prerenderOptions.maxRedirects, + ) + + if (!res.ok) { + if (isRedirectResponse(res)) { + logger.warn(`Max redirects reached for ${page.path}`) + } + throw new Error(`Failed to fetch ${page.path}: ${res.statusText}`, { + cause: res, + }) + } + + const cleanPagePath = ( + prerenderOptions.outputPath || page.path + ).split(/[?#]/)[0]! + + const contentType = res.headers.get('content-type') || '' + const isImplicitHTML = + !cleanPagePath.endsWith('.html') && contentType.includes('html') + + const routeWithIndex = cleanPagePath.endsWith('/') + ? cleanPagePath + 'index' + : cleanPagePath + + const isSpaShell = + startConfig.spa?.prerender.outputPath === cleanPagePath + + let htmlPath: string + if (isSpaShell) { + htmlPath = cleanPagePath + '.html' + } else { + if ( + cleanPagePath.endsWith('/') || + (prerenderOptions.autoSubfolderIndex ?? true) + ) { + htmlPath = joinURL(cleanPagePath, 'index.html') + } else { + htmlPath = cleanPagePath + '.html' + } + } + + const filename = withoutBase( + isImplicitHTML ? htmlPath : routeWithIndex, + routerBasePath, + ) + + const html = await res.text() + + const filepath = path.join(outputDir, filename) + + await fsp.mkdir(path.dirname(filepath), { + recursive: true, + }) + + await fsp.writeFile(filepath, html) + + prerendered.add(page.path) + + const newPage = await prerenderOptions.onSuccess?.({ page, html }) + + if (newPage) { + Object.assign(page, newPage) + } + + if (prerenderOptions.crawlLinks ?? true) { + const links = extractLinks(html) + for (const link of links) { + addCrawlPageTask({ path: link, fromCrawl: true }) + } + } + } catch (error) { + if (retries < (prerenderOptions.retryCount ?? 0)) { + logger.warn(`Encountered error, retrying: ${page.path} in 500ms`) + await new Promise((resolve) => + setTimeout(resolve, prerenderOptions.retryDelay), + ) + retriesByPath.set(page.path, retries + 1) + addCrawlPageTask(page) + } else { + if (prerenderOptions.failOnError ?? true) { + throw error + } + } + } + }) + } + } +} + +function validateAndNormalizePrerenderPages( + pages: Array, + routerBaseUrl: URL, +): Array { + return pages.map((page) => { + let url: URL + try { + url = new URL(page.path, routerBaseUrl) + } catch (err) { + throw new Error(`prerender page path must be relative: ${page.path}`, { + cause: err, + }) + } + + if (url.origin !== 'http://localhost') { + throw new Error(`prerender page path must be relative: ${page.path}`) + } + + const decodedPathname = decodeURIComponent(url.pathname) + + return { + ...page, + path: decodedPathname + url.search + url.hash, + } + }) +} diff --git a/packages/start-plugin-core/src/rsbuild/route-tree-loader.ts b/packages/start-plugin-core/src/rsbuild/route-tree-loader.ts new file mode 100644 index 00000000000..36ad41d2682 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/route-tree-loader.ts @@ -0,0 +1,15 @@ +import { getClientRouteTreeContent } from './route-tree-state' + +export default function routeTreeLoader(this: any) { + const callback = this.async() + const options = this.getOptions() as { + routerConfig?: any + root?: string + } + getClientRouteTreeContent({ + routerConfig: options.routerConfig, + root: options.root, + }) + .then((code) => callback(null, code)) + .catch((error) => callback(error)) +} diff --git a/packages/start-plugin-core/src/rsbuild/route-tree-state.ts b/packages/start-plugin-core/src/rsbuild/route-tree-state.ts new file mode 100644 index 00000000000..18a09e5042e --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/route-tree-state.ts @@ -0,0 +1,46 @@ +import { Generator } from '@tanstack/router-generator' +import { pruneServerOnlySubtrees } from '../start-router-plugin/pruneServerOnlySubtrees' +import type { Config } from '@tanstack/router-generator' + +let generatorInstance: Generator | null = null + +export function setGeneratorInstance(generator: Generator) { + generatorInstance = generator +} + +export async function getClientRouteTreeContent(options?: { + routerConfig?: Config + root?: string +}) { + let generator = generatorInstance + if (!generator) { + if (!options?.routerConfig || !options.root) { + throw new Error('Generator instance not initialized for route tree loader') + } + generator = new Generator({ + config: options.routerConfig, + root: options.root, + }) + await generator.run() + } + const crawlingResult = await generator.getCrawlingResult() + if (!crawlingResult) { + throw new Error('Crawling result not available') + } + const prunedAcc = pruneServerOnlySubtrees(crawlingResult) + const acc = { + ...crawlingResult.acc, + ...prunedAcc, + } + const buildResult = generator.buildRouteTree({ + ...crawlingResult, + acc, + config: { + disableTypes: true, + enableRouteTreeFormatting: false, + routeTreeFileHeader: [], + routeTreeFileFooter: [], + }, + }) + return buildResult.routeTreeContent +} diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts new file mode 100644 index 00000000000..f2c442755ec --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts @@ -0,0 +1,257 @@ +import fs, { promises as fsp } from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { + KindDetectionPatterns, + LookupKindsPerEnv, + StartCompiler, + detectKindsInCode, +} from '../start-compiler-plugin/compiler' +import { cleanId } from '../start-compiler-plugin/utils' +import type { LookupConfig } from '../start-compiler-plugin/compiler' +import type { CompileStartFrameworkOptions } from '../types' +import type { + GenerateFunctionIdFnOptional, + ServerFn, +} from '../start-compiler-plugin/types' + +type LoaderOptions = { + env: 'client' | 'server' + envName: string + root: string + framework: CompileStartFrameworkOptions + providerEnvName: string + generateFunctionId?: GenerateFunctionIdFnOptional + manifestPath?: string +} + +const compilers = new Map() +const serverFnsById: Record = {} +const require = createRequire(import.meta.url) +const appendServerFnsToManifest = ( + manifestPath: string, + data: Record, +) => { + if (!manifestPath || Object.keys(data).length === 0) return + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }) + fs.appendFileSync(manifestPath, `${JSON.stringify(data)}\n`) +} + +export const getServerFnsById = () => serverFnsById + +// Derive transform code filter from KindDetectionPatterns (single source of truth) +function getTransformCodeFilterForEnv(env: 'client' | 'server'): Array { + const validKinds = LookupKindsPerEnv[env] + const patterns: Array = [] + for (const [kind, pattern] of Object.entries(KindDetectionPatterns) as Array< + [keyof typeof KindDetectionPatterns, RegExp] + >) { + if (validKinds.has(kind)) { + patterns.push(pattern) + } + } + return patterns +} + +const getLookupConfigurationsForEnv = ( + env: 'client' | 'server', + framework: CompileStartFrameworkOptions, +): Array => { + const commonConfigs: Array = [ + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createServerFn', + kind: 'Root', + }, + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createIsomorphicFn', + kind: 'Root', + }, + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createServerOnlyFn', + kind: 'Root', + }, + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createClientOnlyFn', + kind: 'Root', + }, + ] + + if (env === 'client') { + return [ + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createMiddleware', + kind: 'Root', + }, + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createStart', + kind: 'Root', + }, + ...commonConfigs, + ] + } + + return [ + ...commonConfigs, + { + libName: `@tanstack/${framework}-router`, + rootExport: 'ClientOnly', + kind: 'ClientOnlyJSX', + }, + ] +} + +function shouldTransformCode(code: string, env: 'client' | 'server') { + const patterns = getTransformCodeFilterForEnv(env) + return patterns.some((pattern) => pattern.test(code)) +} + +async function resolveId( + loaderContext: any, + source: string, + importer?: string, +): Promise { + const baseContext = importer + ? path.dirname(cleanId(importer)) + : loaderContext.context + const resolveContext = + source.startsWith('.') || source.startsWith('/') + ? baseContext + : loaderContext.rootContext || baseContext + + return new Promise((resolve) => { + const resolver = + loaderContext.getResolve?.({ + conditionNames: ['import', 'module', 'default'], + }) ?? loaderContext.resolve + + resolver( + resolveContext, + source, + (err: Error | null, result?: string) => { + if (!err && result) { + resolve(cleanId(result)) + return + } + try { + const resolved = require.resolve(source, { + paths: [ + baseContext, + loaderContext.rootContext || loaderContext.context, + ].filter(Boolean), + }) + resolve(cleanId(resolved)) + } catch { + resolve(null) + } + }, + ) + }) +} + +async function loadModule( + compiler: StartCompiler, + loaderContext: any, + id: string, +) { + const cleaned = cleanId(id) + const resolvedPath = + cleaned.startsWith('.') || cleaned.startsWith('/') + ? cleaned + : (await resolveId(loaderContext, cleaned)) ?? cleaned + + if (resolvedPath.includes('\0')) return + + try { + const code = await fsp.readFile(resolvedPath, 'utf-8') + compiler.ingestModule({ code, id: resolvedPath }) + } catch { + // ignore missing files + } +} + +export default function startCompilerLoader( + this: any, + code: string, + map: any, +) { + const callback = this.async() + const options = this.getOptions() as LoaderOptions + + const env = options.env + const envName = options.envName + const root = options.root || process.cwd() + const framework = options.framework + const providerEnvName = options.providerEnvName + const manifestPath = options.manifestPath + + const shouldTransform = shouldTransformCode(code, env) + if (!shouldTransform) { + callback(null, code, map) + return + } + + let compiler = compilers.get(envName) + if (!compiler) { + const mode = + this.mode === 'production' || this._compiler?.options?.mode === 'production' + ? 'build' + : 'dev' + const shouldPersistManifest = Boolean(manifestPath) && mode === 'build' + const onServerFnsById = (d: Record) => { + Object.assign(serverFnsById, d) + if (shouldPersistManifest && manifestPath) { + appendServerFnsToManifest(manifestPath, d) + } + } + + compiler = new StartCompiler({ + env, + envName, + root, + lookupKinds: LookupKindsPerEnv[env], + lookupConfigurations: getLookupConfigurationsForEnv(env, framework), + mode, + framework, + providerEnvName, + generateFunctionId: options.generateFunctionId, + onServerFnsById, + getKnownServerFns: () => serverFnsById, + loadModule: async (id: string) => loadModule(compiler!, this, id), + resolveId: async (source: string, importer?: string) => + resolveId(this, source, importer), + }) + compilers.set(envName, compiler) + } + + const detectedKinds = detectKindsInCode(code, env) + const resourceQuery = + typeof this.resourceQuery === 'string' ? this.resourceQuery : '' + const baseResource = + typeof this.resource === 'string' ? this.resource : this.resourcePath + const resourceId = + resourceQuery && !baseResource.includes(resourceQuery) + ? `${baseResource}${resourceQuery}` + : baseResource + compiler + .compile({ + id: resourceId, + code, + detectedKinds, + }) + .then((result) => { + if (!result) { + callback(null, code, map) + return + } + callback(null, result.code, result.map ?? map) + }) + .catch((error) => { + callback(error) + }) +} diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts new file mode 100644 index 00000000000..20d773acb06 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts @@ -0,0 +1,679 @@ +import { promises as fsp } from 'node:fs' +import path from 'node:path' +import { createRspackPlugin } from 'unplugin' +import { VIRTUAL_MODULES } from '@tanstack/start-server-core' +import { VITE_ENVIRONMENT_NAMES } from '../constants' +import { getServerFnsById } from './start-compiler-loader' +import type { ServerFn } from '../start-compiler-plugin/types' + +const SERVER_FN_MANIFEST_FILE = 'tanstack-start-server-fn-manifest.json' +export const SERVER_FN_MANIFEST_TEMP_FILE = + 'tanstack-start-server-fn-manifest.temp.jsonl' + +const readTempManifest = async (manifestPath: string) => { + try { + const raw = await fsp.readFile(manifestPath, 'utf-8') + return raw + .split('\n') + .filter(Boolean) + .reduce>((acc, line) => { + try { + const parsed = JSON.parse(line) as Record + Object.assign(acc, parsed) + } catch { + return acc + } + return acc + }, {}) + } catch { + return {} + } +} + +const normalizeIdentifier = (value: unknown) => + `${value ?? ''}`.replace(/\\/g, '/') + +const isJsFile = (file: unknown): file is string => + typeof file === 'string' && file.endsWith('.js') + +type CompilationModule = { + id?: string | number + [key: string]: any +} + +const getChunkFiles = (chunk: any) => { + const files = [ + ...Array.from(chunk?.files ?? []), + ...Array.from(chunk?.auxiliaryFiles ?? []), + ] + return files.filter((file) => typeof file === 'string') +} + +const getModuleIdentifiers = (module: any) => + [ + module.identifier, + module.name, + module.nameForCondition, + module.resource, + module.moduleIdentifier, + ] + .filter(Boolean) + .map((value) => normalizeIdentifier(value)) + +const getCompilationModuleIdentifiers = (module: any) => + [ + module.resource, + module.userRequest, + module.request, + typeof module.identifier === 'function' ? module.identifier() : module.identifier, + module.debugId, + ] + .filter(Boolean) + .map((value) => normalizeIdentifier(value)) + +function generateManifestModule( + serverFnsById: Record, + includeClientReferencedCheck: boolean, +): string { + const manifestEntries = Object.entries(serverFnsById) + .map(([id, fn]) => { + const baseEntry = `'${id}': { + functionName: '${fn.functionName}', + importer: () => import(${JSON.stringify(fn.extractedFilename)})${ + includeClientReferencedCheck + ? `, + isClientReferenced: ${fn.isClientReferenced ?? true}` + : '' + } + }` + return baseEntry + }) + .join(',') + + const getServerFnByIdParams = includeClientReferencedCheck ? 'id, opts' : 'id' + const clientReferencedCheck = includeClientReferencedCheck + ? ` + if (opts?.fromClient && !serverFnInfo.isClientReferenced) { + throw new Error('Server function not accessible from client: ' + id) + } +` + : '' + + return ` + const manifest = {${manifestEntries}} + + export async function getServerFnById(${getServerFnByIdParams}) { + const serverFnInfo = manifest[id] + if (!serverFnInfo) { + throw new Error('Server function info not found for ' + id) + } +${clientReferencedCheck} + const fnModule = await serverFnInfo.importer() + + if (!fnModule) { + console.info('serverFnInfo', serverFnInfo) + throw new Error('Server function module not resolved for ' + id) + } + + let action = fnModule[serverFnInfo.functionName] + if (action?.serverFnMeta?.id && action.serverFnMeta.id !== id) { + action = undefined + } + if (!action) { + const fallbackAction = Object.values(fnModule).find( + (candidate) => + candidate?.serverFnMeta?.id && + candidate.serverFnMeta.id === id, + ) + if (fallbackAction) { + action = fallbackAction + } + } + if (Array.isArray(globalThis.__tssServerFnHandlers)) { + const globalMatch = globalThis.__tssServerFnHandlers.find( + (candidate) => + candidate?.serverFnMeta?.id && + candidate.serverFnMeta.id === id, + ) + if (globalMatch && (!action || action.__executeServer)) { + action = globalMatch + } + } + + if (!action) { + console.info('serverFnInfo', serverFnInfo) + console.info('fnModule', fnModule) + + throw new Error( + \`Server function module export not resolved for serverFn ID: \${id}\`, + ) + } + return action + } + ` +} + +function generateManifestModuleFromFile( + manifestPath: string, + includeClientReferencedCheck: boolean, +): string { + const getServerFnByIdParams = includeClientReferencedCheck ? 'id, opts' : 'id' + const clientReferencedCheck = includeClientReferencedCheck + ? ` + if (opts?.fromClient && !serverFnInfo.isClientReferenced) { + throw new Error('Server function not accessible from client: ' + id) + } +` + : '' + + return ` + import fs from 'node:fs' + import { pathToFileURL } from 'node:url' + + let cached + const getManifest = () => { + if (cached) return cached + try { + const raw = fs.readFileSync(${JSON.stringify(manifestPath)}, 'utf-8') + cached = JSON.parse(raw) + return cached + } catch (error) { + cached = {} + return cached + } + } + + export async function getServerFnById(${getServerFnByIdParams}) { + const manifest = getManifest() + const serverFnInfo = manifest[id] + if (!serverFnInfo) { + throw new Error('Server function info not found for ' + id) + } +${clientReferencedCheck} + let fnModule + if (typeof __webpack_require__ === 'function' && serverFnInfo.importerModuleId != null) { + const chunkIds = Array.isArray(serverFnInfo.importerChunkIds) + ? serverFnInfo.importerChunkIds + : [] + if (chunkIds.length > 0 && typeof __webpack_require__.e === 'function') { + await Promise.all(chunkIds.map((chunkId) => __webpack_require__.e(chunkId))) + } + fnModule = __webpack_require__(serverFnInfo.importerModuleId) + } else { + const importerPath = serverFnInfo.importerPath ?? serverFnInfo.extractedFilename + fnModule = await import(/* webpackIgnore: true */ pathToFileURL(importerPath).href) + } + + if (!fnModule) { + console.info('serverFnInfo', serverFnInfo) + throw new Error('Server function module not resolved for ' + id) + } + + let action = fnModule[serverFnInfo.functionName] + if (action?.serverFnMeta?.id && action.serverFnMeta.id !== id) { + action = undefined + } + if (!action) { + const fallbackAction = Object.values(fnModule).find( + (candidate) => + candidate?.serverFnMeta?.id && + candidate.serverFnMeta.id === id, + ) + if (fallbackAction) { + action = fallbackAction + } + } + if (Array.isArray(globalThis.__tssServerFnHandlers)) { + const globalMatch = globalThis.__tssServerFnHandlers.find( + (candidate) => + candidate?.serverFnMeta?.id && + candidate.serverFnMeta.id === id, + ) + if (globalMatch && (!action || action.__executeServer)) { + action = globalMatch + } + } + + if (!action) { + console.info('serverFnInfo', serverFnInfo) + console.info('fnModule', fnModule) + + throw new Error( + \`Server function module export not resolved for serverFn ID: \${id}\`, + ) + } + return action + } + ` +} + +export function createServerFnManifestRspackPlugin(opts: { + serverOutputDir: string +}) { + const tempManifestPath = path.join( + opts.serverOutputDir, + SERVER_FN_MANIFEST_TEMP_FILE, + ) + + return { + apply(compiler: any) { + compiler.hooks.beforeRun.tapPromise( + 'tanstack-start:server-fn-manifest', + async () => { + await fsp.rm(tempManifestPath, { force: true }) + }, + ) + compiler.hooks.afterEmit.tapPromise( + 'tanstack-start:server-fn-manifest', + async (compilation: any) => { + const serverFnsById = getServerFnsById() + const fileServerFnsById = await readTempManifest(tempManifestPath) + const mergedServerFnsById = { + ...serverFnsById, + ...fileServerFnsById, + } + const stats = compilation?.getStats?.() + const statsJson = stats?.toJson?.({ + all: false, + assets: true, + chunks: true, + chunkModules: true, + moduleAssets: true, + modules: true, + }) + const chunks = statsJson?.chunks ?? [] + const chunksById = new Map( + chunks.map((chunk: any) => [chunk.id, getChunkFiles(chunk)]), + ) + const chunkModuleEntries = chunks.flatMap((chunk: any) => { + const chunkFiles = getChunkFiles(chunk) + const chunkModules = chunk.modules ?? [] + return chunkModules.flatMap((module: any) => + getModuleIdentifiers(module).map((identifier) => ({ + identifier, + files: chunkFiles, + })), + ) + }) + const modules = statsJson?.modules ?? [] + const compilationModules: Array = Array.from( + compilation?.modules ?? [], + ) + const chunkGraph = compilation?.chunkGraph + const moduleGraph = compilation?.moduleGraph + const compilationEntries = compilationModules.flatMap((module: any) => { + const identifiers = getCompilationModuleIdentifiers(module) + if (identifiers.length === 0) return [] + const chunkFiles = chunkGraph + ? Array.from(chunkGraph.getModuleChunksIterable(module) ?? []).flatMap( + (chunk: any) => getChunkFiles(chunk), + ) + : [] + return identifiers.map((identifier) => ({ + identifier, + files: chunkFiles, + })) + }) + const assetFiles = (statsJson?.assets ?? []) + .map((asset: any) => asset.name ?? asset) + .filter((name: string) => typeof name === 'string') + .filter((name: string) => name.endsWith('.js')) + const getAssetContent = async (assetName: string) => { + const assetFromCompilation = + (typeof compilation?.getAsset === 'function' + ? compilation.getAsset(assetName)?.source + : undefined) ?? + compilation?.assets?.[assetName] ?? + (typeof compilation?.assets?.get === 'function' + ? compilation.assets.get(assetName) + : undefined) + const sourceValue = + assetFromCompilation && typeof assetFromCompilation.source === 'function' + ? assetFromCompilation.source() + : assetFromCompilation + if (typeof sourceValue === 'string') return sourceValue + if (Buffer.isBuffer(sourceValue)) { + return sourceValue.toString('utf-8') + } + if (sourceValue && typeof sourceValue.toString === 'function') { + return sourceValue.toString() + } + try { + const assetPath = path.join(opts.serverOutputDir, assetName) + return await fsp.readFile(assetPath, 'utf-8') + } catch { + return undefined + } + } + const findAssetMatch = async (searchTokens: Array) => { + for (const assetName of assetFiles) { + const content = await getAssetContent(assetName) + if (!content) continue + if (searchTokens.some((token) => content.includes(token))) { + return assetName + } + } + return undefined + } + const manifestWithImporters: Record = {} + for (const [id, info] of Object.entries(mergedServerFnsById)) { + const normalizedExtracted = info.extractedFilename.replace(/\\/g, '/') + const normalizedFilename = info.filename.replace(/\\/g, '/') + const searchTokens = [ + normalizedExtracted, + normalizedFilename, + path.basename(normalizedExtracted), + path.basename(normalizedFilename), + id, + info.functionName, + ].filter(Boolean) + const matchedModuleByExtracted = modules.find((module: any) => + getModuleIdentifiers(module).some((identifier) => + identifier.includes(normalizedExtracted), + ), + ) + const matchedModuleByFilename = modules.find((module: any) => + getModuleIdentifiers(module).some((identifier) => + identifier.includes(normalizedFilename), + ), + ) + const matchedModule = matchedModuleByExtracted ?? matchedModuleByFilename + const chunkIds = matchedModule?.chunks ?? matchedModule?.chunkIds ?? [] + const statsModuleId = matchedModule?.id ?? matchedModule?.moduleId + const chunkFiles = chunkIds.flatMap((chunkId: any) => { + return chunksById.get(chunkId) ?? [] + }) + const moduleAssets = Array.isArray(matchedModule?.assets) + ? matchedModule.assets + : matchedModule?.assets + ? Object.keys(matchedModule.assets) + : [] + const matchedCompilationModuleByExtracted = compilationModules.find( + (module) => + getCompilationModuleIdentifiers(module).some((identifier) => + identifier.includes(normalizedExtracted), + ), + ) + const matchedCompilationModuleByFilename = compilationModules.find( + (module) => + getCompilationModuleIdentifiers(module).some((identifier) => + identifier.includes(normalizedFilename), + ), + ) + const matchedCompilationModule = + matchedCompilationModuleByExtracted ?? matchedCompilationModuleByFilename + const exportsInfo = matchedCompilationModule + ? moduleGraph?.getExportsInfo?.(matchedCompilationModule) + : null + const providedExports = + exportsInfo?.getProvidedExports?.() ?? + (Array.isArray(exportsInfo?.exports) + ? exportsInfo.exports + .map((exportInfo: any) => exportInfo.name) + .filter(Boolean) + : []) + const resolvedFunctionName = + Array.isArray(providedExports) && providedExports.length === 1 + ? providedExports[0] + : undefined + const compilationChunkIds = + matchedCompilationModule && chunkGraph + ? Array.from(chunkGraph.getModuleChunksIterable(matchedCompilationModule)) + .map((chunk: any) => chunk.id) + .filter((chunkId: any) => chunkId !== undefined && chunkId !== null) + : [] + const compilationModuleId = + matchedCompilationModule?.id ?? + (matchedCompilationModule && chunkGraph?.getModuleId + ? chunkGraph.getModuleId(matchedCompilationModule) + : undefined) + const compilationFiles = + compilationEntries.find((entry) => { + return entry.identifier.includes(normalizedExtracted) + })?.files ?? + compilationEntries.find((entry) => { + return entry.identifier.includes(normalizedFilename) + })?.files ?? + [] + const chunkModuleFiles = + chunkFiles.length > 0 + ? chunkFiles + : (chunkModuleEntries.find((entry: any) => { + return ( + entry.identifier.includes(normalizedExtracted) || + entry.identifier.includes(normalizedFilename) + ) + })?.files ?? []) + const jsFile = chunkFiles.find(isJsFile) + const fallbackJsFile = + jsFile ?? + chunkModuleFiles.find(isJsFile) ?? + compilationFiles.find(isJsFile) ?? + moduleAssets.find(isJsFile) + let importerPath = jsFile + ? path.join(opts.serverOutputDir, jsFile) + : fallbackJsFile + ? path.join(opts.serverOutputDir, fallbackJsFile) + : undefined + if (!importerPath) { + const assetMatch = await findAssetMatch(searchTokens) + importerPath = assetMatch + ? path.join(opts.serverOutputDir, assetMatch) + : undefined + } + + manifestWithImporters[id] = { + ...info, + functionName: resolvedFunctionName ?? info.functionName, + importerPath, + importerChunkIds: + chunkIds.length > 0 + ? chunkIds + : compilationChunkIds.length > 0 + ? compilationChunkIds + : undefined, + importerModuleId: statsModuleId ?? (compilationModuleId ?? undefined), + } + } + const extractExportName = ( + content: string, + moduleId: string | number, + functionId: string, + ) => { + const marker = `${moduleId}:function` + const startIndex = content.indexOf(marker) + const scope = + startIndex === -1 + ? content + : content.slice(startIndex, startIndex + 4000) + const idIndex = scope.indexOf(functionId) + if (idIndex === -1) return undefined + const beforeId = scope.slice(Math.max(0, idIndex - 300), idIndex) + const assignmentMatches = Array.from( + beforeId.matchAll(/([A-Za-z_$][\w$]*)=(?!>)/g), + ) + const handlerVar = + assignmentMatches[assignmentMatches.length - 1]?.[1] + if (!handlerVar) return undefined + const exportMatch = scope.match( + new RegExp(`([A-Za-z_$][\\\\w$]*):\\\\(\\\\)=>${handlerVar}`), + ) + return exportMatch?.[1] + } + const findExportName = async ( + moduleId: string | number, + functionId: string, + preferredAssetName?: string, + ) => { + if (preferredAssetName) { + const content = await getAssetContent(preferredAssetName) + const resolved = content + ? extractExportName(content, moduleId, functionId) + : undefined + if (resolved) return resolved + } + for (const assetName of assetFiles) { + if (assetName === preferredAssetName) continue + const content = await getAssetContent(assetName) + if (!content) continue + const resolved = extractExportName(content, moduleId, functionId) + if (resolved) return resolved + } + return undefined + } + const extractModuleIdFromContent = ( + content: string, + functionId: string, + ) => { + const idIndex = content.indexOf(functionId) + if (idIndex === -1) return undefined + const beforeId = content.slice(Math.max(0, idIndex - 1500), idIndex) + const matches = Array.from( + beforeId.matchAll( + /(?:^|[,{])\s*([0-9]+)\s*:\s*(?:function|\()/g, + ), + ) + const moduleId = matches[matches.length - 1]?.[1] + if (!moduleId) return undefined + return Number.isNaN(Number(moduleId)) ? moduleId : Number(moduleId) + } + const parseChunkIdFromAssetName = (assetName: string) => { + const base = path.basename(assetName, path.extname(assetName)) + if (!base) return undefined + return /^\d+$/.test(base) ? Number(base) : undefined + } + const findModuleIdByFunctionId = async ( + functionId: string, + preferredAssetName?: string, + ) => { + if (preferredAssetName) { + const content = await getAssetContent(preferredAssetName) + if (content) { + const moduleId = extractModuleIdFromContent(content, functionId) + if (moduleId !== undefined) { + return { + moduleId, + assetName: preferredAssetName, + } + } + } + } + for (const assetName of assetFiles) { + if (assetName === preferredAssetName) continue + const content = await getAssetContent(assetName) + if (!content) continue + const moduleId = extractModuleIdFromContent(content, functionId) + if (moduleId !== undefined) { + return { moduleId, assetName } + } + } + return undefined + } + for (const info of Object.values(manifestWithImporters)) { + const importerAssetName = info.importerPath + ? path + .relative(opts.serverOutputDir, info.importerPath) + .replace(/\\/g, '/') + : undefined + if (info.importerModuleId == null) { + const moduleMatch = await findModuleIdByFunctionId( + info.functionId, + importerAssetName, + ) + if (moduleMatch) { + info.importerModuleId = moduleMatch.moduleId + if (!info.importerPath) { + info.importerPath = path.join( + opts.serverOutputDir, + moduleMatch.assetName, + ) + } + if (!info.importerChunkIds) { + const chunkId = parseChunkIdFromAssetName( + moduleMatch.assetName, + ) + if (chunkId !== undefined) { + info.importerChunkIds = [chunkId] + } + } + } + } + if (info.importerModuleId == null) continue + const resolvedName = await findExportName( + info.importerModuleId, + info.functionId, + importerAssetName, + ) + if (resolvedName) { + info.functionName = resolvedName + } + } + const manifestPath = path.join( + opts.serverOutputDir, + SERVER_FN_MANIFEST_FILE, + ) + await fsp.mkdir(path.dirname(manifestPath), { recursive: true }) + await fsp.writeFile( + manifestPath, + JSON.stringify(manifestWithImporters), + 'utf-8', + ) + await fsp.rm(tempManifestPath, { force: true }) + }, + ) + }, + } +} + +export function createServerFnResolverPlugin(opts: { + environmentName: string + providerEnvName: string + serverOutputDir?: string +}) { + const ssrIsProvider = opts.providerEnvName === VITE_ENVIRONMENT_NAMES.server + const includeClientReferencedCheck = !ssrIsProvider + const manifestPath = opts.serverOutputDir + ? path.join(opts.serverOutputDir, SERVER_FN_MANIFEST_FILE) + : null + const tempManifestPath = opts.serverOutputDir + ? path.join(opts.serverOutputDir, SERVER_FN_MANIFEST_TEMP_FILE) + : null + + const pluginFactory = createRspackPlugin(() => ({ + name: `tanstack-start-core:server-fn-resolver:${opts.environmentName}`, + resolveId(id) { + if (id === VIRTUAL_MODULES.serverFnResolver) { + return id + } + return null + }, + async load(id) { + if (id !== VIRTUAL_MODULES.serverFnResolver) return null + if (opts.environmentName !== opts.providerEnvName) { + return `export { getServerFnById } from '@tanstack/start-server-core/server-fn-ssr-caller'` + } + const serverFnsById = getServerFnsById() + const fileServerFnsById = tempManifestPath + ? await readTempManifest(tempManifestPath) + : {} + const mergedServerFnsById = { + ...serverFnsById, + ...fileServerFnsById, + } + if (Object.keys(mergedServerFnsById).length > 0) { + return generateManifestModule( + mergedServerFnsById, + includeClientReferencedCheck, + ) + } + if (manifestPath) { + return generateManifestModuleFromFile( + manifestPath, + includeClientReferencedCheck, + ) + } + return generateManifestModule(serverFnsById, includeClientReferencedCheck) + }, + })) + return pluginFactory() +} diff --git a/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts new file mode 100644 index 00000000000..74eff928c99 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts @@ -0,0 +1,318 @@ +import { promises as fsp } from 'node:fs' +import path from 'node:path' +import { joinURL } from 'ufo' +import { rootRouteId } from '@tanstack/router-core' +import { VIRTUAL_MODULES } from '@tanstack/start-server-core' +import { tsrSplit } from '@tanstack/router-plugin' +import { createRspackPlugin } from 'unplugin' +import type { Manifest, RouterManagedTag } from '@tanstack/router-core' + +const START_MANIFEST_FILE = 'tanstack-start-manifest.json' + +type StatsAsset = string | { name?: string } + +type StatsChunk = { + files?: Array + auxiliaryFiles?: Array + modules?: Array + names?: Array + entry?: boolean + initial?: boolean +} + +type StatsModule = { + name?: string + identifier?: string + nameForCondition?: string +} + +type StatsJson = { + entrypoints?: Record }> + chunks?: Array +} + +function getAssetName(asset: StatsAsset): string | undefined { + if (!asset) return undefined + if (typeof asset === 'string') return asset + return asset.name +} + +function isJsAsset(asset: string) { + return asset.endsWith('.js') || asset.endsWith('.mjs') +} + +function isCssAsset(asset: string) { + return asset.endsWith('.css') +} + +function createCssTags( + basePath: string, + assets: Array, +): Array { + return assets.map((asset) => ({ + tag: 'link', + attrs: { + rel: 'stylesheet', + href: joinURL(basePath, asset), + type: 'text/css', + }, + })) +} + +function createEntryScriptTags( + basePath: string, + assets: Array, +): Array { + return assets.map((asset) => ({ + tag: 'script', + attrs: { + type: 'module', + async: true, + src: joinURL(basePath, asset), + }, + })) +} + +function unique(items: Array) { + return Array.from(new Set(items)) +} + +function getRouteModuleFilePath(module: StatsModule): string | undefined { + const moduleId = module.identifier ?? module.name ?? '' + if (!moduleId.includes(tsrSplit)) return undefined + + if (module.nameForCondition) { + return module.nameForCondition + } + + const resource = moduleId.split('!').pop() ?? moduleId + const cleanedResource = resource.startsWith('module|') + ? resource.slice('module|'.length) + : resource + const [resourcePath, queryString] = cleanedResource.split('?') + if (!queryString?.includes(tsrSplit)) return undefined + + return resourcePath +} + +function getStatsEntryPointName(statsJson: StatsJson): string | undefined { + const entrypoints = statsJson.entrypoints ?? {} + if (entrypoints['index']) return 'index' + if (entrypoints['main']) return 'main' + return Object.keys(entrypoints)[0] +} + +function getStatsEntryAssets(statsJson: StatsJson): { + entrypointName?: string + assets: Array +} { + const entrypoints = statsJson.entrypoints ?? {} + const entrypointName = getStatsEntryPointName(statsJson) + const entrypoint = entrypointName ? entrypoints[entrypointName] : undefined + + if (!entrypoint?.assets) { + return { entrypointName, assets: [] } + } + + return { + entrypointName, + assets: unique( + entrypoint.assets + .map(getAssetName) + .filter((asset): asset is string => Boolean(asset)), + ), + } +} + +function getEntryChunkAssets( + statsJson: StatsJson, + entrypointName?: string, +): Array { + if (!entrypointName) return [] + const chunks = statsJson.chunks ?? [] + const entryChunks = chunks.filter((chunk) => { + if (chunk.entry) return true + const names = chunk.names ?? [] + return names.includes(entrypointName) + }) + return unique( + entryChunks.flatMap((chunk) => chunk.files ?? []).filter(isJsAsset), + ) +} + +function pickEntryAsset( + assets: Array, + entrypointName?: string, +): string | undefined { + if (assets.length === 0) return undefined + if (entrypointName) { + const match = assets.find((asset) => { + const baseName = path.posix.basename(asset) + return ( + baseName === `${entrypointName}.js` || + baseName.startsWith(`${entrypointName}.`) + ) + }) + if (match) return match + } + return assets[assets.length - 1] +} + +function buildStartManifest({ + statsJson, + basePath, +}: { + statsJson: StatsJson + basePath: string +}): Manifest & { clientEntry: string } { + const { entrypointName, assets: entryAssets } = + getStatsEntryAssets(statsJson) + const entryJsAssets = unique(entryAssets.filter(isJsAsset)) + const entryCssAssets = unique(entryAssets.filter(isCssAsset)) + + const entryFile = + pickEntryAsset(entryJsAssets, entrypointName) ?? + pickEntryAsset( + getEntryChunkAssets(statsJson, entrypointName), + entrypointName, + ) + if (!entryFile) { + throw new Error('No client entry file found in rsbuild stats') + } + + const routeTreeRoutes: Record = + globalThis.TSS_ROUTES_MANIFEST + + const routeChunks: Record> = {} + for (const chunk of statsJson.chunks ?? []) { + const modules = chunk.modules ?? [] + for (const mod of modules) { + const filePath = getRouteModuleFilePath(mod) + if (!filePath) continue + const normalizedPath = path.normalize(filePath) + const existingChunks = routeChunks[normalizedPath] + if (existingChunks) { + existingChunks.push(chunk) + } else { + routeChunks[normalizedPath] = [chunk] + } + } + } + + const manifest: Manifest = { routes: {} } + + Object.entries(routeTreeRoutes).forEach(([routeId, route]) => { + const chunks = routeChunks[path.normalize(route.filePath)] + if (!chunks?.length) { + manifest.routes[routeId] = {} + return + } + + const preloadAssets = unique( + chunks.flatMap((chunk) => chunk.files ?? []).filter(isJsAsset), + ) + const cssAssets = unique( + chunks + .flatMap((chunk) => [ + ...(chunk.files ?? []), + ...(chunk.auxiliaryFiles ?? []), + ]) + .filter(isCssAsset), + ) + + manifest.routes[routeId] = { + preloads: preloadAssets.map((asset) => joinURL(basePath, asset)), + assets: createCssTags(basePath, cssAssets), + } + }) + + const entryScriptAssets = entryJsAssets.filter( + (asset) => asset !== entryFile, + ) + + manifest.routes[rootRouteId] = { + ...(manifest.routes[rootRouteId] ?? {}), + preloads: entryJsAssets.map((asset) => joinURL(basePath, asset)), + assets: [ + ...createCssTags(basePath, entryCssAssets), + ...createEntryScriptTags(basePath, entryScriptAssets), + ...(manifest.routes[rootRouteId]?.assets ?? []), + ], + } + + return { + routes: manifest.routes, + clientEntry: joinURL(basePath, entryFile), + } +} + +export function createStartManifestRspackPlugin(opts: { + basePath: string + clientOutputDir: string +}) { + return { + apply(compiler: any) { + compiler.hooks.done.tapPromise( + 'tanstack-start:manifest', + async (stats: any) => { + const statsJson: StatsJson = stats.toJson({ + all: false, + entrypoints: true, + chunks: true, + chunkModules: true, + modules: true, + }) + const manifest = buildStartManifest({ + statsJson, + basePath: opts.basePath, + }) + + const manifestPath = path.join( + opts.clientOutputDir, + START_MANIFEST_FILE, + ) + await fsp.mkdir(path.dirname(manifestPath), { recursive: true }) + await fsp.writeFile( + manifestPath, + JSON.stringify(manifest), + 'utf-8', + ) + }, + ) + }, + } +} + +export function createStartManifestVirtualModulePlugin(opts: { + clientOutputDir: string +}) { + const manifestPath = path.join(opts.clientOutputDir, START_MANIFEST_FILE) + const pluginFactory = createRspackPlugin(() => ({ + name: 'tanstack-start:manifest:virtual', + resolveId(id) { + if (id === VIRTUAL_MODULES.startManifest) { + return id + } + return null + }, + load(id) { + if (id !== VIRTUAL_MODULES.startManifest) return null + return ` +import fs from 'node:fs' + +let cached +export const tsrStartManifest = () => { + if (cached) return cached + try { + const raw = fs.readFileSync(${JSON.stringify(manifestPath)}, 'utf-8') + cached = JSON.parse(raw) + return cached + } catch (error) { + return { routes: {}, clientEntry: '' } + } +} +` + }, + })) + return pluginFactory() +} diff --git a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts new file mode 100644 index 00000000000..fff31f162e7 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts @@ -0,0 +1,179 @@ +import { fileURLToPath } from 'node:url' +import fs from 'node:fs' +import path from 'pathe' +import { + tanstackRouterAutoImport, + tanstackRouterCodeSplitter, + tanstackRouterGenerator, +} from '@tanstack/router-plugin/rspack' +import { routesManifestPlugin } from '../start-router-plugin/generator-plugins/routes-manifest-plugin' +import { prerenderRoutesPlugin } from '../start-router-plugin/generator-plugins/prerender-routes-plugin' +import { VITE_ENVIRONMENT_NAMES } from '../constants' +import { setGeneratorInstance } from './route-tree-state' +import type { GetConfigFn, TanStackStartVitePluginCoreOptions } from '../types' +import type { GeneratorPlugin } from '@tanstack/router-generator' +import type { TanStackStartInputConfig } from '../schema' + +function moduleDeclaration({ + startFilePath, + routerFilePath, + corePluginOpts, + generatedRouteTreePath, +}: { + startFilePath: string | undefined + routerFilePath: string + corePluginOpts: TanStackStartVitePluginCoreOptions + generatedRouteTreePath: string +}): string { + function getImportPath(absolutePath: string) { + let relativePath = path.relative( + path.dirname(generatedRouteTreePath), + absolutePath, + ) + + if (!relativePath.startsWith('.')) { + relativePath = './' + relativePath + } + + relativePath = relativePath.split(path.sep).join('/') + return relativePath + } + + const result: Array = [ + `import type { getRouter } from '${getImportPath(routerFilePath)}'`, + ] + if (startFilePath) { + result.push( + `import type { startInstance } from '${getImportPath(startFilePath)}'`, + ) + } else { + result.push( + `import type { createStart } from '@tanstack/${corePluginOpts.framework}-start'`, + ) + } + result.push( + `declare module '@tanstack/${corePluginOpts.framework}-start' { + interface Register { + ssr: true + router: Awaited>`, + ) + if (startFilePath) { + result.push( + ` config: Awaited>`, + ) + } + result.push(` } +}`) + + return result.join('\n') +} + +function resolveLoaderPath(relativePath: string) { + const currentDir = path.dirname(fileURLToPath(import.meta.url)) + const basePath = path.resolve(currentDir, relativePath) + const jsPath = `${basePath}.js` + const tsPath = `${basePath}.ts` + if (fs.existsSync(jsPath)) return jsPath + if (fs.existsSync(tsPath)) return tsPath + return jsPath +} + +export function tanStackStartRouterRsbuild( + startPluginOpts: TanStackStartInputConfig, + getConfig: GetConfigFn, + corePluginOpts: TanStackStartVitePluginCoreOptions, +) { + const getGeneratedRouteTreePath = () => { + const { startConfig } = getConfig() + return path.resolve(startConfig.router.generatedRouteTree) + } + + const clientTreeGeneratorPlugin: GeneratorPlugin = { + name: 'start-client-tree-plugin', + init({ generator }) { + setGeneratorInstance(generator) + }, + } + + let routeTreeFileFooter: Array | null = null + function getRouteTreeFileFooter() { + if (routeTreeFileFooter) { + return routeTreeFileFooter + } + const { startConfig, resolvedStartConfig } = getConfig() + const ogRouteTreeFileFooter = startConfig.router.routeTreeFileFooter + if (ogRouteTreeFileFooter) { + if (Array.isArray(ogRouteTreeFileFooter)) { + routeTreeFileFooter = ogRouteTreeFileFooter + } else { + routeTreeFileFooter = ogRouteTreeFileFooter() + } + } + routeTreeFileFooter = [ + moduleDeclaration({ + generatedRouteTreePath: getGeneratedRouteTreePath(), + corePluginOpts, + startFilePath: resolvedStartConfig.startFilePath, + routerFilePath: resolvedStartConfig.routerFilePath, + }), + ...(routeTreeFileFooter ?? []), + ] + return routeTreeFileFooter + } + + const routeTreeLoaderPath = resolveLoaderPath('./route-tree-loader') + + const generatorPlugin = tanstackRouterGenerator(() => { + const routerConfig = getConfig().startConfig.router + const plugins = [clientTreeGeneratorPlugin, routesManifestPlugin()] + if (startPluginOpts?.prerender?.enabled === true) { + plugins.push(prerenderRoutesPlugin()) + } + return { + ...routerConfig, + target: corePluginOpts.framework, + routeTreeFileFooter: getRouteTreeFileFooter(), + plugins, + } + }) + + const clientCodeSplitter = tanstackRouterCodeSplitter(() => { + const routerConfig = getConfig().startConfig.router + return { + ...routerConfig, + codeSplittingOptions: { + ...routerConfig.codeSplittingOptions, + deleteNodes: ['ssr', 'server', 'headers'], + addHmr: true, + }, + plugin: { + vite: { environmentName: VITE_ENVIRONMENT_NAMES.client }, + }, + } + }) + + const serverCodeSplitter = tanstackRouterCodeSplitter(() => { + const routerConfig = getConfig().startConfig.router + return { + ...routerConfig, + codeSplittingOptions: { + ...routerConfig.codeSplittingOptions, + addHmr: false, + }, + plugin: { + vite: { environmentName: VITE_ENVIRONMENT_NAMES.server }, + }, + } + }) + + const autoImport = tanstackRouterAutoImport(startPluginOpts?.router) + + return { + generatorPlugin, + clientCodeSplitter, + serverCodeSplitter, + autoImport, + routeTreeLoaderPath, + getGeneratedRouteTreePath, + } +} diff --git a/packages/start-plugin-core/src/rsbuild/start-storage-context-stub.ts b/packages/start-plugin-core/src/rsbuild/start-storage-context-stub.ts new file mode 100644 index 00000000000..a1662a4364e --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-storage-context-stub.ts @@ -0,0 +1,12 @@ +export function getStartContext() { + return { + startOptions: undefined, + } +} + +export async function runWithStartContext( + _context: unknown, + fn: () => T | Promise, +) { + return fn() +} diff --git a/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts b/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts index 36b9f429fba..cb1a257eaa1 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts @@ -7,7 +7,7 @@ import { generateFromAst, parseAst, } from '@tanstack/router-utils' -import babel from '@babel/core' +import * as babel from '@babel/core' import { handleCreateServerFn } from './handleCreateServerFn' import { handleCreateMiddleware } from './handleCreateMiddleware' import { handleCreateIsomorphicFn } from './handleCreateIsomorphicFn' diff --git a/packages/start-plugin-core/src/start-compiler-plugin/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler-plugin/handleCreateServerFn.ts index ddad1cf0611..284a3d32f32 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/handleCreateServerFn.ts @@ -1,5 +1,5 @@ import * as t from '@babel/types' -import babel from '@babel/core' +import * as babel from '@babel/core' import path from 'pathe' import { VITE_ENVIRONMENT_NAMES } from '../constants' import { cleanId, codeFrameError, stripMethodCall } from './utils' @@ -218,8 +218,11 @@ export function handleCreateServerFn( const exportNames = new Set() const serverFnsById: Record = {} + let providerImportPath: string | null = null + const providerImportNames = new Set() const [baseFilename] = context.id.split('?') as [string] + const baseDir = path.dirname(baseFilename) const extractedFilename = `${baseFilename}?${TSS_SERVERFN_SPLIT_PARAM}` const relativeFilename = path.relative(context.root, baseFilename) const knownFns = context.getKnownServerFns() @@ -309,6 +312,20 @@ export function handleCreateServerFn( // to avoid duplicates - provider files process the same functions if (!isProviderFile) { + if (!envConfig.isClientEnvironment && envConfig.ssrIsProvider) { + const [canonicalBase] = canonicalExtractedFilename.split('?') as [ + string, + ] + let relativeImportPath = path.relative(baseDir, canonicalBase) + if (!relativeImportPath.startsWith('.')) { + relativeImportPath = `./${relativeImportPath}` + } + relativeImportPath = relativeImportPath.split(path.sep).join('/') + providerImportPath = `${relativeImportPath}?${TSS_SERVERFN_SPLIT_PARAM}` + } + if (providerImportPath) { + providerImportNames.add(functionName) + } serverFnsById[functionId] = { functionName, functionId, @@ -452,12 +469,65 @@ export function handleCreateServerFn( context.onServerFnsById(serverFnsById) } - // Add runtime import using cached AST node const runtimeCode = getCachedRuntimeCode( context.framework, envConfig.runtimeCodeType, ) - context.ast.program.body.unshift(t.cloneNode(runtimeCode)) + + const importStatements: Array = [t.cloneNode(runtimeCode)] + if (providerImportPath && providerImportNames.size > 0) { + importStatements.push( + t.importDeclaration( + Array.from(providerImportNames).map((name) => + t.importSpecifier(t.identifier(name), t.identifier(name)), + ), + t.stringLiteral(providerImportPath), + ), + ) + } + + context.ast.program.body.unshift(...importStatements) + + if (providerImportPath && providerImportNames.size > 0) { + const globalHandlers = t.memberExpression( + t.identifier('globalThis'), + t.identifier('__tssServerFnHandlers'), + ) + const initHandlers = t.expressionStatement( + t.assignmentExpression( + '=', + t.memberExpression( + t.identifier('globalThis'), + t.identifier('__tssServerFnHandlers'), + ), + t.logicalExpression( + '||', + t.memberExpression( + t.identifier('globalThis'), + t.identifier('__tssServerFnHandlers'), + ), + t.arrayExpression([]), + ), + ), + ) + const pushHandlers = t.expressionStatement( + t.callExpression( + t.memberExpression(globalHandlers, t.identifier('push')), + Array.from(providerImportNames).map((name) => t.identifier(name)), + ), + ) + const lastImportIndex = context.ast.program.body.reduce( + (lastIndex, node, index) => + t.isImportDeclaration(node) ? index : lastIndex, + -1, + ) + context.ast.program.body.splice( + lastImportIndex + 1, + 0, + initHandlers, + pushHandlers, + ) + } } /** diff --git a/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts index 973b2b192b6..dc86f545dd2 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts @@ -193,7 +193,6 @@ export function startCompilerPlugin( } let root = process.cwd() - let command: 'build' | 'serve' = 'build' const resolvedResolverVirtualImportId = resolveViteId( VIRTUAL_MODULES.serverFnResolver, @@ -227,7 +226,6 @@ export function startCompilerPlugin( }, configResolved(config) { root = config.root - command = config.command }, transform: { filter: { @@ -373,7 +371,6 @@ export function startCompilerPlugin( }, configResolved(config) { root = config.root - command = config.command }, resolveId: { filter: { id: new RegExp(VIRTUAL_MODULES.serverFnResolver) }, diff --git a/packages/start-plugin-core/src/start-compiler-plugin/types.ts b/packages/start-plugin-core/src/start-compiler-plugin/types.ts index 141cb4e28fb..19d4517ab8a 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/types.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/types.ts @@ -85,6 +85,12 @@ export interface ServerFn { extractedFilename: string /** The original source filename */ filename: string + /** The emitted chunk IDs for this function (rspack build) */ + importerChunkIds?: Array + /** The emitted module ID for this function (rspack build) */ + importerModuleId?: string | number + /** The emitted importer path for this function (rspack build fallback) */ + importerPath?: string /** * True when this function was discovered by the client build. * Used to restrict HTTP access to only client-referenced functions. diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index a99eed371ff..4ab0dbabd6d 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -128,7 +128,10 @@ describe('createServerFn compiles correctly', async () => { // Server caller: no second argument (implementation from extracted chunk) expect(compiledResultServerCaller!.code).toMatchInlineSnapshot(` "import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; + import { myServerFn_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createServerFn } from '@tanstack/react-start'; + globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; + globalThis.__tssServerFnHandlers.push(myServerFn_createServerFn_handler); const myServerFn = createServerFn().handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJteVNlcnZlckZuX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["myServerFn_createServerFn_handler"])));" `) @@ -184,7 +187,10 @@ describe('createServerFn compiles correctly', async () => { expect(compiledResultServerCaller!.code).toMatchInlineSnapshot(` "import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; + import { exportedFn_createServerFn_handler, nonExportedFn_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createServerFn } from '@tanstack/react-start'; + globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; + globalThis.__tssServerFnHandlers.push(exportedFn_createServerFn_handler, nonExportedFn_createServerFn_handler); export const exportedFn = createServerFn().handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJleHBvcnRlZEZuX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["exportedFn_createServerFn_handler"]))); const nonExportedFn = createServerFn().handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJub25FeHBvcnRlZEZuX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["nonExportedFn_createServerFn_handler"])));" `) diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructured.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructured.tsx index b8dbdcaae58..626d0f3ae7a 100644 --- a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructured.tsx +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructured.tsx @@ -1,6 +1,9 @@ import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { withUseServer_createServerFn_handler, withArrowFunction_createServerFn_handler, withArrowFunctionAndFunction_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler, withValidatorFn_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createServerFn } from '@tanstack/react-start'; import { z } from 'zod'; +globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; +globalThis.__tssServerFnHandlers.push(withUseServer_createServerFn_handler, withArrowFunction_createServerFn_handler, withArrowFunctionAndFunction_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler, withValidatorFn_createServerFn_handler); export const withUseServer = createServerFn({ method: 'GET' }).handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJ3aXRoVXNlU2VydmVyX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["withUseServer_createServerFn_handler"]))); diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructuredRename.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructuredRename.tsx index 815702d5d8f..cadb811cf37 100644 --- a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructuredRename.tsx +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructuredRename.tsx @@ -1,5 +1,8 @@ import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { withUseServer_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createServerFn as serverFn } from '@tanstack/react-start'; +globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; +globalThis.__tssServerFnHandlers.push(withUseServer_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler); export const withUseServer = serverFn({ method: 'GET' }).handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJ3aXRoVXNlU2VydmVyX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["withUseServer_createServerFn_handler"]))); diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnStarImport.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnStarImport.tsx index 2f68c8c276d..6452aa604d2 100644 --- a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnStarImport.tsx +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnStarImport.tsx @@ -1,5 +1,8 @@ import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { withUseServer_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import * as TanStackStart from '@tanstack/react-start'; +globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; +globalThis.__tssServerFnHandlers.push(withUseServer_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler); export const withUseServer = TanStackStart.createServerFn({ method: 'GET' }).handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJ3aXRoVXNlU2VydmVyX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["withUseServer_createServerFn_handler"]))); diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnValidator.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnValidator.tsx index ba0b4b577e2..b6a5abb5eb4 100644 --- a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnValidator.tsx +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnValidator.tsx @@ -1,6 +1,9 @@ import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { withUseServer_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createServerFn } from '@tanstack/react-start'; import { z } from 'zod'; +globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; +globalThis.__tssServerFnHandlers.push(withUseServer_createServerFn_handler); export const withUseServer = createServerFn({ method: 'GET' }).inputValidator(z.number()).handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJ3aXRoVXNlU2VydmVyX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["withUseServer_createServerFn_handler"]))); \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/factory.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/factory.tsx index dbc4e764d9a..874d4c8fd90 100644 --- a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/factory.tsx +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/factory.tsx @@ -1,5 +1,8 @@ import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { myAuthedFn_createServerFn_handler, deleteUserFn_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createServerFn, createMiddleware } from '@tanstack/react-start'; +globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; +globalThis.__tssServerFnHandlers.push(myAuthedFn_createServerFn_handler, deleteUserFn_createServerFn_handler); const authMiddleware = createMiddleware({ type: 'function' }).server(({ diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/isomorphic-fns.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/isomorphic-fns.tsx index 02675e9e956..aa574d6ff56 100644 --- a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/isomorphic-fns.tsx +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/isomorphic-fns.tsx @@ -1,7 +1,10 @@ import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { getServerEnv_createServerFn_handler, getServerEcho_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createFileRoute } from '@tanstack/react-router'; import { createIsomorphicFn, createServerFn } from '@tanstack/react-start'; import { useState } from 'react'; +globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; +globalThis.__tssServerFnHandlers.push(getServerEnv_createServerFn_handler, getServerEcho_createServerFn_handler); const getEnv = createIsomorphicFn().server(() => 'server').client(() => 'client'); const getServerEnv = createServerFn().handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJnZXRTZXJ2ZXJFbnZfY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["getServerEnv_createServerFn_handler"]))); const getEcho = createIsomorphicFn().server((input: string) => 'server received ' + input).client(input => 'client received ' + input); diff --git a/packages/start-plugin-core/vite.config.ts b/packages/start-plugin-core/vite.config.ts index d6650eebf65..dad7d4279ff 100644 --- a/packages/start-plugin-core/vite.config.ts +++ b/packages/start-plugin-core/vite.config.ts @@ -14,7 +14,13 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: './src/index.ts', + entry: [ + './src/index.ts', + './src/rsbuild/index.ts', + './src/rsbuild/start-compiler-loader.ts', + './src/rsbuild/route-tree-loader.ts', + './src/rsbuild/start-storage-context-stub.ts', + ], srcDir: './src', outDir: './dist', cjs: false, diff --git a/packages/start-server-core/src/router-manifest.ts b/packages/start-server-core/src/router-manifest.ts index ceec7eaf4e8..925ae7893bf 100644 --- a/packages/start-server-core/src/router-manifest.ts +++ b/packages/start-server-core/src/router-manifest.ts @@ -20,7 +20,7 @@ const ROUTER_BASEPATH = process.env.TSS_ROUTER_BASEPATH || '/' export async function getStartManifest( matchedRoutes?: ReadonlyArray, ): Promise { - const { tsrStartManifest } = await import('tanstack-start-manifest:v') + const { tsrStartManifest } = await import('tanstack-start-manifest') const startManifest = tsrStartManifest() const rootRoute = (startManifest.routes[rootRouteId] = @@ -45,7 +45,7 @@ export async function getStartManifest( // build the client entry script tag after URL transforms are applied) let injectedHeadScripts: string | undefined if (process.env.TSS_DEV_SERVER === 'true') { - const mod = await import('tanstack-start-injected-head-scripts:v') + const mod = await import('tanstack-start-injected-head-scripts') if (mod.injectedHeadScripts) { injectedHeadScripts = mod.injectedHeadScripts } diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index fdc6fb06723..e5774627bfb 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -50,6 +50,10 @@ export const handleServerAction = async ({ const url = new URL(request.url) const action = await getServerFnById(serverFnId, { fromClient: true }) + const executableAction = + typeof (action as any)?.__executeServer === 'function' + ? (action as any).__executeServer.bind(action) + : action const isServerFn = request.headers.get('x-tsr-serverFn') === 'true' @@ -111,7 +115,8 @@ export const handleServerAction = async ({ } } - return await action(params) + const result = await executableAction(params) + return result } // Get requests use the query string @@ -129,7 +134,8 @@ export const handleServerAction = async ({ payload.context = safeObjectMerge(context, payload.context) payload.method = methodUpper // Send it through! - return await action(payload) + const result = await executableAction(payload) + return result } if (methodLower !== 'post') { @@ -144,7 +150,8 @@ export const handleServerAction = async ({ const payload = jsonPayload ? parsePayload(jsonPayload) : {} payload.context = safeObjectMerge(payload.context, context) payload.method = methodUpper - return await action(payload) + const result = await executableAction(payload) + return result })() const unwrapped = res.result || res.error @@ -157,8 +164,18 @@ export const handleServerAction = async ({ return unwrapped } - if (unwrapped instanceof Response) { - if (isRedirect(unwrapped)) { + const redirectOptions = getRedirectOptions(unwrapped) + if (redirectOptions) { + return Response.json( + { ...redirectOptions, isSerializedRedirect: true }, + { headers: getResponseHeaders(unwrapped) }, + ) + } + + if (isResponseLike(unwrapped)) { + const isRedirectResponse = + isRedirect(unwrapped) || Boolean(getRedirectOptions(unwrapped)) + if (isRedirectResponse) { return unwrapped } unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true') @@ -305,7 +322,20 @@ export const handleServerAction = async ({ }) } } catch (error: any) { - if (error instanceof Response) { + if (isResponseLike(error)) { + const redirectOptions = getRedirectOptions(error) + if (redirectOptions && isServerFn) { + return Response.json( + { ...redirectOptions, isSerializedRedirect: true }, + { headers: getResponseHeaders(error) }, + ) + } + const isRedirectResponse = + isRedirect(error) || Boolean(getRedirectOptions(error)) + if (isRedirectResponse) { + return error + } + error.headers.set(X_TSS_RAW_RESPONSE, 'true') return error } // else if ( @@ -365,3 +395,37 @@ function isNotFoundResponse(error: any) { }, }) } + +function isResponseLike(value: unknown): value is Response { + if (value instanceof Response) { + return true + } + if (value === null || typeof value !== 'object') { + return false + } + if (!('status' in value) || !('headers' in value)) { + return false + } + const headers = (value as { headers?: { get?: unknown } }).headers + return typeof headers?.get === 'function' +} + +function getRedirectOptions( + value: unknown, +): Record | undefined { + if (!isRedirect(value)) { + return undefined + } + return value.options as Record +} + +function getResponseHeaders(value: unknown): Headers | undefined { + if (value === null || typeof value !== 'object') { + return undefined + } + if (!('headers' in value)) { + return undefined + } + const headers = (value as { headers?: Headers }).headers + return headers +} diff --git a/packages/start-server-core/src/tanstack-start.d.ts b/packages/start-server-core/src/tanstack-start.d.ts index 106375b4da9..49fb61c849a 100644 --- a/packages/start-server-core/src/tanstack-start.d.ts +++ b/packages/start-server-core/src/tanstack-start.d.ts @@ -1,4 +1,4 @@ -declare module 'tanstack-start-manifest:v' { +declare module 'tanstack-start-manifest' { import type { Manifest } from '@tanstack/router-core' export const tsrStartManifest: () => Manifest & { clientEntry: string } @@ -18,6 +18,6 @@ declare module '#tanstack-start-server-fn-resolver' { ): Promise } -declare module 'tanstack-start-injected-head-scripts:v' { +declare module 'tanstack-start-injected-head-scripts' { export const injectedHeadScripts: string | undefined } diff --git a/packages/start-server-core/src/virtual-modules.ts b/packages/start-server-core/src/virtual-modules.ts index 7280feacf26..3fd43ba4e9d 100644 --- a/packages/start-server-core/src/virtual-modules.ts +++ b/packages/start-server-core/src/virtual-modules.ts @@ -1,5 +1,5 @@ export const VIRTUAL_MODULES = { - startManifest: 'tanstack-start-manifest:v', - injectedHeadScripts: 'tanstack-start-injected-head-scripts:v', + startManifest: 'tanstack-start-manifest', + injectedHeadScripts: 'tanstack-start-injected-head-scripts', serverFnResolver: '#tanstack-start-server-fn-resolver', } as const diff --git a/packages/vue-start/package.json b/packages/vue-start/package.json index d515d54c85f..d732b138200 100644 --- a/packages/vue-start/package.json +++ b/packages/vue-start/package.json @@ -74,6 +74,12 @@ "default": "./dist/esm/plugin/vite.js" } }, + "./plugin/rsbuild": { + "import": { + "types": "./dist/esm/plugin/rsbuild.d.ts", + "default": "./dist/esm/plugin/rsbuild.js" + } + }, "./server-entry": { "import": { "types": "./dist/default-entry/esm/server.d.ts", @@ -106,7 +112,13 @@ "vue": "^3.5.25" }, "peerDependencies": { + "@rsbuild/core": ">=1.0.0", "vue": "^3.3.0", "vite": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + } } } diff --git a/packages/vue-start/src/plugin/rsbuild.ts b/packages/vue-start/src/plugin/rsbuild.ts new file mode 100644 index 00000000000..55b4b13323d --- /dev/null +++ b/packages/vue-start/src/plugin/rsbuild.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from 'node:url' +import path from 'pathe' +import { TanStackStartRsbuildPluginCore } from '@tanstack/start-plugin-core/rsbuild' +import type { TanStackStartInputConfig } from '@tanstack/start-plugin-core' + +type RsbuildPlugin = { + name: string + setup: (api: any) => void +} + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) +const defaultEntryDir = path.resolve( + currentDir, + '..', + '..', + 'plugin', + 'default-entry', +) +const defaultEntryPaths = { + client: path.resolve(defaultEntryDir, 'client.tsx'), + server: path.resolve(defaultEntryDir, 'server.ts'), + start: path.resolve(defaultEntryDir, 'start.ts'), +} + +export function tanstackStart( + options?: TanStackStartInputConfig, +): Array { + return TanStackStartRsbuildPluginCore( + { + framework: 'vue', + defaultEntryPaths, + }, + options, + ) +} diff --git a/packages/vue-start/vite.config.ts b/packages/vue-start/vite.config.ts index 3e1088b3ef3..b9c0ec1e262 100644 --- a/packages/vue-start/vite.config.ts +++ b/packages/vue-start/vite.config.ts @@ -33,6 +33,7 @@ export default mergeConfig( './src/server-rpc.ts', './src/server.tsx', './src/plugin/vite.ts', + './src/plugin/rsbuild.ts', ], externalDeps: ['@tanstack/vue-start-client', '@tanstack/vue-start-server'], cjs: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9453614209..b13062c3c4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1119,6 +1119,12 @@ importers: '@playwright/test': specifier: ^1.57.0 version: 1.57.0 + '@rsbuild/core': + specifier: ^1.2.4 + version: 1.2.4 + '@rsbuild/plugin-react': + specifier: ^1.1.0 + version: 1.1.0(@rsbuild/core@1.2.4) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) @@ -1594,7 +1600,7 @@ importers: version: 4.7.0(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) nitro: specifier: 3.0.1-alpha.2 - version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) sass: specifier: ^1.97.2 version: 1.97.2 @@ -1716,7 +1722,7 @@ importers: version: 9.2.1 nitro: specifier: npm:nitro-nightly@latest - version: nitro-nightly@3.0.1-20260123-195236-c6b834cd(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: nitro-nightly@3.0.1-20260206-171553-bc737c0c(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) typescript: specifier: ^5.7.2 version: 5.9.3 @@ -7955,7 +7961,7 @@ importers: version: 4.6.0(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) nitro: specifier: 3.0.1-alpha.2 - version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -10494,7 +10500,7 @@ importers: version: 25.0.9 nitro: specifier: 3.0.1-alpha.2 - version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -10721,7 +10727,7 @@ importers: version: 25.0.9 nitro: specifier: 3.0.1-alpha.2 - version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -11538,7 +11544,7 @@ importers: dependencies: nitropack: specifier: ^2.13.1 - version: 2.13.1(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(encoding@0.1.13)(mysql2@3.15.3) + version: 2.13.1(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(encoding@0.1.13)(mysql2@3.15.3)(rolldown@1.0.0-rc.3) pathe: specifier: ^2.0.3 version: 2.0.3 @@ -11644,6 +11650,9 @@ importers: packages/react-start: dependencies: + '@rsbuild/core': + specifier: '>=1.0.0' + version: 1.2.4 '@tanstack/react-router': specifier: workspace:* version: link:../react-router @@ -12097,6 +12106,9 @@ importers: packages/solid-start: dependencies: + '@rsbuild/core': + specifier: '>=1.0.0' + version: 1.2.4 '@tanstack/solid-router': specifier: workspace:* version: link:../solid-router @@ -12228,6 +12240,9 @@ importers: '@rolldown/pluginutils': specifier: 1.0.0-beta.40 version: 1.0.0-beta.40 + '@rsbuild/core': + specifier: '>=1.0.0' + version: 1.2.4 '@tanstack/router-core': specifier: workspace:* version: link:../router-core @@ -12264,6 +12279,9 @@ importers: ufo: specifier: ^1.5.4 version: 1.6.1 + unplugin: + specifier: ^2.3.11 + version: 2.3.11 vitefu: specifier: ^1.1.1 version: 1.1.1(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) @@ -12300,7 +12318,7 @@ importers: version: link:../start-storage-context h3-v2: specifier: npm:h3@2.0.1-rc.14 - version: h3@2.0.1-rc.14(crossws@0.4.3(srvx@0.11.2)) + version: h3@2.0.1-rc.14(crossws@0.4.4(srvx@0.11.2)) seroval: specifier: ^1.4.2 version: 1.4.2 @@ -12460,6 +12478,9 @@ importers: packages/vue-start: dependencies: + '@rsbuild/core': + specifier: '>=1.0.0' + version: 1.2.4 '@tanstack/start-client-core': specifier: workspace:* version: link:../start-client-core @@ -15350,6 +15371,9 @@ packages: cpu: [x64] os: [win32] + '@oxc-project/types@0.112.0': + resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} + '@oxc-transform/binding-android-arm-eabi@0.110.0': resolution: {integrity: sha512-sE9dxvqqAax1YYJ3t7j+h5ZSI9jl6dYuDfngl6ieZUrIy5P89/8JKVgAzgp8o3wQSo7ndpJvYsi1K4ZqrmbP7w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -16388,6 +16412,83 @@ packages: '@remix-run/node-fetch-server@0.8.1': resolution: {integrity: sha512-J1dev372wtJqmqn9U/qbpbZxbJSQrogNN2+Qv1lKlpATpe/WQ9aCZfl/xSb9d2Rgh1IyLSvNxZAXPZxruO6Xig==} + '@rolldown/binding-android-arm64@1.0.0-rc.3': + resolution: {integrity: sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.3': + resolution: {integrity: sha512-JWWLzvcmc/3pe7qdJqPpuPk91SoE/N+f3PcWx/6ZwuyDVyungAEJPvKm/eEldiDdwTmaEzWfIR+HORxYWrCi1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.3': + resolution: {integrity: sha512-MTakBxfx3tde5WSmbHxuqlDsIW0EzQym+PJYGF4P6lG2NmKzi128OGynoFUqoD5ryCySEY85dug4v+LWGBElIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.3': + resolution: {integrity: sha512-jje3oopyOLs7IwfvXoS6Lxnmie5JJO7vW29fdGFu5YGY1EDbVDhD+P9vDihqS5X6fFiqL3ZQZCMBg6jyHkSVww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.3': + resolution: {integrity: sha512-A0n8P3hdLAaqzSFrQoA42p23ZKBYQOw+8EH5r15Sa9X1kD9/JXe0YT2gph2QTWvdr0CVK2BOXiK6ENfy6DXOag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.3': + resolution: {integrity: sha512-kWXkoxxarYISBJ4bLNf5vFkEbb4JvccOwxWDxuK9yee8lg5XA7OpvlTptfRuwEvYcOZf+7VS69Uenpmpyo5Bjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': + resolution: {integrity: sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': + resolution: {integrity: sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': + resolution: {integrity: sha512-qaj+MFudtdCv9xZo9znFvkgoajLdc+vwf0Kz5N44g+LU5XMe+IsACgn3UG7uTRlCCvhMAGXm1XlpEA5bZBrOcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': + resolution: {integrity: sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': + resolution: {integrity: sha512-gekrQ3Q2HiC1T5njGyuUJoGpK/l6B/TNXKed3fZXNf9YRTJn3L5MOZsFBn4bN2+UX+8+7hgdlTcEsexX988G4g==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3': + resolution: {integrity: sha512-85y5JifyMgs8m5K2XzR/VDsapKbiFiohl7s5lEj7nmNGO0pkTXE7q6TQScei96BNAsoK7JC3pA7ukA8WRHVJpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.3': + resolution: {integrity: sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.0-beta.19': resolution: {integrity: sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==} @@ -16409,6 +16510,9 @@ packages: '@rolldown/pluginutils@1.0.0-beta.54': resolution: {integrity: sha512-AHgcZ+w7RIRZ65ihSQL8YuoKcpD9Scew4sEeP1BBUT9QdTo6KjwHrZZXjID6nL10fhKessCH6OPany2QKwAwTQ==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/plugin-alias@6.0.0': resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} engines: {node: '>=20.19.0'} @@ -17399,6 +17503,7 @@ packages: '@tanstack/config@0.22.0': resolution: {integrity: sha512-7Wwfw6wBv2Kc+OBNIJQzBSJ6q7GABtwVT+VOQ/7/Gl7z8z1rtEYUZrxUrNvbbrHY+J5/WNZNZjJjTWDf8nTUBw==} engines: {node: '>=18'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@tanstack/devtools-client@0.0.4': resolution: {integrity: sha512-LefnH9KE9uRDEWifc3QDcooskA8ikfs41bybDTgpYQpyTUspZnaEdUdya9Hry0KYxZ8nos0S3nNbsP79KHqr6Q==} @@ -19364,6 +19469,14 @@ packages: srvx: optional: true + crossws@0.4.4: + resolution: {integrity: sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg==} + peerDependencies: + srvx: '>=0.7.1' + peerDependenciesMeta: + srvx: + optional: true + css-loader@7.1.2: resolution: {integrity: sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==} engines: {node: '>= 18.12.0'} @@ -20517,6 +20630,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -21885,24 +21999,21 @@ packages: netlify-redirector@0.5.0: resolution: {integrity: sha512-4zdzIP+6muqPCuE8avnrgDJ6KW/2+UpHTRcTbMXCIRxiRmyrX+IZ4WSJGZdHPWF3WmQpXpy603XxecZ9iygN7w==} + nf3@0.3.10: + resolution: {integrity: sha512-UlqmHkZiHGgSkRj17yrOXEsSu5ECvtlJ3Xm1W5WsWrTKgu9m7OjrMZh9H/ME2LcWrTlMD0/vmmNVpyBG4yRdGg==} + nf3@0.3.5: resolution: {integrity: sha512-1VozaVz0lVfGL3c2wZ4c6bmQCm340gDiIYUU3lcg8vVGL/WeuTdrd6OhJiUHZWofc7fFdquhS8Gm+13c3Tumcw==} - nf3@0.3.6: - resolution: {integrity: sha512-/XRUUILTAyuy1XunyVQuqGp8aEmZ2TfRTn8Rji+FA4xqv20qzL4jV7Reqbuey2XucKgPeRVcEYGScmJM0UnB6Q==} - - nitro-nightly@3.0.1-20260123-195236-c6b834cd: - resolution: {integrity: sha512-TjFlflqrAwl+jJcUwgXAq9qVSBRan3o6O4jR4SIt9J/8ipuoud8H+ERhvzUEZhunOJwjdbkp8B9X2Ik6cC1Yww==} + nitro-nightly@3.0.1-20260206-171553-bc737c0c: + resolution: {integrity: sha512-fqne2eTFStLkCODKJ2PWuN6mWv0HNL8mb0xYH/W14cNqbFPiwWQQPWPG9BWARfXm8q/QjN93kTyIYMwRgE5tag==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - rolldown: '>=1.0.0-beta.0' - rollup: ^4 + rollup: ^4.57.0 vite: ^7.3.1 xml2js: ^0.6.2 peerDependenciesMeta: - rolldown: - optional: true rollup: optional: true vite: @@ -22929,6 +23040,11 @@ packages: engines: {node: 20 || >=22} hasBin: true + rolldown@1.0.0-rc.3: + resolution: {integrity: sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup-plugin-preserve-directives@0.4.0: resolution: {integrity: sha512-gx4nBxYm5BysmEQS+e2tAMrtFxrGvk+Pe5ppafRibQi0zlW7VYAbEGk6IKDw9sJGPdFWgVTE0o4BU4cdG0Fylg==} peerDependencies: @@ -23627,6 +23743,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me terser-webpack-plugin@5.3.11: resolution: {integrity: sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==} @@ -24069,10 +24186,6 @@ packages: unplugin@1.0.1: resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==} - unplugin@2.3.10: - resolution: {integrity: sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw==} - engines: {node: '>=18.12.0'} - unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -24681,6 +24794,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -26815,7 +26929,7 @@ snapshots: commander: 11.1.0 consola: 3.4.0 json5: 2.2.3 - unplugin: 2.3.10 + unplugin: 2.3.11 urlpattern-polyfill: 10.1.0 transitivePeerDependencies: - babel-plugin-macros @@ -27763,6 +27877,8 @@ snapshots: '@oxc-minify/binding-win32-x64-msvc@0.110.0': optional: true + '@oxc-project/types@0.112.0': {} + '@oxc-transform/binding-android-arm-eabi@0.110.0': optional: true @@ -28880,6 +28996,47 @@ snapshots: '@remix-run/node-fetch-server@0.8.1': {} + '@rolldown/binding-android-arm64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.3': + optional: true + '@rolldown/pluginutils@1.0.0-beta.19': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -28894,6 +29051,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.54': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/plugin-alias@6.0.0(rollup@4.55.3)': optionalDependencies: rollup: 4.55.3 @@ -29220,7 +29379,7 @@ snapshots: '@module-federation/runtime-tools': 0.8.4 '@rspack/binding': 1.2.2 '@rspack/lite-tapable': 1.0.1 - caniuse-lite: 1.0.30001696 + caniuse-lite: 1.0.30001760 optionalDependencies: '@swc/helpers': 0.5.15 @@ -31522,10 +31681,6 @@ snapshots: dependencies: acorn: 8.15.0 - acorn-jsx@5.3.2(acorn@8.14.1): - dependencies: - acorn: 8.14.1 - acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -32472,10 +32627,9 @@ snapshots: optionalDependencies: srvx: 0.10.1 - crossws@0.4.3(srvx@0.11.2): + crossws@0.4.4(srvx@0.11.2): optionalDependencies: srvx: 0.11.2 - optional: true css-loader@7.1.2(@rspack/core@1.2.2(@swc/helpers@0.5.15))(webpack@5.97.1): dependencies: @@ -33439,8 +33593,8 @@ snapshots: espree@10.3.0: dependencies: - acorn: 8.14.1 - acorn-jsx: 5.3.2(acorn@8.14.1) + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 espree@10.4.0: @@ -34016,12 +34170,12 @@ snapshots: optionalDependencies: crossws: 0.4.3(srvx@0.10.1) - h3@2.0.1-rc.14(crossws@0.4.3(srvx@0.11.2)): + h3@2.0.1-rc.14(crossws@0.4.4(srvx@0.11.2)): dependencies: rou3: 0.7.12 srvx: 0.11.2 optionalDependencies: - crossws: 0.4.3(srvx@0.11.2) + crossws: 0.4.4(srvx@0.11.2) handle-thing@2.0.1: {} @@ -35369,24 +35523,22 @@ snapshots: netlify-redirector@0.5.0: {} - nf3@0.3.5: {} + nf3@0.3.10: {} - nf3@0.3.6: {} + nf3@0.3.5: {} - nitro-nightly@3.0.1-20260123-195236-c6b834cd(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)): + nitro-nightly@3.0.1-20260206-171553-bc737c0c(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)): dependencies: consola: 3.4.2 - crossws: 0.4.3(srvx@0.10.1) + crossws: 0.4.4(srvx@0.11.2) db0: 0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3) - h3: 2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1)) + h3: 2.0.1-rc.14(crossws@0.4.4(srvx@0.11.2)) jiti: 2.6.1 - nf3: 0.3.6 + nf3: 0.3.10 ofetch: 2.0.0-alpha.3 ohash: 2.0.11 - oxc-minify: 0.110.0 - oxc-transform: 0.110.0 - srvx: 0.10.1 - undici: 7.18.2 + rolldown: 1.0.0-rc.3 + srvx: 0.11.2 unenv: 2.0.0-rc.24 unstorage: 2.0.0-alpha.5(@netlify/blobs@10.1.0)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(lru-cache@11.2.2)(ofetch@2.0.0-alpha.3) optionalDependencies: @@ -35421,7 +35573,7 @@ snapshots: - sqlite3 - uploadthing - nitro@3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)): + nitro@3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)): dependencies: consola: 3.4.2 crossws: 0.4.3(srvx@0.10.1) @@ -35438,6 +35590,7 @@ snapshots: unenv: 2.0.0-rc.24 unstorage: 2.0.0-alpha.5(@netlify/blobs@10.1.0)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(lru-cache@11.2.2)(ofetch@2.0.0-alpha.3) optionalDependencies: + rolldown: 1.0.0-rc.3 rollup: 4.55.3 vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) transitivePeerDependencies: @@ -35469,7 +35622,7 @@ snapshots: - sqlite3 - uploadthing - nitropack@2.13.1(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(encoding@0.1.13)(mysql2@3.15.3): + nitropack@2.13.1(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(encoding@0.1.13)(mysql2@3.15.3)(rolldown@1.0.0-rc.3): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.55.3) @@ -35522,7 +35675,7 @@ snapshots: pretty-bytes: 7.1.0 radix3: 1.1.2 rollup: 4.55.3 - rollup-plugin-visualizer: 6.0.5(rollup@4.55.3) + rollup-plugin-visualizer: 6.0.5(rolldown@1.0.0-rc.3)(rollup@4.55.3) scule: 1.3.0 semver: 7.7.3 serve-placeholder: 2.0.2 @@ -36686,19 +36839,39 @@ snapshots: glob: 13.0.0 package-json-from-dist: 1.0.1 + rolldown@1.0.0-rc.3: + dependencies: + '@oxc-project/types': 0.112.0 + '@rolldown/pluginutils': 1.0.0-rc.3 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.3 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.3 + '@rolldown/binding-darwin-x64': 1.0.0-rc.3 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.3 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.3 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.3 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.3 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.3 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.3 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.3 + rollup-plugin-preserve-directives@0.4.0(rollup@4.55.3): dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.55.3) magic-string: 0.30.21 rollup: 4.55.3 - rollup-plugin-visualizer@6.0.5(rollup@4.55.3): + rollup-plugin-visualizer@6.0.5(rolldown@1.0.0-rc.3)(rollup@4.55.3): dependencies: open: 8.4.2 picomatch: 4.0.3 source-map: 0.7.6 yargs: 17.7.2 optionalDependencies: + rolldown: 1.0.0-rc.3 rollup: 4.55.3 rollup@4.52.5: @@ -37909,18 +38082,11 @@ snapshots: unplugin@1.0.1: dependencies: - acorn: 8.14.1 + acorn: 8.15.0 chokidar: 3.6.0 webpack-sources: 3.2.3 webpack-virtual-modules: 0.5.0 - unplugin@2.3.10: - dependencies: - '@jridgewell/remapping': 2.3.5 - acorn: 8.15.0 - picomatch: 4.0.3 - webpack-virtual-modules: 0.6.2 - unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5