Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion eslint_temporary_suppressions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1019,7 +1019,6 @@ export default [
{
files: ['packages/build/src/plugins_core/frameworks_api/index.ts'],
rules: {
'n/no-missing-import': 'off',
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
Expand Down Expand Up @@ -2846,4 +2845,18 @@ export default [
'import/no-named-as-default-member': 'off',
},
},
{
// Same class of gap as packages/build-info's own *.test.ts suppressions (e.g.
// tests/bin.test.ts): `@netlify/testing` isn't declared as a dependency anywhere
// (to avoid a circular dependency), so its types don't fully resolve here.
files: [
'packages/build/tests/frameworks_api/spa.test.ts',
'packages/build/tests/spa_fallback/spa_fallback.test.ts',
],
rules: {
'n/no-missing-import': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
},
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]
1 change: 1 addition & 0 deletions packages/build/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tsconfig*.tsbuildinfo
2 changes: 1 addition & 1 deletion packages/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"scripts": {
"prebuild": "rm -rf lib",
"postbuild": "npx cpy \"src/**/*.yml\" \"lib/\"",
"build": "tsc",
"build": "tsc --project tsconfig.build.json",
"test:types": "tsd",
"test": "ava && tsd && vitest run",
"test:dev": "ava -w",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@ import rfdc from 'rfdc'

const clone = rfdc()

export type ConfigMutation = {
keys: string[]
keysString: string
value: unknown
event: string
}

// Copy `netlifyConfig` so we can compare before/after mutating it
export const cloneNetlifyConfig = function (netlifyConfig) {
export function cloneNetlifyConfig<T>(netlifyConfig: T): T {
return clone(netlifyConfig)
}

Expand All @@ -18,22 +25,32 @@ export const cloneNetlifyConfig = function (netlifyConfig) {
// - Apply the change to `netlifyConfig` in the parent process so it can
// run `@netlify/config` to normalize and validate the new values
// `configMutations` is passed to parent process as JSON
export const getConfigMutations = function (netlifyConfig, netlifyConfigCopy, event) {
const configMutations = diffObjects(netlifyConfig, netlifyConfigCopy, [])

export function getConfigMutations(netlifyConfig: object, netlifyConfigCopy: object, event: string): ConfigMutation[] {
const configMutations = diffObjects(
netlifyConfig as Record<string, unknown>,
netlifyConfigCopy as Record<string, unknown>,
[],
)

return configMutations.map((configMutation) => getConfigMutation(configMutation, event))
}

type DiffResult = { keys: string[]; value: unknown }

// We only recurse over plain objects, not arrays. Which means array properties
// can only be modified all at once.
const diffObjects = function (objA, objB, parentKeys) {

function diffObjects(objA: Record<string, unknown>, objB: Record<string, unknown>, parentKeys: string[]): DiffResult[] {
const allKeys = [...new Set([...Object.keys(objA), ...Object.keys(objB)])]

return allKeys.flatMap((key) => {
const valueA = objA[key]
const valueB = objB[key]
const keys = [...parentKeys, key]

if (isPlainObj(valueA) && isPlainObj(valueB)) {
return diffObjects(valueA, valueB, keys)
return diffObjects(valueA as Record<string, unknown>, valueB as Record<string, unknown>, keys)
}

if (isDeepStrictEqual(valueA, valueB)) {
Expand All @@ -44,8 +61,9 @@ const diffObjects = function (objA, objB, parentKeys) {
})
}

const getConfigMutation = function ({ keys, value }, event) {
function getConfigMutation({ keys, value }: DiffResult, event: string): ConfigMutation {
const serializedKeys = keys.map(String)

return {
keys: serializedKeys,
keysString: serializedKeys.join('.'),
Expand Down
1 change: 1 addition & 0 deletions packages/build/src/plugins_core/frameworks_api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const ALLOWED_PROPERTIES = [
['headers'],
['images', 'remote_images'],
['redirects'],
['spa_fallback'],
]

// For array properties, any values set in this API will be merged with the
Expand Down
72 changes: 72 additions & 0 deletions packages/build/src/plugins_core/spa_fallback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { NetlifyConfig } from '../../index.js'
import { logWarning } from '../../log/logger.js'
import { getConfigMutations } from '../../plugins/child/diff.js'
import { CoreStep, CoreStepFunction, CoreStepFunctionArgs } from '../types.js'

// The catch-all redirect that makes a single-page application's client-side
// router handle every path.
const SPA_FALLBACK_REDIRECT = {
from: '/*',
status: 200,
to: '/index.html',
}

function findCatchAllRedirect(redirects: NetlifyConfig['redirects']) {
return redirects.find((r) => r.from === '/*')
}

// A catch-all redirect only serves the single-page application the way ours
// would if it rewrites (rather than redirects) to the same destination.
function matchesSpaFallback(redirect: NonNullable<ReturnType<typeof findCatchAllRedirect>>) {
return redirect.to === SPA_FALLBACK_REDIRECT.to && redirect.status === SPA_FALLBACK_REDIRECT.status
}

function coreStep(coreStepFunctionArgs: CoreStepFunctionArgs): ReturnType<CoreStepFunction> {
const { netlifyConfig, logs } = coreStepFunctionArgs

if (!netlifyConfig.spa_fallback) {
return Promise.resolve({})
}

const existingCatchAll = findCatchAllRedirect(netlifyConfig.redirects)

if (existingCatchAll) {
// `spa_fallback` asked us to add a catch-all redirect, but the site
// already has one. If it doesn't already do what ours would, warn the
// user instead of silently overriding their explicit configuration.
if (!matchesSpaFallback(existingCatchAll)) {
logWarning(
logs,
`
Warning: "spa_fallback" is enabled, but a catch-all redirect ("/*") already exists that does not rewrite to "${SPA_FALLBACK_REDIRECT.to}" with a "${String(SPA_FALLBACK_REDIRECT.status)}" status, so Netlify did not add its own.
Please make sure your existing catch-all redirect correctly serves your single-page application.`,
)
}
Comment thread
hrishikesh-k marked this conversation as resolved.

return Promise.resolve({})
}

const newConfig: Partial<NetlifyConfig> = {
redirects: [...netlifyConfig.redirects, SPA_FALLBACK_REDIRECT],
}

const configMutations = getConfigMutations(
netlifyConfig,
{
...netlifyConfig,
...newConfig,
},
applySpaFallback.event,
)

return Promise.resolve({ configMutations })
}

export const applySpaFallback: CoreStep = {
coreStep,
coreStepDescription: () => '',
coreStepId: 'spa_fallback',
coreStepName: 'Applying SPA fallback redirect',
event: 'onPostBuild',
quiet: true,
}
2 changes: 2 additions & 0 deletions packages/build/src/steps/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { preCleanup } from '../plugins_core/pre_cleanup/index.js'
import { preDevCleanup } from '../plugins_core/pre_dev_cleanup/index.js'
import { saveArtifacts } from '../plugins_core/save_artifacts/index.js'
import { scanForSecrets } from '../plugins_core/secrets_scanning/index.js'
import { applySpaFallback } from '../plugins_core/spa_fallback/index.js'
import { CoreStep, Event } from '../plugins_core/types.js'

// Get all build steps
Expand Down Expand Up @@ -85,6 +86,7 @@ const addCoreSteps = function (steps): CoreStep[] {
bundleFunctions,
bundleEdgeFunctions,
copyDbMigrations,
applySpaFallback,
scanForSecrets,
uploadBlobs,
deploySite,
Expand Down
1 change: 1 addition & 0 deletions packages/build/src/types/config/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface Build {
* Includes the path to a site's [Edge Functions directory](https://docs.netlify.com/edge-functions/optional-configuration/#edge-functions-directory)
*/
edge_functions?: string

/**
* Contains a site's [environment variables](https://docs.netlify.com/configure-builds/environment-variables/#netlify-configuration-variables)
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/build/src/types/config/netlify_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,8 @@ export interface NetlifyConfig {
* object with options for database configuration
*/
database?: DatabaseConfig
/**
* Whether the site is a single-page application (SPA). Defaults to `false`.
*/
spa_fallback?: boolean
}
48 changes: 24 additions & 24 deletions packages/build/tests/core/snapshots/tests.js.md
Original file line number Diff line number Diff line change
Expand Up @@ -917,9 +917,9 @@ Generated by [AVA](https://avajs.dev).
Running \`netlify build\` will execute this build flow␊
┌──────────────────────────────────┐␊
│ Event │ Location │␊
└──────────────────────────────────┘␊
┌──────────────────┬──────────────────┐␊
│ Event │ Location │␊
└──────────────────┴──────────────────┘␊
If this looks good to you, run \`netlify build\` to execute the build␊
`
Expand Down Expand Up @@ -972,18 +972,18 @@ Generated by [AVA](https://avajs.dev).
Running \`netlify build\` will execute this build flow␊
┌──────────────────────────────────┐␊
│ Event │ Location │␊
└──────────────────────────────────┘␊
┌─────────────────┐␊
│ 1. onPreBuild ↓ │ Plugin ./plugin␊
└─────────────────┘ ␊
┌─────────────────┐␊
│ 2. onBuild ↓ │ build.command from netlify.toml␊
└─────────────────┘ ␊
┌─────────────────┐␊
│ 3. onBuild ↓ │ Functions bundling␊
└─────────────────┘ ␊
┌──────────────────┬──────────────────┐␊
│ Event │ Location │␊
└──────────────────┴──────────────────┘␊
┌─────────────────┐␊
│ 1. onPreBuild ↓ │ Plugin ./plugin␊
└─────────────────┘ ␊
┌─────────────────┐␊
│ 2. onBuild ↓ │ build.command from netlify.toml␊
└─────────────────┘ ␊
┌─────────────────┐␊
│ 3. onBuild ↓ │ Functions bundling␊
└─────────────────┘ ␊
If this looks good to you, run \`netlify build\` to execute the build␊
`
Expand Down Expand Up @@ -1026,9 +1026,9 @@ Generated by [AVA](https://avajs.dev).
Running \`netlify build\` will execute this build flow␊
┌──────────────────────────────────┐␊
│ Event │ Location │␊
└──────────────────────────────────┘␊
┌──────────────────┬──────────────────┐␊
│ Event │ Location │␊
└──────────────────┴──────────────────┘␊
If this looks good to you, run \`netlify build\` to execute the build␊
`
Expand Down Expand Up @@ -1073,12 +1073,12 @@ Generated by [AVA](https://avajs.dev).
Running \`netlify build\` will execute this build flow␊
┌──────────────────────────────────┐␊
│ Event │ Location │␊
└──────────────────────────────────┘␊
┌─────────────────┐␊
│ 1. onBuild ↓ │ Build command from Netlify app␊
└─────────────────┘ ␊
┌──────────────────┬──────────────────┐␊
│ Event │ Location │␊
└──────────────────┴──────────────────┘␊
┌─────────────────┐␊
│ 1. onBuild ↓ │ Build command from Netlify app␊
└─────────────────┘ ␊
If this looks good to you, run \`netlify build\` to execute the build␊
`
Expand Down
Binary file modified packages/build/tests/core/snapshots/tests.js.snap
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# .netlify/v1/config.json is generated at build time by build.mjs.
.netlify
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { mkdir, writeFile } from 'node:fs/promises'

const config = {
spa_fallback: true,
}

await mkdir('.netlify/v1', { recursive: true })

await writeFile('.netlify/v1/config.json', JSON.stringify(config))
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
command = "node build.mjs"
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# .netlify/v1/config.json is generated at build time by build.mjs.
.netlify
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { mkdir, writeFile } from 'node:fs/promises'

const config = {
spa_fallback: true,
}

await mkdir('.netlify/v1', { recursive: true })

await writeFile('.netlify/v1/config.json', JSON.stringify(config))
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
spa_fallback = false

[build]
command = "node build.mjs"
22 changes: 22 additions & 0 deletions packages/build/tests/frameworks_api/spa.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Fixture } from '@netlify/testing'
import { expect, test } from 'vitest'

test('Honors `spa_fallback` declared through the Frameworks API config file', async () => {
const { netlifyConfig, success } = await new Fixture(
import.meta.url,
'./fixtures/spa_fallback_config',
).runWithBuildAndIntrospect()

expect(success).toBe(true)
expect(netlifyConfig.spa_fallback).toBe(true)
})

test('`netlify.toml` takes precedence over the Frameworks API for `spa_fallback`', async () => {
const { netlifyConfig, success } = await new Fixture(
import.meta.url,
'./fixtures/spa_fallback_config_precedence',
).runWithBuildAndIntrospect()

expect(success).toBe(true)
expect(netlifyConfig.spa_fallback).toBe(false)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
command = "echo hi"
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
spa_fallback = false

[build]
command = "echo hi"
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
spa_fallback = true

[build]
command = "echo hi"
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
spa_fallback = true

[build]
command = "echo hi"

[[redirects]]
from = "/*"
to = "/200.html"
status = 200
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
spa_fallback = true

[build]
command = "echo hi"

[[redirects]]
from = "/*"
to = "/index.html"
status = 200
Loading
Loading