diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index e8217bc7..c6471e0b 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -209,6 +209,17 @@ jobs: fi env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + - if: + ${{ steps.release.outputs['packages/vite-plugin-tanstack-start--release_created'] || github.event_name == + 'workflow_dispatch' }} + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + npm publish packages/vite-plugin-tanstack-start/ --provenance --access=public || true + else + npm publish packages/vite-plugin-tanstack-start/ --provenance --access=public + fi + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} - if: ${{ steps.release.outputs['packages/otel--release_created'] || github.event_name == 'workflow_dispatch' }} run: | if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2381fd9b..f4f1eeae 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -57,6 +57,8 @@ jobs: run: npm run build --workspaces=true - name: Tests run: npm run test --workspaces=true + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} test-node18: name: Test Node.js 18 for specific packages runs-on: ${{ matrix.os }} diff --git a/.prettierignore b/.prettierignore index e527c529..1911218a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ .circleci dist coverage -CHANGELOG.md \ No newline at end of file +CHANGELOG.md +**/fixtures/* diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0de931a9..9250ea01 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -15,5 +15,6 @@ "packages/runtime-utils": "2.1.0", "packages/static": "3.0.10", "packages/types": "2.0.3", - "packages/vite-plugin": "2.5.10" + "packages/vite-plugin": "2.5.10", + "packages/vite-plugin-tanstack-start": "0.0.0" } diff --git a/README.md b/README.md index ddc2cea9..ccb722f0 100644 --- a/README.md +++ b/README.md @@ -27,21 +27,22 @@ npm run dev ## Packages -| Name | Description | Version | -| --------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| 🤖 [@netlify/ai](packages/ai) | TypeScript utilities for interacting with Netlify AI features | [![npm version](https://img.shields.io/npm/v/@netlify/ai.svg)](https://www.npmjs.com/package/@netlify/ai) | -| 🗄️ [@netlify/blobs](packages/blobs) | TypeScript client for Netlify Blobs | [![npm version](https://img.shields.io/npm/v/@netlify/blobs.svg)](https://www.npmjs.com/package/@netlify/blobs) | -| 💾 [@netlify/cache](packages/cache) | TypeScript utilities for interacting with the Netlify cache | [![npm version](https://img.shields.io/npm/v/@netlify/cache.svg)](https://www.npmjs.com/package/@netlify/cache) | -| 🛠️ [@netlify/dev](packages/dev) | Emulation of the Netlify environment for local development | [![npm version](https://img.shields.io/npm/v/@netlify/dev.svg)](https://www.npmjs.com/package/@netlify/dev) | -| 🔧 [@netlify/dev-utils](packages/dev-utils) | TypeScript utilities for the local emulation of the Netlify environment | [![npm version](https://img.shields.io/npm/v/@netlify/dev-utils.svg)](https://www.npmjs.com/package/@netlify/dev-utils) | -| ⚡ [@netlify/functions](packages/functions) | TypeScript utilities for interacting with Netlify Functions | [![npm version](https://img.shields.io/npm/v/@netlify/functions.svg)](https://www.npmjs.com/package/@netlify/functions) | -| 📋 [@netlify/headers](packages/headers) | TypeScript implementation of Netlify's headers engine | [![npm version](https://img.shields.io/npm/v/@netlify/headers.svg)](https://www.npmjs.com/package/@netlify/headers) | -| 🖼️ [@netlify/images](packages/images) | TypeScript utilities for interacting with Netlify Image CDN | [![npm version](https://img.shields.io/npm/v/@netlify/images.svg)](https://www.npmjs.com/package/@netlify/images) | -| 🚀 [@netlify/nuxt](packages/nuxt-module) | Nuxt module with a local emulation of the Netlify environment | [![npm version](https://img.shields.io/npm/v/@netlify/nuxt.svg)](https://www.npmjs.com/package/@netlify/nuxt) | -| 🔍 [@netlify/otel](packages/otel) | TypeScript utilities to interact with Netlify's OpenTelemetry | [![npm version](https://img.shields.io/npm/v/@netlify/otel.svg)](https://www.npmjs.com/package/@netlify/otel) | -| 🔄 [@netlify/redirects](packages/redirects) | TypeScript implementation of Netlify's rewrites and redirects engine | [![npm version](https://img.shields.io/npm/v/@netlify/redirects.svg)](https://www.npmjs.com/package/@netlify/redirects) | -| 🏛️ [@netlify/runtime](packages/runtime) | Netlify compute runtime | [![npm version](https://img.shields.io/npm/v/@netlify/runtime.svg)](https://www.npmjs.com/package/@netlify/runtime) | -| 🔨 [@netlify/runtime-utils](packages/runtime-utils) | Cross-environment utilities for the Netlify runtime | [![npm version](https://img.shields.io/npm/v/@netlify/runtime-utils.svg)](https://www.npmjs.com/package/@netlify/runtime-utils) | -| 📁 [@netlify/static](packages/static) | TypeScript implementation of Netlify's static file serving logic | [![npm version](https://img.shields.io/npm/v/@netlify/static.svg)](https://www.npmjs.com/package/@netlify/static) | -| 🔢 [@netlify/types](packages/types) | TypeScript types for Netlify platform primitives | [![npm version](https://img.shields.io/npm/v/@netlify/types.svg)](https://www.npmjs.com/package/@netlify/types) | -| 🔌 [@netlify/vite-plugin](packages/vite-plugin) | Vite plugin with a local emulation of the Netlify environment | [![npm version](https://img.shields.io/npm/v/@netlify/vite-plugin.svg)](https://www.npmjs.com/package/@netlify/vite-plugin) | +| Name | Description | Version | +| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| 🤖 [@netlify/ai](packages/ai) | TypeScript utilities for interacting with Netlify AI features | [![npm version](https://img.shields.io/npm/v/@netlify/ai.svg)](https://www.npmjs.com/package/@netlify/ai) | +| 🗄️ [@netlify/blobs](packages/blobs) | TypeScript client for Netlify Blobs | [![npm version](https://img.shields.io/npm/v/@netlify/blobs.svg)](https://www.npmjs.com/package/@netlify/blobs) | +| 💾 [@netlify/cache](packages/cache) | TypeScript utilities for interacting with the Netlify cache | [![npm version](https://img.shields.io/npm/v/@netlify/cache.svg)](https://www.npmjs.com/package/@netlify/cache) | +| 🛠️ [@netlify/dev](packages/dev) | Emulation of the Netlify environment for local development | [![npm version](https://img.shields.io/npm/v/@netlify/dev.svg)](https://www.npmjs.com/package/@netlify/dev) | +| 🔧 [@netlify/dev-utils](packages/dev-utils) | TypeScript utilities for the local emulation of the Netlify environment | [![npm version](https://img.shields.io/npm/v/@netlify/dev-utils.svg)](https://www.npmjs.com/package/@netlify/dev-utils) | +| ⚡ [@netlify/functions](packages/functions) | TypeScript utilities for interacting with Netlify Functions | [![npm version](https://img.shields.io/npm/v/@netlify/functions.svg)](https://www.npmjs.com/package/@netlify/functions) | +| 📋 [@netlify/headers](packages/headers) | TypeScript implementation of Netlify's headers engine | [![npm version](https://img.shields.io/npm/v/@netlify/headers.svg)](https://www.npmjs.com/package/@netlify/headers) | +| 🖼️ [@netlify/images](packages/images) | TypeScript utilities for interacting with Netlify Image CDN | [![npm version](https://img.shields.io/npm/v/@netlify/images.svg)](https://www.npmjs.com/package/@netlify/images) | +| 🚀 [@netlify/nuxt](packages/nuxt-module) | Nuxt module with a local emulation of the Netlify environment | [![npm version](https://img.shields.io/npm/v/@netlify/nuxt.svg)](https://www.npmjs.com/package/@netlify/nuxt) | +| 🔍 [@netlify/otel](packages/otel) | TypeScript utilities to interact with Netlify's OpenTelemetry | [![npm version](https://img.shields.io/npm/v/@netlify/otel.svg)](https://www.npmjs.com/package/@netlify/otel) | +| 🔄 [@netlify/redirects](packages/redirects) | TypeScript implementation of Netlify's rewrites and redirects engine | [![npm version](https://img.shields.io/npm/v/@netlify/redirects.svg)](https://www.npmjs.com/package/@netlify/redirects) | +| 🏛️ [@netlify/runtime](packages/runtime) | Netlify compute runtime | [![npm version](https://img.shields.io/npm/v/@netlify/runtime.svg)](https://www.npmjs.com/package/@netlify/runtime) | +| 🔨 [@netlify/runtime-utils](packages/runtime-utils) | Cross-environment utilities for the Netlify runtime | [![npm version](https://img.shields.io/npm/v/@netlify/runtime-utils.svg)](https://www.npmjs.com/package/@netlify/runtime-utils) | +| 📁 [@netlify/static](packages/static) | TypeScript implementation of Netlify's static file serving logic | [![npm version](https://img.shields.io/npm/v/@netlify/static.svg)](https://www.npmjs.com/package/@netlify/static) | +| 🔢 [@netlify/types](packages/types) | TypeScript types for Netlify platform primitives | [![npm version](https://img.shields.io/npm/v/@netlify/types.svg)](https://www.npmjs.com/package/@netlify/types) | +| 🔌 [@netlify/vite-plugin](packages/vite-plugin) | Vite plugin with a local emulation of the Netlify environment | [![npm version](https://img.shields.io/npm/v/@netlify/vite-plugin.svg)](https://www.npmjs.com/package/@netlify/vite-plugin) | +| 🔌 [@netlify/vite-plugin-tanstack-start](packages/vite-plugin-tanstack-start) | Vite plugin for TanStack Start on Netlify | [![npm version](https://img.shields.io/npm/v/@netlify/vite-plugin.svg)](https://www.npmjs.com/package/@netlify/vite-plugin-tanstack-start) | diff --git a/eslint.config.js b/eslint.config.js index 945fdb6a..91d0eed7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,9 @@ export default tseslint.config( // Global rules and configuration includeIgnoreFile(path.resolve(__dirname, '.gitignore')), ...packageIgnores, + { + ignores: ['**/fixtures/*'], + }, { // Uses its own eslint setup ignores: ['packages/nuxt-module/'], diff --git a/package-lock.json b/package-lock.json index c467e9d0..c1e6a96e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "packages/ai", "packages/nuxt-module", "packages/vite-plugin", + "packages/vite-plugin-tanstack-start", "packages/otel" ], "devDependencies": { @@ -2595,6 +2596,10 @@ "resolved": "packages/vite-plugin", "link": true }, + "node_modules/@netlify/vite-plugin-tanstack-start": { + "resolved": "packages/vite-plugin-tanstack-start", + "link": true + }, "node_modules/@netlify/zip-it-and-ship-it": { "version": "14.1.7", "resolved": "https://registry.npmjs.org/@netlify/zip-it-and-ship-it/-/zip-it-and-ship-it-14.1.7.tgz", @@ -8896,6 +8901,20 @@ "node": ">=0.10.0" } }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -18186,9 +18205,9 @@ } }, "node_modules/vite": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", - "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.6.tgz", + "integrity": "sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ==", "dev": true, "license": "MIT", "dependencies": { @@ -19920,13 +19939,14 @@ "license": "MIT", "dependencies": { "@netlify/dev": "4.5.10", - "@netlify/dev-utils": "^4.1.3" + "@netlify/dev-utils": "^4.1.3", + "dedent": "^1.7.0" }, "devDependencies": { "@types/node": "^20.17.57", "playwright": "^1.52.0", "tsup": "^8.0.0", - "vite": "^6.3.4", + "vite": "^6.3.6", "vitest": "^3.0.0" }, "engines": { @@ -19936,6 +19956,95 @@ "vite": "^5 || ^6 || ^7" } }, + "packages/vite-plugin-tanstack-start": { + "name": "@netlify/vite-plugin-tanstack-start", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@netlify/vite-plugin": "^2.5.10" + }, + "devDependencies": { + "@netlify/dev-utils": "^4.1.3", + "@types/node": "^22.18.5", + "normalize-package-data": "^8.0.0", + "playwright": "^1.52.0", + "semver": "^7.7.2", + "tsup": "^8.0.0", + "vite": "^7.1.5", + "vitest": "^3.0.0" + }, + "engines": { + "node": "^22.12.0" + }, + "peerDependencies": { + "@tanstack/react-start": "alpha", + "@tanstack/solid-start": "alpha", + "vite": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + } + } + }, + "packages/vite-plugin-tanstack-start/node_modules/@types/node": { + "version": "22.18.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", + "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/vite-plugin-tanstack-start/node_modules/hosted-git-info": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.0.tgz", + "integrity": "sha512-gEf705MZLrDPkbbhi8PnoO4ZwYgKoNL+ISZ3AjZMht2r3N5tuTwncyDi6Fv2/qDnMmZxgs0yI8WDOyR8q3G+SQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/vite-plugin-tanstack-start/node_modules/lru-cache": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz", + "integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "packages/vite-plugin-tanstack-start/node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/vite-plugin-tanstack-start/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "packages/vite-plugin/node_modules/@types/node": { "version": "20.19.11", "dev": true, diff --git a/package.json b/package.json index d038746b..63563191 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "packages/ai", "packages/nuxt-module", "packages/vite-plugin", + "packages/vite-plugin-tanstack-start", "packages/otel" ], "version": "0.0.0", diff --git a/packages/dev-utils/src/lib/logger.ts b/packages/dev-utils/src/lib/logger.ts index 2acc1fdd..b356e143 100644 --- a/packages/dev-utils/src/lib/logger.ts +++ b/packages/dev-utils/src/lib/logger.ts @@ -13,3 +13,5 @@ export const netlifyCommand = ansis.cyanBright export const netlifyCyan = ansis.rgb(40, 180, 170) export const netlifyBanner = netlifyCyan('⬥ Netlify') + +export const warning = (message: string): string => ansis.yellow(`⚠ Warning: ${message}`) diff --git a/packages/dev-utils/src/main.ts b/packages/dev-utils/src/main.ts index 3eafd968..e6fa622f 100644 --- a/packages/dev-utils/src/main.ts +++ b/packages/dev-utils/src/main.ts @@ -8,7 +8,7 @@ export { headers, toMultiValueHeaders } from './lib/headers.js' export { getGlobalConfigStore, GlobalConfigStore, resetConfigCache } from './lib/global-config.js' export { Handler } from './lib/handler.js' export { LocalState } from './lib/local-state.js' -export { type Logger, netlifyCommand, netlifyCyan, netlifyBanner } from './lib/logger.js' +export { type Logger, netlifyCommand, netlifyCyan, netlifyBanner, warning } from './lib/logger.js' export { memoize, MemoizeCache } from './lib/memoize.js' export { killProcess, type ProcessRef } from './lib/process.js' export { HTTPServer } from './server/http_server.js' diff --git a/packages/dev-utils/src/test/fixture.ts b/packages/dev-utils/src/test/fixture.ts index 25b310ef..bb68a10c 100644 --- a/packages/dev-utils/src/test/fixture.ts +++ b/packages/dev-utils/src/test/fixture.ts @@ -9,6 +9,7 @@ import tmp from 'tmp-promise' const run = promisify(exec) export class Fixture { directory?: tmp.DirectoryResult + sourceDirectory?: string files: Record npmDependencies: Record @@ -40,7 +41,9 @@ export class Fixture { const packageJSONPath = join(directory, 'package.json') await fs.writeFile(packageJSONPath, JSON.stringify(packageJSON, null, 2)) + console.debug('Installing npm dependencies in fixture...') await run('npm install', { cwd: directory }) + console.debug('Installed npm dependencies in fixture') } async create() { @@ -56,6 +59,12 @@ export class Fixture { } } + if (this.sourceDirectory) { + console.debug(`Copying fixture from ${this.sourceDirectory} to ${this.directory.path}`) + await fs.cp(this.sourceDirectory, this.directory.path, { recursive: true }) + console.debug('Copied fixture') + } + for (const relativePath in this.files) { const filePath = join(this.directory.path, relativePath) @@ -87,6 +96,12 @@ export class Fixture { await fs.rm(this.directory!.path, { force: true, recursive: true }) } + fromDirectory(path: string) { + this.sourceDirectory = path + + return this + } + withFile(path: string, contents: string | Buffer) { this.files[path] = contents diff --git a/packages/vite-plugin-tanstack-start/.gitignore b/packages/vite-plugin-tanstack-start/.gitignore new file mode 100644 index 00000000..de4d1f00 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/packages/vite-plugin-tanstack-start/CHANGELOG.md b/packages/vite-plugin-tanstack-start/CHANGELOG.md new file mode 100644 index 00000000..825c32f0 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/packages/vite-plugin-tanstack-start/README.md b/packages/vite-plugin-tanstack-start/README.md new file mode 100644 index 00000000..efb45009 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/README.md @@ -0,0 +1,50 @@ +# @netlify/vite-plugin-tanstack-start + +This Vite plugin configures your TanStack Start app for **deployment** to Netlify and provides full local emulation of +the Netlify platform directly in `vite dev`. + +## Features + +- Configures `vite build` to prepare your app's production build for deployment to Netlify + - See + [full TanStack Start deployment docs for more](https://docs.netlify.com/build/frameworks/framework-setup-guides/tanstack-start/#deploy-to-netlify) +- Configures `vite dev` to behave just like the production Netlify platform, but locally on your machine + - This has all the same features as `@netlify/vite-plugin`. + [Check out its docs for details](/packages/vite-plugin/README.md). + +## Installation + +```bash +npm install -D @netlify/vite-plugin-tanstack-start +``` + +## Configuration options + +The plugin accepts the following options: + +```typescript +{ + dev: { + edgeFunctions: { + enabled: false, + }, + // ... All dev options are supported here. + // See https://www.npmjs.com/package/@netlify/vite-plugin. + }, +} +``` + +## Usage + +Add the plugin to your `vite.config.js` or `vite.config.ts`: + +```js +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import react from '@vitejs/plugin-react' +import netlify from '@netlify/vite-plugin-tanstack-start' + +export default defineConfig({ + plugins: [tanstackStart(), react(), netlify()], +}) +``` diff --git a/packages/vite-plugin-tanstack-start/package.json b/packages/vite-plugin-tanstack-start/package.json new file mode 100644 index 00000000..44060947 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/package.json @@ -0,0 +1,64 @@ +{ + "name": "@netlify/vite-plugin-tanstack-start", + "version": "0.0.0", + "description": "Vite plugin for TanStack Start on Netlify", + "type": "module", + "engines": { + "node": "^22.12.0" + }, + "main": "./dist/main.js", + "exports": "./dist/main.js", + "types": "./dist/main.d.ts", + "files": [ + "dist/**/*" + ], + "scripts": { + "build": "tsup-node", + "prepack": "npm run build", + "test": "vitest run", + "test:dev": "vitest", + "test:ci": "vitest run", + "dev": "tsup-node --watch", + "publint": "npx -y publint --strict" + }, + "keywords": [ + "netlify", + "tanstack", + "tanstack-start", + "vite-plugin", + "dev", + "build" + ], + "license": "MIT", + "repository": "netlify/primitives", + "bugs": { + "url": "https://github.com/netlify/primitives/issues" + }, + "author": "Netlify Inc.", + "devDependencies": { + "@netlify/dev-utils": "^4.1.3", + "@types/node": "^22.18.5", + "normalize-package-data": "^8.0.0", + "playwright": "^1.52.0", + "semver": "^7.7.2", + "tsup": "^8.0.0", + "vite": "^7.1.5", + "vitest": "^3.0.0" + }, + "dependencies": { + "@netlify/vite-plugin": "^2.5.10" + }, + "peerDependencies": { + "@tanstack/react-start": "alpha", + "@tanstack/solid-start": "alpha", + "vite": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + } + } +} diff --git a/packages/vite-plugin-tanstack-start/src/main.ts b/packages/vite-plugin-tanstack-start/src/main.ts new file mode 100644 index 00000000..a7acb657 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/src/main.ts @@ -0,0 +1,27 @@ +import type { Plugin } from 'vite' +import createNetlifyPlugin, { type NetlifyPluginOptions } from '@netlify/vite-plugin' + +type DevOptions = Omit + +export interface PluginOptions { + /** + * Deploy SSR handler to Netlify Edge Functions instead of Netlify Functions (default: false). + */ + edgeSSR?: boolean + /** + * Optional configuration of Netlify dev features + * @see {link https://www.npmjs.com/package/@netlify/vite-plugin} + */ + dev?: DevOptions +} + +export default function createNetlifyTanstackStartPlugin(options: PluginOptions = {}): Plugin[] { + const netlifyPlugin = createNetlifyPlugin({ + build: { + enabled: true, + edgeSSR: options.edgeSSR ?? false, + }, + ...options.dev, + }) as Plugin[] + return netlifyPlugin +} diff --git a/packages/vite-plugin-tanstack-start/test/e2e/build.test.ts b/packages/vite-plugin-tanstack-start/test/e2e/build.test.ts new file mode 100644 index 00000000..9c486abb --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/e2e/build.test.ts @@ -0,0 +1,74 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { Fixture } from '@netlify/dev-utils' +import { type Browser, type Page, chromium } from 'playwright' +import semver from 'semver' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest' + +import { deploySite } from '../support/netlify-deploy.js' + +const FIXTURES_DIR = fileURLToPath(new URL('../fixtures', import.meta.url)) + +const isSupportedNode = semver.gte(process.versions.node, '22.12.0') +// TODO(serhalp) e2e fixture deploy fails on Windows - investigate and re-enable +const isWindows = process.platform === 'win32' + +describe.runIf(isSupportedNode && !isWindows)('build output when deployed to Netlify', () => { + let fixture: Fixture + let baseUrl: string + beforeAll(async () => { + fixture = new Fixture().fromDirectory(path.join(FIXTURES_DIR, 'start-basic-alpha')) + const fixtureRoot = await fixture.create() + const { url } = await deploySite(fixtureRoot) + baseUrl = url + }) + afterAll(async () => fixture.destroy()) + + let browser: Browser + let page: Page + beforeEach(async () => { + browser = await chromium.launch() + page = await browser.newPage() + }) + afterEach(async () => { + await browser.close() + }) + + test('Renders SSR pages', async () => { + const response = await page.goto(baseUrl) + expect(response?.status()).toBe(200) + expect(await response?.text()).toContain('Welcome Home!!!') + }) + + test('Renders custom 404 SSR page', async () => { + const response = await page.goto(`${baseUrl}/this-route-does-not-exist`) + expect(response?.status()).toBe(404) + expect(await response?.text()).toContain('The page you are looking for does not exist.') + }) + + test('Renders streamed React Suspense components', async () => { + const response = await page.goto(`${baseUrl}/deferred`) + expect(response?.status()).toBe(200) + // This is eventually rendered on the client once it's ready + await page.waitForSelector('text=Hello deferred!') + }) + + test('Handles Server Routes', async () => { + const response = await page.goto(`${baseUrl}/api/users`) + expect(response?.status()).toBe(200) + const body = (await response?.text()) ?? '' + expect(body).toContain('Ervin Howell') + expect(() => { + JSON.parse(body) + }, 'Expected API endpoint to return JSON').not.toThrow() + }) + + test('Handles Server Functions', async () => { + const response = await page.goto(`${baseUrl}/posts`) + expect(response?.status()).toBe(200) + // A Server Function is used on client-side navigation to a post page + await page.click('text=sunt aut facere repe') + await page.waitForSelector('text=quia et suscipit suscipit') + }) +}) diff --git a/packages/vite-plugin-tanstack-start/test/e2e/dev.test.ts b/packages/vite-plugin-tanstack-start/test/e2e/dev.test.ts new file mode 100644 index 00000000..764dda08 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/e2e/dev.test.ts @@ -0,0 +1,5 @@ +import { describe } from 'vitest' + +// TODO(serhalp): I had to cut my losses after spending a whole day trying to make this work. +// Try again with a clear head later :/ +describe.todo('development') diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/.gitignore b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/.gitignore new file mode 100644 index 00000000..6ab0517d --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/.gitignore @@ -0,0 +1,20 @@ +node_modules +package-lock.json +yarn.lock + +.DS_Store +.cache +.env +.vercel +.output +.nitro +/build/ +/api/ +/server/build +/public/build# Sentry Config File +.env.sentry-build-plugin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +.tanstack \ No newline at end of file diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/.prettierignore b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/.prettierignore new file mode 100644 index 00000000..2be5eaa6 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts \ No newline at end of file diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/README.md b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/README.md new file mode 100644 index 00000000..90cba4aa --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/README.md @@ -0,0 +1,72 @@ +# Welcome to TanStack.com! + +This site is built with TanStack Router! + +- [TanStack Router Docs](https://tanstack.com/router) + +It's deployed automagically with Netlify! + +- [Netlify](https://netlify.com/) + +## Development + +From your terminal: + +```sh +pnpm install +pnpm dev +``` + +This starts your app in development mode, rebuilding assets on file changes. + +## Editing and previewing the docs of TanStack projects locally + +The documentations for all TanStack projects except for `React Charts` are hosted on [https://tanstack.com](https://tanstack.com), powered by this TanStack Router app. +In production, the markdown doc pages are fetched from the GitHub repos of the projects, but in development they are read from the local file system. + +Follow these steps if you want to edit the doc pages of a project (in these steps we'll assume it's [`TanStack/form`](https://github.com/tanstack/form)) and preview them locally : + +1. Create a new directory called `tanstack`. + +```sh +mkdir tanstack +``` + +2. Enter the directory and clone this repo and the repo of the project there. + +```sh +cd tanstack +git clone git@github.com:TanStack/tanstack.com.git +git clone git@github.com:TanStack/form.git +``` + +> [!NOTE] +> Your `tanstack` directory should look like this: +> +> ``` +> tanstack/ +> | +> +-- form/ +> | +> +-- tanstack.com/ +> ``` + +> [!WARNING] +> Make sure the name of the directory in your local file system matches the name of the project's repo. For example, `tanstack/form` must be cloned into `form` (this is the default) instead of `some-other-name`, because that way, the doc pages won't be found. + +3. Enter the `tanstack/tanstack.com` directory, install the dependencies and run the app in dev mode: + +```sh +cd tanstack.com +pnpm i +# The app will run on https://localhost:3000 by default +pnpm dev +``` + +4. Now you can visit http://localhost:3000/form/latest/docs/overview in the browser and see the changes you make in `tanstack/form/docs`. + +> [!NOTE] +> The updated pages need to be manually reloaded in the browser. + +> [!WARNING] +> You will need to update the `docs/config.json` file (in the project's repo) if you add a new doc page! diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/netlify.toml b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/netlify.toml new file mode 100644 index 00000000..9db47766 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/netlify.toml @@ -0,0 +1,3 @@ +[build] + command = "npm run build" + publish = "dist/client" diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/package.json b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/package.json new file mode 100644 index 00000000..c1835011 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/package.json @@ -0,0 +1,33 @@ +{ + "name": "tanstack-start-example-basic", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build && tsc --noEmit", + "start": "node .output/server/index.mjs" + }, + "dependencies": { + "@tanstack/react-router": "^1.132.0-alpha.21", + "@tanstack/react-router-devtools": "^1.132.0-alpha.21", + "@tanstack/react-start": "^1.132.0-alpha.22", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.6.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "@netlify/vite-plugin-tanstack-start": "file:../../..", + "@types/node": "^22.5.4", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.6.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^7.1.1", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/postcss.config.mjs b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/postcss.config.mjs new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/android-chrome-192x192.png b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/android-chrome-192x192.png new file mode 100644 index 00000000..09c8324f Binary files /dev/null and b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/android-chrome-192x192.png differ diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/android-chrome-512x512.png b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/android-chrome-512x512.png new file mode 100644 index 00000000..11d626ea Binary files /dev/null and b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/android-chrome-512x512.png differ diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/apple-touch-icon.png b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/apple-touch-icon.png new file mode 100644 index 00000000..5a9423cc Binary files /dev/null and b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/apple-touch-icon.png differ diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/favicon-16x16.png b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/favicon-16x16.png new file mode 100644 index 00000000..e3389b00 Binary files /dev/null and b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/favicon-16x16.png differ diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/favicon-32x32.png b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/favicon-32x32.png new file mode 100644 index 00000000..900c77d4 Binary files /dev/null and b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/favicon-32x32.png differ diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/favicon.ico b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/favicon.ico new file mode 100644 index 00000000..1a175167 Binary files /dev/null and b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/favicon.ico differ diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/favicon.png b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/favicon.png new file mode 100644 index 00000000..1e77bc06 Binary files /dev/null and b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/favicon.png differ diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/site.webmanifest b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/site.webmanifest new file mode 100644 index 00000000..fa99de77 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/components/DefaultCatchBoundary.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000..f750e7bd --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,53 @@ +import { + ErrorComponent, + Link, + rootRouteId, + useMatch, + useRouter, +} from '@tanstack/react-router' +import type { ErrorComponentProps } from '@tanstack/react-router' + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + const isRoot = useMatch({ + strict: false, + select: (state) => state.id === rootRouteId, + }) + + console.error('DefaultCatchBoundary Error:', error) + + return ( +
+ +
+ + {isRoot ? ( + + Home + + ) : ( + { + e.preventDefault() + window.history.back() + }} + > + Go Back + + )} +
+
+ ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/components/NotFound.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/components/NotFound.tsx new file mode 100644 index 00000000..7b54fa56 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/components/NotFound.tsx @@ -0,0 +1,25 @@ +import { Link } from '@tanstack/react-router' + +export function NotFound({ children }: { children?: any }) { + return ( +
+
+ {children ||

The page you are looking for does not exist.

} +
+

+ + + Start Over + +

+
+ ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/components/PostError.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/components/PostError.tsx new file mode 100644 index 00000000..3573f469 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/components/PostError.tsx @@ -0,0 +1,5 @@ +import { ErrorComponent, ErrorComponentProps } from '@tanstack/react-router' + +export function PostErrorComponent({ error }: ErrorComponentProps) { + return +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/components/UserError.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/components/UserError.tsx new file mode 100644 index 00000000..ebea2f62 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/components/UserError.tsx @@ -0,0 +1,5 @@ +import { ErrorComponent, ErrorComponentProps } from '@tanstack/react-router' + +export function UserErrorComponent({ error }: ErrorComponentProps) { + return +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routeTree.gen.ts b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routeTree.gen.ts new file mode 100644 index 00000000..9f7ccda0 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routeTree.gen.ts @@ -0,0 +1,459 @@ +/* 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. + +import { createServerRootRoute } from '@tanstack/react-start/server' + +import { Route as rootRouteImport } from './routes/__root' +import { Route as UsersRouteImport } from './routes/users' +import { Route as RedirectRouteImport } from './routes/redirect' +import { Route as PostsRouteImport } from './routes/posts' +import { Route as DeferredRouteImport } from './routes/deferred' +import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' +import { Route as IndexRouteImport } from './routes/index' +import { Route as UsersIndexRouteImport } from './routes/users.index' +import { Route as PostsIndexRouteImport } from './routes/posts.index' +import { Route as UsersUserIdRouteImport } from './routes/users.$userId' +import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' +import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout' +import { Route as PostsPostIdDeepRouteImport } from './routes/posts_.$postId.deep' +import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' +import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a' +import { ServerRoute as CustomScriptDotjsServerRouteImport } from './routes/customScript[.]js' +import { ServerRoute as ApiUsersServerRouteImport } from './routes/api/users' +import { ServerRoute as ApiUsersUserIdServerRouteImport } from './routes/api/users.$userId' + +const rootServerRouteImport = createServerRootRoute() + +const UsersRoute = UsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => rootRouteImport, +} as any) +const RedirectRoute = RedirectRouteImport.update({ + id: '/redirect', + path: '/redirect', + getParentRoute: () => rootRouteImport, +} as any) +const PostsRoute = PostsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any) +const DeferredRoute = DeferredRouteImport.update({ + id: '/deferred', + path: '/deferred', + getParentRoute: () => rootRouteImport, +} as any) +const PathlessLayoutRoute = PathlessLayoutRouteImport.update({ + id: '/_pathlessLayout', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const UsersIndexRoute = UsersIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => UsersRoute, +} as any) +const PostsIndexRoute = PostsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => PostsRoute, +} as any) +const UsersUserIdRoute = UsersUserIdRouteImport.update({ + id: '/$userId', + path: '/$userId', + getParentRoute: () => UsersRoute, +} as any) +const PostsPostIdRoute = PostsPostIdRouteImport.update({ + id: '/$postId', + path: '/$postId', + getParentRoute: () => PostsRoute, +} as any) +const PathlessLayoutNestedLayoutRoute = + PathlessLayoutNestedLayoutRouteImport.update({ + id: '/_nested-layout', + getParentRoute: () => PathlessLayoutRoute, + } as any) +const PostsPostIdDeepRoute = PostsPostIdDeepRouteImport.update({ + id: '/posts_/$postId/deep', + path: '/posts/$postId/deep', + getParentRoute: () => rootRouteImport, +} as any) +const PathlessLayoutNestedLayoutRouteBRoute = + PathlessLayoutNestedLayoutRouteBRouteImport.update({ + id: '/route-b', + path: '/route-b', + getParentRoute: () => PathlessLayoutNestedLayoutRoute, + } as any) +const PathlessLayoutNestedLayoutRouteARoute = + PathlessLayoutNestedLayoutRouteARouteImport.update({ + id: '/route-a', + path: '/route-a', + getParentRoute: () => PathlessLayoutNestedLayoutRoute, + } as any) +const CustomScriptDotjsServerRoute = CustomScriptDotjsServerRouteImport.update({ + id: '/customScript.js', + path: '/customScript.js', + getParentRoute: () => rootServerRouteImport, +} as any) +const ApiUsersServerRoute = ApiUsersServerRouteImport.update({ + id: '/api/users', + path: '/api/users', + getParentRoute: () => rootServerRouteImport, +} as any) +const ApiUsersUserIdServerRoute = ApiUsersUserIdServerRouteImport.update({ + id: '/$userId', + path: '/$userId', + getParentRoute: () => ApiUsersServerRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/deferred': typeof DeferredRoute + '/posts': typeof PostsRouteWithChildren + '/redirect': typeof RedirectRoute + '/users': typeof UsersRouteWithChildren + '/posts/$postId': typeof PostsPostIdRoute + '/users/$userId': typeof UsersUserIdRoute + '/posts/': typeof PostsIndexRoute + '/users/': typeof UsersIndexRoute + '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute + '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute + '/posts/$postId/deep': typeof PostsPostIdDeepRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/deferred': typeof DeferredRoute + '/redirect': typeof RedirectRoute + '/posts/$postId': typeof PostsPostIdRoute + '/users/$userId': typeof UsersUserIdRoute + '/posts': typeof PostsIndexRoute + '/users': typeof UsersIndexRoute + '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute + '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute + '/posts/$postId/deep': typeof PostsPostIdDeepRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren + '/deferred': typeof DeferredRoute + '/posts': typeof PostsRouteWithChildren + '/redirect': typeof RedirectRoute + '/users': typeof UsersRouteWithChildren + '/_pathlessLayout/_nested-layout': typeof PathlessLayoutNestedLayoutRouteWithChildren + '/posts/$postId': typeof PostsPostIdRoute + '/users/$userId': typeof UsersUserIdRoute + '/posts/': typeof PostsIndexRoute + '/users/': typeof UsersIndexRoute + '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute + '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute + '/posts_/$postId/deep': typeof PostsPostIdDeepRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/deferred' + | '/posts' + | '/redirect' + | '/users' + | '/posts/$postId' + | '/users/$userId' + | '/posts/' + | '/users/' + | '/route-a' + | '/route-b' + | '/posts/$postId/deep' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/deferred' + | '/redirect' + | '/posts/$postId' + | '/users/$userId' + | '/posts' + | '/users' + | '/route-a' + | '/route-b' + | '/posts/$postId/deep' + id: + | '__root__' + | '/' + | '/_pathlessLayout' + | '/deferred' + | '/posts' + | '/redirect' + | '/users' + | '/_pathlessLayout/_nested-layout' + | '/posts/$postId' + | '/users/$userId' + | '/posts/' + | '/users/' + | '/_pathlessLayout/_nested-layout/route-a' + | '/_pathlessLayout/_nested-layout/route-b' + | '/posts_/$postId/deep' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren + DeferredRoute: typeof DeferredRoute + PostsRoute: typeof PostsRouteWithChildren + RedirectRoute: typeof RedirectRoute + UsersRoute: typeof UsersRouteWithChildren + PostsPostIdDeepRoute: typeof PostsPostIdDeepRoute +} +export interface FileServerRoutesByFullPath { + '/customScript.js': typeof CustomScriptDotjsServerRoute + '/api/users': typeof ApiUsersServerRouteWithChildren + '/api/users/$userId': typeof ApiUsersUserIdServerRoute +} +export interface FileServerRoutesByTo { + '/customScript.js': typeof CustomScriptDotjsServerRoute + '/api/users': typeof ApiUsersServerRouteWithChildren + '/api/users/$userId': typeof ApiUsersUserIdServerRoute +} +export interface FileServerRoutesById { + __root__: typeof rootServerRouteImport + '/customScript.js': typeof CustomScriptDotjsServerRoute + '/api/users': typeof ApiUsersServerRouteWithChildren + '/api/users/$userId': typeof ApiUsersUserIdServerRoute +} +export interface FileServerRouteTypes { + fileServerRoutesByFullPath: FileServerRoutesByFullPath + fullPaths: '/customScript.js' | '/api/users' | '/api/users/$userId' + fileServerRoutesByTo: FileServerRoutesByTo + to: '/customScript.js' | '/api/users' | '/api/users/$userId' + id: '__root__' | '/customScript.js' | '/api/users' | '/api/users/$userId' + fileServerRoutesById: FileServerRoutesById +} +export interface RootServerRouteChildren { + CustomScriptDotjsServerRoute: typeof CustomScriptDotjsServerRoute + ApiUsersServerRoute: typeof ApiUsersServerRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/users': { + id: '/users' + path: '/users' + fullPath: '/users' + preLoaderRoute: typeof UsersRouteImport + parentRoute: typeof rootRouteImport + } + '/redirect': { + id: '/redirect' + path: '/redirect' + fullPath: '/redirect' + preLoaderRoute: typeof RedirectRouteImport + parentRoute: typeof rootRouteImport + } + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteImport + parentRoute: typeof rootRouteImport + } + '/deferred': { + id: '/deferred' + path: '/deferred' + fullPath: '/deferred' + preLoaderRoute: typeof DeferredRouteImport + parentRoute: typeof rootRouteImport + } + '/_pathlessLayout': { + id: '/_pathlessLayout' + path: '' + fullPath: '' + preLoaderRoute: typeof PathlessLayoutRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/users/': { + id: '/users/' + path: '/' + fullPath: '/users/' + preLoaderRoute: typeof UsersIndexRouteImport + parentRoute: typeof UsersRoute + } + '/posts/': { + id: '/posts/' + path: '/' + fullPath: '/posts/' + preLoaderRoute: typeof PostsIndexRouteImport + parentRoute: typeof PostsRoute + } + '/users/$userId': { + id: '/users/$userId' + path: '/$userId' + fullPath: '/users/$userId' + preLoaderRoute: typeof UsersUserIdRouteImport + parentRoute: typeof UsersRoute + } + '/posts/$postId': { + id: '/posts/$postId' + path: '/$postId' + fullPath: '/posts/$postId' + preLoaderRoute: typeof PostsPostIdRouteImport + parentRoute: typeof PostsRoute + } + '/_pathlessLayout/_nested-layout': { + id: '/_pathlessLayout/_nested-layout' + path: '' + fullPath: '' + preLoaderRoute: typeof PathlessLayoutNestedLayoutRouteImport + parentRoute: typeof PathlessLayoutRoute + } + '/posts_/$postId/deep': { + id: '/posts_/$postId/deep' + path: '/posts/$postId/deep' + fullPath: '/posts/$postId/deep' + preLoaderRoute: typeof PostsPostIdDeepRouteImport + parentRoute: typeof rootRouteImport + } + '/_pathlessLayout/_nested-layout/route-b': { + id: '/_pathlessLayout/_nested-layout/route-b' + path: '/route-b' + fullPath: '/route-b' + preLoaderRoute: typeof PathlessLayoutNestedLayoutRouteBRouteImport + parentRoute: typeof PathlessLayoutNestedLayoutRoute + } + '/_pathlessLayout/_nested-layout/route-a': { + id: '/_pathlessLayout/_nested-layout/route-a' + path: '/route-a' + fullPath: '/route-a' + preLoaderRoute: typeof PathlessLayoutNestedLayoutRouteARouteImport + parentRoute: typeof PathlessLayoutNestedLayoutRoute + } + } +} +declare module '@tanstack/react-start/server' { + interface ServerFileRoutesByPath { + '/customScript.js': { + id: '/customScript.js' + path: '/customScript.js' + fullPath: '/customScript.js' + preLoaderRoute: typeof CustomScriptDotjsServerRouteImport + parentRoute: typeof rootServerRouteImport + } + '/api/users': { + id: '/api/users' + path: '/api/users' + fullPath: '/api/users' + preLoaderRoute: typeof ApiUsersServerRouteImport + parentRoute: typeof rootServerRouteImport + } + '/api/users/$userId': { + id: '/api/users/$userId' + path: '/$userId' + fullPath: '/api/users/$userId' + preLoaderRoute: typeof ApiUsersUserIdServerRouteImport + parentRoute: typeof ApiUsersServerRoute + } + } +} + +interface PathlessLayoutNestedLayoutRouteChildren { + PathlessLayoutNestedLayoutRouteARoute: typeof PathlessLayoutNestedLayoutRouteARoute + PathlessLayoutNestedLayoutRouteBRoute: typeof PathlessLayoutNestedLayoutRouteBRoute +} + +const PathlessLayoutNestedLayoutRouteChildren: PathlessLayoutNestedLayoutRouteChildren = + { + PathlessLayoutNestedLayoutRouteARoute: + PathlessLayoutNestedLayoutRouteARoute, + PathlessLayoutNestedLayoutRouteBRoute: + PathlessLayoutNestedLayoutRouteBRoute, + } + +const PathlessLayoutNestedLayoutRouteWithChildren = + PathlessLayoutNestedLayoutRoute._addFileChildren( + PathlessLayoutNestedLayoutRouteChildren, + ) + +interface PathlessLayoutRouteChildren { + PathlessLayoutNestedLayoutRoute: typeof PathlessLayoutNestedLayoutRouteWithChildren +} + +const PathlessLayoutRouteChildren: PathlessLayoutRouteChildren = { + PathlessLayoutNestedLayoutRoute: PathlessLayoutNestedLayoutRouteWithChildren, +} + +const PathlessLayoutRouteWithChildren = PathlessLayoutRoute._addFileChildren( + PathlessLayoutRouteChildren, +) + +interface PostsRouteChildren { + PostsPostIdRoute: typeof PostsPostIdRoute + PostsIndexRoute: typeof PostsIndexRoute +} + +const PostsRouteChildren: PostsRouteChildren = { + PostsPostIdRoute: PostsPostIdRoute, + PostsIndexRoute: PostsIndexRoute, +} + +const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) + +interface UsersRouteChildren { + UsersUserIdRoute: typeof UsersUserIdRoute + UsersIndexRoute: typeof UsersIndexRoute +} + +const UsersRouteChildren: UsersRouteChildren = { + UsersUserIdRoute: UsersUserIdRoute, + UsersIndexRoute: UsersIndexRoute, +} + +const UsersRouteWithChildren = UsersRoute._addFileChildren(UsersRouteChildren) + +interface ApiUsersServerRouteChildren { + ApiUsersUserIdServerRoute: typeof ApiUsersUserIdServerRoute +} + +const ApiUsersServerRouteChildren: ApiUsersServerRouteChildren = { + ApiUsersUserIdServerRoute: ApiUsersUserIdServerRoute, +} + +const ApiUsersServerRouteWithChildren = ApiUsersServerRoute._addFileChildren( + ApiUsersServerRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + PathlessLayoutRoute: PathlessLayoutRouteWithChildren, + DeferredRoute: DeferredRoute, + PostsRoute: PostsRouteWithChildren, + RedirectRoute: RedirectRoute, + UsersRoute: UsersRouteWithChildren, + PostsPostIdDeepRoute: PostsPostIdDeepRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() +const rootServerRouteChildren: RootServerRouteChildren = { + CustomScriptDotjsServerRoute: CustomScriptDotjsServerRoute, + ApiUsersServerRoute: ApiUsersServerRouteWithChildren, +} +export const serverRouteTree = rootServerRouteImport + ._addFileChildren(rootServerRouteChildren) + ._addFileTypes() diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/router.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/router.tsx new file mode 100644 index 00000000..c76eb021 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/router.tsx @@ -0,0 +1,22 @@ +import { createRouter as createTanStackRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' +import { NotFound } from './components/NotFound' + +export function createRouter() { + const router = createTanStackRouter({ + routeTree, + defaultPreload: 'intent', + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , + scrollRestoration: true, + }) + + return router +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/__root.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/__root.tsx new file mode 100644 index 00000000..346409e9 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/__root.tsx @@ -0,0 +1,131 @@ +/// +import { + HeadContent, + Link, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import * as React from 'react' +import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' +import { NotFound } from '~/components/NotFound' +import appCss from '~/styles/app.css?url' +import { seo } from '~/utils/seo' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ...seo({ + title: + 'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework', + description: `TanStack Start is a type-safe, client-first, full-stack React framework. `, + }), + ], + links: [ + { rel: 'stylesheet', href: appCss }, + { + rel: 'apple-touch-icon', + sizes: '180x180', + href: '/apple-touch-icon.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '32x32', + href: '/favicon-32x32.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '16x16', + href: '/favicon-16x16.png', + }, + { rel: 'manifest', href: '/site.webmanifest', color: '#fffff' }, + { rel: 'icon', href: '/favicon.ico' }, + ], + scripts: [ + { + src: '/customScript.js', + type: 'text/javascript', + }, + ], + }), + errorComponent: DefaultCatchBoundary, + notFoundComponent: () => , + shellComponent: RootDocument, +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + +
+ + Home + {' '} + + Posts + {' '} + + Users + {' '} + + Pathless Layout + {' '} + + Deferred + {' '} + + This Route Does Not Exist + +
+
+ {children} + + + + + ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/_pathlessLayout.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/_pathlessLayout.tsx new file mode 100644 index 00000000..c3b12442 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/_pathlessLayout.tsx @@ -0,0 +1,16 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_pathlessLayout')({ + component: LayoutComponent, +}) + +function LayoutComponent() { + return ( +
+
I'm a layout
+
+ +
+
+ ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/_pathlessLayout/_nested-layout.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/_pathlessLayout/_nested-layout.tsx new file mode 100644 index 00000000..9a48b73a --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/_pathlessLayout/_nested-layout.tsx @@ -0,0 +1,34 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_pathlessLayout/_nested-layout')({ + component: LayoutComponent, +}) + +function LayoutComponent() { + return ( +
+
I'm a nested layout
+
+ + Go to route A + + + Go to route B + +
+
+ +
+
+ ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/_pathlessLayout/_nested-layout/route-a.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/_pathlessLayout/_nested-layout/route-a.tsx new file mode 100644 index 00000000..0213f151 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/_pathlessLayout/_nested-layout/route-a.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/_pathlessLayout/_nested-layout/route-a')( + { + component: LayoutAComponent, + }, +) + +function LayoutAComponent() { + return
I'm A!
+} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/_pathlessLayout/_nested-layout/route-b.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/_pathlessLayout/_nested-layout/route-b.tsx new file mode 100644 index 00000000..3d909523 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/_pathlessLayout/_nested-layout/route-b.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/_pathlessLayout/_nested-layout/route-b')( + { + component: LayoutBComponent, + }, +) + +function LayoutBComponent() { + return
I'm B!
+} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/api/users.$userId.ts b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/api/users.$userId.ts new file mode 100644 index 00000000..c5c25399 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/api/users.$userId.ts @@ -0,0 +1,28 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' +import { json } from '@tanstack/react-start' +import type { User } from '~/utils/users' + +export const ServerRoute = createServerFileRoute('/api/users/$userId').methods({ + GET: async ({ params, request }) => { + console.info(`Fetching users by id=${params.userId}... @`, request.url) + try { + const res = await fetch( + 'https://jsonplaceholder.typicode.com/users/' + params.userId, + ) + if (!res.ok) { + throw new Error('Failed to fetch user') + } + + const user = (await res.json()) as User + + return json({ + id: user.id, + name: user.name, + email: user.email, + }) + } catch (e) { + console.error(e) + return json({ error: 'User not found' }, { status: 404 }) + } + }, +}) diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/api/users.ts b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/api/users.ts new file mode 100644 index 00000000..cb7f9e97 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/api/users.ts @@ -0,0 +1,62 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' +import { getRequestHeaders } from '@tanstack/react-start/server' +import { createMiddleware, json } from '@tanstack/react-start' +import type { User } from '~/utils/users' + +const userLoggerMiddleware = createMiddleware({ type: 'request' }).server( + async ({ next, request }) => { + console.info('In: /users') + console.info('Request Headers:', getRequestHeaders()) + const result = await next() + result.response.headers.set('x-users', 'true') + console.info('Out: /users') + return result + }, +) + +const testParentMiddleware = createMiddleware({ type: 'request' }).server( + async ({ next, request }) => { + console.info('In: testParentMiddleware') + const result = await next() + result.response.headers.set('x-test-parent', 'true') + console.info('Out: testParentMiddleware') + return result + }, +) + +const testMiddleware = createMiddleware({ type: 'request' }) + .middleware([testParentMiddleware]) + .server(async ({ next, request }) => { + console.info('In: testMiddleware') + const result = await next() + result.response.headers.set('x-test', 'true') + + // if (Math.random() > 0.5) { + // throw new Response(null, { + // status: 302, + // headers: { Location: 'https://www.google.com' }, + // }) + // } + + console.info('Out: testMiddleware') + return result + }) + +export const ServerRoute = createServerFileRoute('/api/users') + .middleware([testMiddleware, userLoggerMiddleware, testParentMiddleware]) + .methods({ + GET: async ({ request }) => { + console.info('GET /api/users @', request.url) + console.info('Fetching users... @', request.url) + const res = await fetch('https://jsonplaceholder.typicode.com/users') + if (!res.ok) { + throw new Error('Failed to fetch users') + } + + const data = (await res.json()) as Array + + const list = data.slice(0, 10) + + return json(list.map((u) => ({ id: u.id, name: u.name, email: u.email }))) + }, + }) diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/customScript[.]js.ts b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/customScript[.]js.ts new file mode 100644 index 00000000..92cc40da --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/customScript[.]js.ts @@ -0,0 +1,10 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' +export const ServerRoute = createServerFileRoute('/customScript.js').methods({ + GET: async ({ request }) => { + return new Response('console.log("Hello from customScript.js!")', { + headers: { + 'Content-Type': 'application/javascript', + }, + }) + }, +}) diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/deferred.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/deferred.tsx new file mode 100644 index 00000000..f3e09d1d --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/deferred.tsx @@ -0,0 +1,62 @@ +import { Await, createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { Suspense, useState } from 'react' + +const personServerFn = createServerFn({ method: 'GET' }) + .validator((d: string) => d) + .handler(({ data: name }) => { + return { name, randomNumber: Math.floor(Math.random() * 100) } + }) + +const slowServerFn = createServerFn({ method: 'GET' }) + .validator((d: string) => d) + .handler(async ({ data: name }) => { + await new Promise((r) => setTimeout(r, 1000)) + return { name, randomNumber: Math.floor(Math.random() * 100) } + }) + +export const Route = createFileRoute('/deferred')({ + loader: async () => { + return { + deferredStuff: new Promise((r) => + setTimeout(() => r('Hello deferred!'), 2000), + ), + deferredPerson: slowServerFn({ data: 'Tanner Linsley' }), + person: await personServerFn({ data: 'John Doe' }), + } + }, + component: Deferred, +}) + +function Deferred() { + const [count, setCount] = useState(0) + const { deferredStuff, deferredPerson, person } = Route.useLoaderData() + + return ( +
+
+ {person.name} - {person.randomNumber} +
+ Loading person...
}> + ( +
+ {data.name} - {data.randomNumber} +
+ )} + /> + + Loading stuff...}> +

{data}

} + /> +
+
Count: {count}
+
+ +
+ + ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/index.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/index.tsx new file mode 100644 index 00000000..09a907cb --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/index.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

Welcome Home!!!

+
+ ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/posts.$postId.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/posts.$postId.tsx new file mode 100644 index 00000000..f509f9a4 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/posts.$postId.tsx @@ -0,0 +1,34 @@ +import { Link, createFileRoute } from '@tanstack/react-router' +import { fetchPost } from '../utils/posts' +import { NotFound } from '~/components/NotFound' +import { PostErrorComponent } from '~/components/PostError' + +export const Route = createFileRoute('/posts/$postId')({ + loader: ({ params: { postId } }) => fetchPost({ data: postId }), + errorComponent: PostErrorComponent, + component: PostComponent, + notFoundComponent: () => { + return Post not found + }, +}) + +function PostComponent() { + const post = Route.useLoaderData() + + return ( +
+

{post.title}

+
{post.body}
+ + Deep View + +
+ ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/posts.index.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/posts.index.tsx new file mode 100644 index 00000000..c6592745 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/posts.index.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/posts/')({ + component: PostsIndexComponent, +}) + +function PostsIndexComponent() { + return
Select a post.
+} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/posts.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/posts.tsx new file mode 100644 index 00000000..ae490324 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/posts.tsx @@ -0,0 +1,38 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' +import { fetchPosts } from '../utils/posts' + +export const Route = createFileRoute('/posts')({ + loader: async () => fetchPosts(), + component: PostsComponent, +}) + +function PostsComponent() { + const posts = Route.useLoaderData() + + return ( +
+
    + {[...posts, { id: 'i-do-not-exist', title: 'Non-existent Post' }].map( + (post) => { + return ( +
  • + +
    {post.title.substring(0, 20)}
    + +
  • + ) + }, + )} +
+
+ +
+ ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/posts_.$postId.deep.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/posts_.$postId.deep.tsx new file mode 100644 index 00000000..29e6c39b --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/posts_.$postId.deep.tsx @@ -0,0 +1,29 @@ +import { Link, createFileRoute } from '@tanstack/react-router' +import { fetchPost } from '../utils/posts' +import { PostErrorComponent } from '~/components/PostError' + +export const Route = createFileRoute('/posts_/$postId/deep')({ + loader: async ({ params: { postId } }) => + fetchPost({ + data: postId, + }), + errorComponent: PostErrorComponent, + component: PostDeepComponent, +}) + +function PostDeepComponent() { + const post = Route.useLoaderData() + + return ( +
+ + ← All Posts + +

{post.title}

+
{post.body}
+
+ ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/redirect.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/redirect.tsx new file mode 100644 index 00000000..fa220b50 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/redirect.tsx @@ -0,0 +1,9 @@ +import { redirect, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/redirect')({ + beforeLoad: async () => { + throw redirect({ + to: '/posts', + }) + }, +}) diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/users.$userId.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/users.$userId.tsx new file mode 100644 index 00000000..e9bf081f --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/users.$userId.tsx @@ -0,0 +1,44 @@ +import { createFileRoute } from '@tanstack/react-router' +import { NotFound } from 'src/components/NotFound' +import { UserErrorComponent } from 'src/components/UserError' + +export const Route = createFileRoute('/users/$userId')({ + loader: async ({ params: { userId } }) => { + try { + const res = await fetch('/api/users/' + userId) + if (!res.ok) { + throw new Error('Unexpected status code') + } + + const data = await res.json() + + return data + } catch { + throw new Error('Failed to fetch user') + } + }, + errorComponent: UserErrorComponent, + component: UserComponent, + notFoundComponent: () => { + return User not found + }, +}) + +function UserComponent() { + const user = Route.useLoaderData() + + return ( +
+

{user.name}

+
{user.email}
+ +
+ ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/users.index.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/users.index.tsx new file mode 100644 index 00000000..410d3254 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/users.index.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/users/')({ + component: UsersIndexComponent, +}) + +function UsersIndexComponent() { + return ( +
+ Select a user or{' '} + + view as JSON + +
+ ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/users.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/users.tsx new file mode 100644 index 00000000..8017e21e --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/routes/users.tsx @@ -0,0 +1,49 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' +import type { User } from '../utils/users' + +export const Route = createFileRoute('/users')({ + loader: async () => { + const res = await fetch('/api/users') + + if (!res.ok) { + throw new Error('Unexpected status code') + } + + const data = (await res.json()) as Array + + return data + }, + component: UsersComponent, +}) + +function UsersComponent() { + const users = Route.useLoaderData() + + return ( +
+
    + {[ + ...users, + { id: 'i-do-not-exist', name: 'Non-existent User', email: '' }, + ].map((user) => { + return ( +
  • + +
    {user.name}
    + +
  • + ) + })} +
+
+ +
+ ) +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/styles/app.css b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/styles/app.css new file mode 100644 index 00000000..c53c8706 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/styles/app.css @@ -0,0 +1,22 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@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; + } +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/utils/loggingMiddleware.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/utils/loggingMiddleware.tsx new file mode 100644 index 00000000..3ea9a064 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/utils/loggingMiddleware.tsx @@ -0,0 +1,41 @@ +import { createMiddleware } from '@tanstack/react-start' + +const preLogMiddleware = createMiddleware({ type: 'function' }) + .client(async (ctx) => { + const clientTime = new Date() + + return ctx.next({ + context: { + clientTime, + }, + sendContext: { + clientTime, + }, + }) + }) + .server(async (ctx) => { + const serverTime = new Date() + + return ctx.next({ + sendContext: { + serverTime, + durationToServer: + serverTime.getTime() - ctx.context.clientTime.getTime(), + }, + }) + }) + +export const logMiddleware = createMiddleware({ type: 'function' }) + .middleware([preLogMiddleware]) + .client(async (ctx) => { + const res = await ctx.next() + + const now = new Date() + console.log('Client Req/Res:', { + duration: now.getTime() - res.context.clientTime.getTime(), + durationToServer: res.context.durationToServer, + durationFromServer: now.getTime() - res.context.serverTime.getTime(), + }) + + return res + }) diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/utils/posts.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/utils/posts.tsx new file mode 100644 index 00000000..52877be6 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/utils/posts.tsx @@ -0,0 +1,40 @@ +import { notFound } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' + +export type PostType = { + id: string + title: string + body: string +} + +export const fetchPost = createServerFn() + .validator((d: string) => d) + .handler(async ({ data }) => { + console.info(`Fetching post with id ${data}...`) + const res = await fetch( + `https://jsonplaceholder.typicode.com/posts/${data}`, + ) + if (!res.ok) { + if (res.status === 404) { + throw notFound() + } + + throw new Error('Failed to fetch post') + } + + const post = (await res.json()) as PostType + + return post + }) + +export const fetchPosts = createServerFn().handler(async () => { + console.info('Fetching posts...') + const res = await fetch('https://jsonplaceholder.typicode.com/posts') + if (!res.ok) { + throw new Error('Failed to fetch posts') + } + + const posts = (await res.json()) as Array + + return posts.slice(0, 10) +}) diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/utils/seo.ts b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/utils/seo.ts new file mode 100644 index 00000000..d18ad84b --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/utils/seo.ts @@ -0,0 +1,33 @@ +export const seo = ({ + title, + description, + keywords, + image, +}: { + title: string + description?: string + image?: string + keywords?: string +}) => { + const tags = [ + { title }, + { name: 'description', content: description }, + { name: 'keywords', content: keywords }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + { name: 'twitter:creator', content: '@tannerlinsley' }, + { name: 'twitter:site', content: '@tannerlinsley' }, + { name: 'og:type', content: 'website' }, + { name: 'og:title', content: title }, + { name: 'og:description', content: description }, + ...(image + ? [ + { name: 'twitter:image', content: image }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'og:image', content: image }, + ] + : []), + ] + + return tags +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/utils/users.tsx b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/utils/users.tsx new file mode 100644 index 00000000..7ba645b3 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/src/utils/users.tsx @@ -0,0 +1,5 @@ +export type User = { + id: number + name: string + email: string +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/tailwind.config.mjs b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/tailwind.config.mjs new file mode 100644 index 00000000..e49f4eb7 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/tailwind.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}'], +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/tsconfig.json b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/tsconfig.json new file mode 100644 index 00000000..3a9fb7cd --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/vite.config.ts b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/vite.config.ts new file mode 100644 index 00000000..c6ead940 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/fixtures/start-basic-alpha/vite.config.ts @@ -0,0 +1,19 @@ +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import netlify from '@netlify/vite-plugin-tanstack-start' +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import viteReact from '@vitejs/plugin-react' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + netlify(), + ], +}) diff --git a/packages/vite-plugin-tanstack-start/test/support/netlify-deploy.ts b/packages/vite-plugin-tanstack-start/test/support/netlify-deploy.ts new file mode 100644 index 00000000..69992cae --- /dev/null +++ b/packages/vite-plugin-tanstack-start/test/support/netlify-deploy.ts @@ -0,0 +1,89 @@ +import { exec as originalExec } from 'node:child_process' +import { writeFile, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { URL } from 'node:url' +import { promisify } from 'node:util' + +import normalizePackageData, { type Package } from 'normalize-package-data' + +const exec = promisify(originalExec) + +// https://app.netlify.com/sites/tanstack-start-e2e-tests +const SITE_ID = process.env.NETLIFY_SITE_ID ?? 'cd4e45ce-895c-432b-af6d-61e10ad1d125' + +export interface Deploy { + deployId: string + url: string + logs: string +} + +// TODO(serhalp): List all monorepo packages dynamically? It gets super confusing when you +// have changes to some transitive package and it fails in a weird way... +const packages = [ + { name: '@netlify/dev-utils', dirName: 'dev-utils' }, + { name: '@netlify/vite-plugin', dirName: 'vite-plugin' }, + { name: '@netlify/vite-plugin-tanstack-start', dirName: 'vite-plugin-tanstack-start' }, +] + +/** + * Inject the current revision of this repo's packages into the fixture. + * + * We can't use a simpler approach like a monorepo workspace or `npm link` because the fixture site + * needs to be self-contained to be deployable to Netlify (i.e. it can't have symlinks). + */ +const prepareDeps = async (cwd: string, packagesAbsoluteDir: string): Promise => { + const packageJson = JSON.parse(await readFile(`${cwd}/package.json`, 'utf-8')) as Package & { + overrides?: Record + } + normalizePackageData(packageJson) + packageJson.overrides ??= {} + const { dependencies = {}, devDependencies = {} } = packageJson + for (const pkg of packages) { + const isDep = pkg.name in dependencies + const isDevDep = pkg.name in devDependencies + console.log(`💉 Injecting local ${pkg.name} ${isDevDep ? 'dev ' : ''}dependency`) + const { stdout } = await exec(`npm pack --json --ignore-scripts --pack-destination ${cwd}`, { + cwd: join(packagesAbsoluteDir, pkg.dirName), + }) + const [{ filename }] = JSON.parse(stdout) as { filename: string }[] + if (isDep) { + dependencies[pkg.name] = `file:${filename}` + } + if (isDevDep) { + devDependencies[pkg.name] = `file:${filename}` + } + // Ensure that even a transitive dependency on this package is overridden. + packageJson.overrides[pkg.name] = `file:${filename}` + } + await writeFile(`${cwd}/package.json`, JSON.stringify(packageJson, null, 2)) + console.log('📦 Installing dependencies...') + await exec('npm install --no-package-lock', { cwd }) + console.log('📦 Installed dependencies') +} + +export const deploySite = async (projectDir: string): Promise => { + await prepareDeps(projectDir, join(import.meta.dirname, '../../../')) + + console.log(`🚀 Building and deploying site...`) + try { + // Ideally `netlify-cli` should be installed as a dev dep, but since it in turn + // depends on some packages in this monorepo, this runs into some issues (despite no actual + // circular dependencies). This should be easy to avoid but npm's workspaces feature is + // too basic. + const cmd = `npx -y netlify deploy --json --site ${SITE_ID}` + const { stdout, stderr } = await exec(cmd, { cwd: projectDir }) + await writeFile(join(projectDir, '__deploy.stdout.log'), stdout, { encoding: 'utf-8' }) + await writeFile(join(projectDir, '__deploy.stderr.log'), stderr, { encoding: 'utf-8' }) + // NOTE: `--json` is needed because otherwise the pretty box may wrap the URL over 2+ lines + const [url] = new RegExp(/https:.+\.netlify\.app/gm).exec(stdout) ?? [] + if (!url) { + throw new Error('Could not extract the URL from the build logs') + } + console.log(`🌍 Deployed site is live: ${url}`) + const [deployId] = new URL(url).host.split('--') + return { url, deployId, logs: `${stdout}\n${stderr}` } + } catch (err) { + console.error('❌ Deployment failed:', err) + throw err + } +} diff --git a/packages/vite-plugin-tanstack-start/tsconfig.json b/packages/vite-plugin-tanstack-start/tsconfig.json new file mode 100644 index 00000000..c9505ed4 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "allowJs": true, + "resolveJsonModule": true, + "declaration": true, + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src", "test"] +} diff --git a/packages/vite-plugin-tanstack-start/tsup.config.ts b/packages/vite-plugin-tanstack-start/tsup.config.ts new file mode 100644 index 00000000..05255ac7 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/tsup.config.ts @@ -0,0 +1,18 @@ +import { argv } from 'node:process' + +import { defineConfig } from 'tsup' + +export default defineConfig([ + { + clean: true, + entry: ['src/main.ts'], + outDir: 'dist', + format: ['esm'], + dts: true, + splitting: false, + watch: argv.includes('--watch'), + platform: 'node', + bundle: true, + external: ['vite'], + }, +]) diff --git a/packages/vite-plugin-tanstack-start/vitest.config.ts b/packages/vite-plugin-tanstack-start/vitest.config.ts new file mode 100644 index 00000000..504b2c80 --- /dev/null +++ b/packages/vite-plugin-tanstack-start/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + testTimeout: 15_000, + // e2e fixture install & deploy time is highly variable on GitHub's CI infra, especially Windows :( + hookTimeout: (process.platform === 'win32' ? 10 : 5) * 60_000, + env: { + // See https://github.com/webdiscus/ansis/?tab=readme-ov-file#disable-colors-in-tests + NO_COLOR: 'true', + }, + }, +}) diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index 87681d13..3d97baea 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -17,7 +17,7 @@ "prepack": "npm run build", "test": "vitest run", "test:dev": "vitest", - "test:ci": "npm run build && vitest run", + "test:ci": "vitest run", "dev": "tsup-node --watch", "publint": "npx -y publint --strict" }, @@ -36,12 +36,13 @@ "@types/node": "^20.17.57", "playwright": "^1.52.0", "tsup": "^8.0.0", - "vite": "^6.3.4", + "vite": "^6.3.6", "vitest": "^3.0.0" }, "dependencies": { "@netlify/dev": "4.5.10", - "@netlify/dev-utils": "^4.1.3" + "@netlify/dev-utils": "^4.1.3", + "dedent": "^1.7.0" }, "peerDependencies": { "vite": "^5 || ^6 || ^7" diff --git a/packages/vite-plugin/src/lib/build.ts b/packages/vite-plugin/src/lib/build.ts new file mode 100644 index 00000000..0fea5ae6 --- /dev/null +++ b/packages/vite-plugin/src/lib/build.ts @@ -0,0 +1,99 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' +import { sep as posixSep } from 'node:path/posix' + +import js from 'dedent' +import type { Plugin, ResolvedConfig, Rollup } from 'vite' + +import { createLoggerFromViteLogger } from './logger.js' +import { version, name } from '../../package.json' + +// https://docs.netlify.com/frameworks-api/#netlify-v1-functions +const NETLIFY_FUNCTIONS_DIR = '.netlify/v1/functions' + +const NETLIFY_FUNCTION_FILENAME = 'server.mjs' +const NETLIFY_FUNCTION_DEFAULT_NAME = '@netlify/vite-plugin server handler' + +const toPosixPath = (path: string) => path.split(sep).join(posixSep) + +// Generate the Netlify function that imports the built server entry point +const createNetlifyFunctionHandler = ( + /** + * The path to the server entry point, relative to the Netlify functions directory. + * This must be a resolvable node module path on all platforms. + */ + serverEntrypointPath: string, + displayName: string, +) => js` +import serverEntrypoint from "${serverEntrypointPath}"; + +if (typeof serverEntrypoint?.fetch !== "function") { + console.error("The server entry point must have a default export with a property \`fetch: (req: Request) => Promise\`"); +} + +export default serverEntrypoint.fetch; + +export const config = { + name: "${displayName}", + generator: "${name}@${version}", + path: "/*", + preferStatic: true, +}; +` +// TODO(serhalp): In the future, support Remix/RR7 by allowing a custom `getServerEntrypoint` +// passed as a plugin opt. +const getServerEntrypoint = (bundle: Rollup.OutputBundle, outDir: string): string => { + // Find the built server entry point file in the bundle. + // NOTE: This assumes there is only one BUNDLE entry and that this is the SERVER request entry point. This is an + // assumption that everyone in the Vite ecosystem seems to make and it holds for our supported frameworks. + const entryChunks = Object.values(bundle).filter( + (chunk): chunk is Rollup.OutputChunk => chunk.type === 'chunk' && chunk.isEntry, + ) + if (entryChunks.length === 0) { + throw new Error('Could not find entry chunk in bundle - aborting!') + } + if (entryChunks.length > 1) { + throw new Error('Found multiple entry chunks, unable to resolve server entry point - aborting!') + } + return join(outDir, entryChunks[0].fileName) +} + +export function createBuildPlugin(options?: { displayName?: string; edgeSSR?: boolean }): Plugin { + let resolvedConfig: ResolvedConfig + + return { + name: 'vite-plugin-netlify:build', + apply: 'build', + /** @see {@link https://vite.dev/guide/api-environment-plugins.html#per-environment-plugins} */ + applyToEnvironment({ name }) { + // There is a backwards-compatible environment with the name `ssr`: + // https://vite.dev/guide/api-environment-runtimes.html#creating-a-new-environment-factory. + // Eventually, to support more advanced configurations, we'll change this condition + // to `consumer === 'server'` and pass down request routing metadata. + return name === 'ssr' + }, + /** @see {@link https://vite.dev/guide/api-plugin.html#configresolved} */ + configResolved(config) { + resolvedConfig = config + }, + + /** @see {@link https://rollupjs.org/plugin-development/#writebundle} */ + async writeBundle(_, bundle) { + // Get the built server entry point and write a Netlify function with a handler that calls it + const functionsDirectory = join(resolvedConfig.root, NETLIFY_FUNCTIONS_DIR) + await mkdir(functionsDirectory, { recursive: true }) + const serverEntrypoint = getServerEntrypoint(bundle, resolvedConfig.build.outDir) + const serverEntrypointRelativePath = toPosixPath(relative(functionsDirectory, serverEntrypoint)) + await writeFile( + join(functionsDirectory, NETLIFY_FUNCTION_FILENAME), + createNetlifyFunctionHandler( + serverEntrypointRelativePath, + options?.displayName ?? NETLIFY_FUNCTION_DEFAULT_NAME, + ), + ) + createLoggerFromViteLogger(resolvedConfig.logger).log( + `✓ Wrote SSR entry point to ${join(NETLIFY_FUNCTIONS_DIR, NETLIFY_FUNCTION_FILENAME)}\n`, + ) + }, + } +} diff --git a/packages/vite-plugin/src/lib/logger.ts b/packages/vite-plugin/src/lib/logger.ts index 6d016b5e..0757748d 100644 --- a/packages/vite-plugin/src/lib/logger.ts +++ b/packages/vite-plugin/src/lib/logger.ts @@ -7,3 +7,5 @@ export const createLoggerFromViteLogger = (viteLogger: ViteLogger): Logger => ({ log: (msg?: string) => viteLogger.info(msg ?? '', { timestamp: true, environment: netlifyBanner }), warn: (msg?: string) => viteLogger.warn(msg ?? '', { timestamp: true, environment: netlifyBanner }), }) + +export type { Logger } diff --git a/packages/vite-plugin/src/main.test.ts b/packages/vite-plugin/src/main.test.ts index 1fc481a3..7c23816b 100644 --- a/packages/vite-plugin/src/main.test.ts +++ b/packages/vite-plugin/src/main.test.ts @@ -70,6 +70,34 @@ describe.for([['5.0.0'], ['6.0.0'], ['7.0.0']])('Vite %s', ([viteVersion]) => { }) describe('configureServer', { timeout: 15_000 }, () => { + test('does not warn on single plugin instance', async () => { + const mockLogger = createMockViteLogger() + const { server } = await startTestServer({ + customLogger: mockLogger, + plugins: [netlify({ middleware: false })], + }) + + expect(mockLogger.warn).not.toHaveBeenCalled() + + await server.close() + }) + + test('warns on duplicate plugin instances', async () => { + const mockLogger = createMockViteLogger() + const { server } = await startTestServer({ + customLogger: mockLogger, + plugins: [netlify({ middleware: false }), netlify({ middleware: false })], + }) + + expect(mockLogger.warn).toHaveBeenCalledOnce() + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringMatching(/Multiple instances of @netlify\/vite-plugin have been loaded/), + expect.objectContaining({}), + ) + + await server.close() + }) + test('Populates Netlify runtime environment (globals and env vars)', async () => { const fixture = new Fixture() .withFile( @@ -530,7 +558,7 @@ defined on your team and site and much more. Run npx netlify init to get started 'vite.config.js', `import { defineConfig } from 'vite'; import netlify from '@netlify/vite-plugin'; - + export default defineConfig({ plugins: [ netlify({ @@ -567,7 +595,7 @@ defined on your team and site and much more. Run npx netlify init to get started return new Response(entry) } - + export const config = { path: "/blob" } @@ -603,7 +631,7 @@ defined on your team and site and much more. Run npx netlify init to get started 'vite.config.js', `import { defineConfig } from 'vite'; import netlify from '@netlify/vite-plugin'; - + export default defineConfig({ plugins: [ netlify({ @@ -628,10 +656,10 @@ defined on your team and site and much more. Run npx netlify init to get started export default async (req, context) => { const res = await context.next() const text = await res.text() - + return new Response(text.toUpperCase(), res) } - + export const config = { path: "/*" } diff --git a/packages/vite-plugin/src/main.ts b/packages/vite-plugin/src/main.ts index f62bba88..9f41379e 100644 --- a/packages/vite-plugin/src/main.ts +++ b/packages/vite-plugin/src/main.ts @@ -1,10 +1,12 @@ import process from 'node:process' import { NetlifyDev, type Features } from '@netlify/dev' -import { fromWebResponse, netlifyCommand } from '@netlify/dev-utils' -import * as vite from 'vite' +import { fromWebResponse, netlifyCommand, warning } from '@netlify/dev-utils' +import dedent from 'dedent' +import type { Plugin } from 'vite' -import { createLoggerFromViteLogger } from './lib/logger.js' +import { createLoggerFromViteLogger, type Logger } from './lib/logger.js' +import { createBuildPlugin } from './lib/build.js' export interface NetlifyPluginOptions extends Features { /** @@ -12,8 +14,32 @@ export interface NetlifyPluginOptions extends Features { * same way as the Netlify production environment (default: true). */ middleware?: boolean + + /** + * DO NOT USE - build options, not meant for public use at this time. + * @private + */ + build?: { + /** + * Prepare the server build for deployment to Netlify - no additional configuration, + * plugins, or adapters necessary (default: false). + * + * This is currently only supported for TanStack Start projects. + */ + enabled?: boolean + /** + * Deploy SSR handler to Netlify Edge Functions instead of Netlify Functions (default: false). + */ + edgeSSR?: boolean + /** + * A display name for the serverless function or edge function deployed to Netlify + * (default: `@netlify/vite-plugin server handler`). + */ + displayName?: string + } } +// FIXME(serhalp): This should return `Plugin[]`. export default function netlify(options: NetlifyPluginOptions = {}): any { // If we're already running inside the Netlify CLI, there is no need to run // the plugin, as the environment will already be configured. @@ -21,15 +47,20 @@ export default function netlify(options: NetlifyPluginOptions = {}): any { return [] } - const plugin: vite.Plugin = { + const devPlugin: Plugin = { name: 'vite-plugin-netlify', + async configureServer(viteDevServer) { // if the vite dev server's http server isn't ready (or we're in // middleware mode) let's not get involved if (!viteDevServer.httpServer) { return } + const logger = createLoggerFromViteLogger(viteDevServer.config.logger) + + warnOnDuplicatePlugin(logger) + const { blobs, edgeFunctions, @@ -102,5 +133,20 @@ export default function netlify(options: NetlifyPluginOptions = {}): any { }, } - return [plugin] + const { enabled, ...buildOptions } = options.build ?? {} + return [devPlugin, ...(enabled === true ? [createBuildPlugin(buildOptions)] : [])] +} + +const warnOnDuplicatePlugin = (logger: Logger) => { + if (process.env.__VITE_PLUGIN_NETLIFY_LOADED__) { + logger.warn( + warning(dedent` + Multiple instances of @netlify/vite-plugin have been loaded. This may cause unexpected \ + behavior if the plugin is configured differently in each instance. If you have one \ + instance of this plugin in your Vite config, you may safely remove it. + `), + ) + return + } + process.env.__VITE_PLUGIN_NETLIFY_LOADED__ = 'true' } diff --git a/packages/vite-plugin/tsconfig.json b/packages/vite-plugin/tsconfig.json index 7b592eaa..f6ea4c44 100644 --- a/packages/vite-plugin/tsconfig.json +++ b/packages/vite-plugin/tsconfig.json @@ -5,6 +5,7 @@ "rootDir": "./src", "moduleResolution": "node", "allowJs": true, + "resolveJsonModule": true, "declaration": true, "outDir": "./dist", "esModuleInterop": true, diff --git a/release-please-config.json b/release-please-config.json index 3d799c9c..a8483d7b 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -22,6 +22,7 @@ "packages/runtime-utils": {}, "packages/static": {}, "packages/types": {}, - "packages/vite-plugin": {} + "packages/vite-plugin": {}, + "packages/vite-plugin-tanstack-start": {} } }