From 813c340f05c4087dbcbe949e40a90c1851dad48d Mon Sep 17 00:00:00 2001 From: Hrishikesh Kokate Date: Thu, 23 Jul 2026 17:21:33 +0530 Subject: [PATCH 1/2] frameworks api inject spa config --- .prettierignore | 1 + packages/angular-runtime/demo.test.mjs | 1 - packages/vite-plugin/src/lib/build.test.ts | 64 ++++++++++++++++++++++ packages/vite-plugin/src/lib/build.ts | 57 ++++++++++++++++++- packages/vite-plugin/src/main.ts | 4 +- 5 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 packages/vite-plugin/src/lib/build.test.ts diff --git a/.prettierignore b/.prettierignore index 787ce4d4..8e955001 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ .circleci +.nuxt dist coverage CHANGELOG.md diff --git a/packages/angular-runtime/demo.test.mjs b/packages/angular-runtime/demo.test.mjs index b8a3f9a0..2f5546ea 100644 --- a/packages/angular-runtime/demo.test.mjs +++ b/packages/angular-runtime/demo.test.mjs @@ -10,7 +10,6 @@ test( skip: !satisfies(versions.node, '>=20.11'), }, async () => { - // eslint-disable-next-line n/no-missing-import -- build output, only present after the demo is built const { config } = await import('./demo/.netlify/edge-functions/angular-ssr/angular-ssr.mjs') const excludedPathsWithMaskedHashes = config.excludedPath.map((path) => path.replace(/-[A-Z\d]{8}\./, '-HASHHASH.')) diff --git a/packages/vite-plugin/src/lib/build.test.ts b/packages/vite-plugin/src/lib/build.test.ts new file mode 100644 index 00000000..2eb3d0a1 --- /dev/null +++ b/packages/vite-plugin/src/lib/build.test.ts @@ -0,0 +1,64 @@ +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, test } from 'vitest' +import { build } from 'vite' + +import { createSpaConfigPlugin } from './build.js' + +describe('createSpaConfigPlugin', () => { + let root: string + + beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'vite-plugin-netlify-spa-config-'))) + await writeFile(join(root, 'index.html'), 'Hello') + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + const configPath = () => join(root, '.netlify/v1/config.json') + const readConfig = async (): Promise => JSON.parse(await readFile(configPath(), 'utf8')) + + test('writes build.spa = true for a default (SPA) app', async () => { + await build({ + root, + logLevel: 'silent', + plugins: [createSpaConfigPlugin()], + }) + + expect(await readConfig()).toEqual({ build: { spa: true } }) + }) + + test('merges into an existing config.json, preserving other keys', async () => { + await mkdir(join(root, '.netlify/v1'), { recursive: true }) + await writeFile( + configPath(), + JSON.stringify({ redirects: [{ from: '/a', to: '/b' }], build: { command: 'npm run build' } }), + ) + + await build({ + root, + logLevel: 'silent', + plugins: [createSpaConfigPlugin()], + }) + + expect(await readConfig()).toEqual({ + redirects: [{ from: '/a', to: '/b' }], + build: { command: 'npm run build', spa: true }, + }) + }) + + test('does not write config.json when appType is not spa', async () => { + await build({ + root, + logLevel: 'silent', + appType: 'custom', + plugins: [createSpaConfigPlugin()], + }) + + await expect(readConfig()).rejects.toThrow() + }) +}) diff --git a/packages/vite-plugin/src/lib/build.ts b/packages/vite-plugin/src/lib/build.ts index f3388646..1393c9f6 100644 --- a/packages/vite-plugin/src/lib/build.ts +++ b/packages/vite-plugin/src/lib/build.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join, relative, sep } from 'node:path' import { sep as posixSep } from 'node:path/posix' @@ -10,6 +10,7 @@ import { version, name } from '../../package.json' // https://docs.netlify.com/frameworks-api/#netlify-v1-functions const NETLIFY_FUNCTIONS_DIR = '.netlify/v1/functions' +const NETLIFY_CONFIG_PATH = '.netlify/v1/config.json' const NETLIFY_FUNCTION_FILENAME = 'server.mjs' const NETLIFY_FUNCTION_DEFAULT_NAME = '@netlify/vite-plugin server handler' @@ -97,3 +98,57 @@ export function createBuildPlugin(options?: { displayName?: string; edgeSSR?: bo }, } } + +const readNetlifyConfig = async (path: string): Promise> => { + try { + const jsonFile = await readFile(path, 'utf8') + return JSON.parse(jsonFile) as Record + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {} + } + + throw error + } +} + +// Marks the site as a single-page app so the Netlify build system serves `index.html` for all paths +// that don't match a static file, mirroring Vite's own dev server and preview behavior for `appType: 'spa'`. +// https://docs.netlify.com/frameworks-api/#netlify-v1-configjson +export function createSpaConfigPlugin(): Plugin { + let resolvedConfig: ResolvedConfig + + return { + apply: 'build', + applyToEnvironment({ name }) { + return name === 'client' + }, + configResolved(config) { + resolvedConfig = config + }, + name: 'vite-plugin-netlify:spa-config', + async writeBundle() { + if (resolvedConfig.appType !== 'spa') { + return + } + + const configPath = join(resolvedConfig.root, NETLIFY_CONFIG_PATH) + const config = await readNetlifyConfig(configPath) + + await mkdir(join(resolvedConfig.root, '.netlify/v1'), { + recursive: true, + }) + + await writeFile( + configPath, + JSON.stringify({ + ...config, + build: { + ...(config.build as Record | undefined), + spa: true, + }, + }), + ) + }, + } +} diff --git a/packages/vite-plugin/src/main.ts b/packages/vite-plugin/src/main.ts index 89381c0d..2bd7cd27 100644 --- a/packages/vite-plugin/src/main.ts +++ b/packages/vite-plugin/src/main.ts @@ -6,7 +6,7 @@ import dedent from 'dedent' import type { Plugin } from 'vite' import { createLoggerFromViteLogger, type Logger } from './lib/logger.js' -import { createBuildPlugin } from './lib/build.js' +import { createBuildPlugin, createSpaConfigPlugin } from './lib/build.js' export interface NetlifyPluginOptions extends Features { /** @@ -141,7 +141,7 @@ export default function netlify(options: NetlifyPluginOptions = {}): any { } const { enabled, ...buildOptions } = options.build ?? {} - return [devPlugin, ...(enabled === true ? [createBuildPlugin(buildOptions)] : [])] + return [devPlugin, createSpaConfigPlugin(), ...(enabled === true ? [createBuildPlugin(buildOptions)] : [])] } const warnOnDuplicatePlugin = (logger: Logger) => { From fc1f3f8f4054e6701b62085efcd7db1224952d28 Mon Sep 17 00:00:00 2001 From: Hrishikesh Kokate Date: Thu, 23 Jul 2026 17:38:38 +0530 Subject: [PATCH 2/2] fix lint --- packages/angular-runtime/demo.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/angular-runtime/demo.test.mjs b/packages/angular-runtime/demo.test.mjs index 2f5546ea..b8a3f9a0 100644 --- a/packages/angular-runtime/demo.test.mjs +++ b/packages/angular-runtime/demo.test.mjs @@ -10,6 +10,7 @@ test( skip: !satisfies(versions.node, '>=20.11'), }, async () => { + // eslint-disable-next-line n/no-missing-import -- build output, only present after the demo is built const { config } = await import('./demo/.netlify/edge-functions/angular-ssr/angular-ssr.mjs') const excludedPathsWithMaskedHashes = config.excludedPath.map((path) => path.replace(/-[A-Z\d]{8}\./, '-HASHHASH.'))