diff --git a/packages/next/src/build/build-context.ts b/packages/next/src/build/build-context.ts index eb4318f8291b1..4d748e6dea4e4 100644 --- a/packages/next/src/build/build-context.ts +++ b/packages/next/src/build/build-context.ts @@ -4,6 +4,7 @@ import { Rewrite } from '../lib/load-custom-routes' import { __ApiPreviewProps } from '../server/api-utils' import { NextConfigComplete } from '../server/config-shared' import { Span } from '../trace' +import type getBaseWebpackConfig from './webpack-config' import { TelemetryPlugin } from './webpack/plugins/telemetry-plugin' // a global object to store context for the current build @@ -48,4 +49,7 @@ export const NextBuildContext: Partial<{ reactProductionProfiling: boolean noMangling: boolean appDirOnly: boolean + clientRouterFilters: Parameters< + typeof getBaseWebpackConfig + >[1]['clientRouterFilters'] }> = {} diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index f897fed7dc12f..f1a0b974c30b1 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -131,6 +131,7 @@ import { webpackBuild } from './webpack-build' import { NextBuildContext } from './build-context' import { normalizePathSep } from '../shared/lib/page-path/normalize-path-sep' import { isAppRouteRoute } from '../lib/is-app-route-route' +import { createClientRouterFilter } from '../lib/create-router-client-filter' export type SsgRoute = { initialRevalidateSeconds: number | false @@ -840,6 +841,18 @@ export default async function build( ...rewrites.fallback, ] + if (config.experimental.clientRouterFilter) { + const nonInternalRedirects = redirects.filter( + (redir) => !(redir as any).internal + ) + const clientRouterFilters = createClientRouterFilter( + appPageKeys, + nonInternalRedirects + ) + + NextBuildContext.clientRouterFilters = clientRouterFilters + } + const distDirCreated = await nextBuildSpan .traceChild('create-dist-dir') .traceAsyncFn(async () => { diff --git a/packages/next/src/build/webpack-build.ts b/packages/next/src/build/webpack-build.ts index 8e3c59df18f95..4d7c8d19fd603 100644 --- a/packages/next/src/build/webpack-build.ts +++ b/packages/next/src/build/webpack-build.ts @@ -93,6 +93,7 @@ async function webpackBuildImpl(): Promise<{ rewrites: NextBuildContext.rewrites!, reactProductionProfiling: NextBuildContext.reactProductionProfiling!, noMangling: NextBuildContext.noMangling!, + clientRouterFilters: NextBuildContext.clientRouterFilters!, } const configs = await runWebpackSpan diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 65a6f1a6a372f..676f82d23f891 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -172,6 +172,7 @@ export function getDefineEnv({ isNodeServer, isEdgeServer, middlewareMatchers, + clientRouterFilters, }: { dev?: boolean distDir: string @@ -181,6 +182,9 @@ export function getDefineEnv({ isEdgeServer?: boolean middlewareMatchers?: MiddlewareMatcher[] config: NextConfigComplete + clientRouterFilters: Parameters< + typeof getBaseWebpackConfig + >[1]['clientRouterFilters'] }) { return { // internal field to identify the plugin config @@ -229,6 +233,15 @@ export function getDefineEnv({ 'process.env.__NEXT_NEW_LINK_BEHAVIOR': JSON.stringify( config.experimental.newNextLinkBehavior ), + 'process.env.__NEXT_CLIENT_ROUTER_FILTER_ENABLED': JSON.stringify( + config.experimental.clientRouterFilter + ), + 'process.env.__NEXT_CLIENT_ROUTER_S_FILTER': JSON.stringify( + clientRouterFilters?.staticFilter + ), + 'process.env.__NEXT_CLIENT_ROUTER_D_FILTER': JSON.stringify( + clientRouterFilters?.dynamicFilter + ), 'process.env.__NEXT_OPTIMISTIC_CLIENT_CACHE': JSON.stringify( config.experimental.optimisticClientCache ), @@ -610,6 +623,7 @@ export default async function getBaseWebpackConfig( jsConfig, resolvedBaseUrl, supportedBrowsers, + clientRouterFilters, }: { buildId: string config: NextConfigComplete @@ -628,6 +642,14 @@ export default async function getBaseWebpackConfig( jsConfig: any resolvedBaseUrl: string | undefined supportedBrowsers: string[] | undefined + clientRouterFilters?: { + staticFilter: ReturnType< + import('../shared/lib/bloom-filter').BloomFilter['export'] + > + dynamicFilter: ReturnType< + import('../shared/lib/bloom-filter').BloomFilter['export'] + > + } } ): Promise { const isClient = compilerType === COMPILER_NAMES.client @@ -2061,6 +2083,7 @@ export default async function getBaseWebpackConfig( isNodeServer, isEdgeServer, middlewareMatchers, + clientRouterFilters, }) ), isClient && diff --git a/packages/next/src/lib/create-router-client-filter.ts b/packages/next/src/lib/create-router-client-filter.ts new file mode 100644 index 0000000000000..d81df08a8a30e --- /dev/null +++ b/packages/next/src/lib/create-router-client-filter.ts @@ -0,0 +1,71 @@ +import { BloomFilter } from '../shared/lib/bloom-filter' +import { isDynamicRoute } from '../shared/lib/router/utils' +import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' +import { Redirect } from './load-custom-routes' + +const POTENTIAL_ERROR_RATE = 0.02 + +export function createClientRouterFilter( + paths: string[], + redirects: Redirect[] +): { + staticFilter: ReturnType + dynamicFilter: ReturnType +} { + const staticPaths = new Set() + const dynamicPaths = new Set() + + for (const path of paths) { + if (isDynamicRoute(path)) { + let subPath = '' + const pathParts = path.split('/') + + for (let i = 1; i < pathParts.length + 1; i++) { + const curPart = pathParts[i] + + if (curPart.startsWith('[')) { + break + } + subPath = `${subPath}/${curPart}` + } + dynamicPaths.add(subPath) + } else { + staticPaths.add(path) + } + } + + for (const redirect of redirects) { + const { source } = redirect + const path = removeTrailingSlash(source) + + if (path.includes(':') || path.includes('(')) { + let subPath = '' + const pathParts = path.split('/') + + for (let i = 1; i < pathParts.length + 1; i++) { + const curPart = pathParts[i] + + if (curPart.includes(':') || curPart.includes('(')) { + break + } + subPath = `${subPath}/${curPart}` + } + + dynamicPaths.add(subPath) + } else { + staticPaths.add(path) + } + } + + const staticFilter = BloomFilter.from([...staticPaths], POTENTIAL_ERROR_RATE) + + const dynamicFilter = BloomFilter.from( + [...dynamicPaths], + POTENTIAL_ERROR_RATE + ) + const data = { + staticFilter: staticFilter.export(), + dynamicFilter: dynamicFilter.export(), + } + return data +} diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index d4b262fc9bc27..49fdf8e07e79b 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -247,6 +247,9 @@ const configSchema = { }, type: 'object', }, + clientRouterFilter: { + type: 'boolean', + }, cpus: { type: 'number', }, diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 44920198a71e6..9a92a49a89109 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -113,6 +113,7 @@ export interface NextJsWebpackConfig { } export interface ExperimentalConfig { + clientRouterFilter?: boolean externalMiddlewareRewritesResolve?: boolean extensionAlias?: Record allowedRevalidateHeaderKeys?: string[] @@ -619,6 +620,7 @@ export const defaultConfig: NextConfig = { output: !!process.env.NEXT_PRIVATE_STANDALONE ? 'standalone' : undefined, modularizeImports: undefined, experimental: { + clientRouterFilter: false, preCompiledNextServer: false, fetchCache: false, middlewarePrefetch: 'flexible', diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 07c7ded4af4f0..cac4a8a11991c 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -167,14 +167,20 @@ function assignDefaults( value ) as (keyof ExperimentalConfig)[]) { const featureValue = value[featureName] - if ( - featureName === 'appDir' && - featureValue === true && - !isAboveNodejs16 - ) { - throw new Error( - `experimental.appDir requires Node v${NODE_16_VERSION} or later.` - ) + if (featureName === 'appDir' && featureValue === true) { + if (!isAboveNodejs16) { + throw new Error( + `experimental.appDir requires Node v${NODE_16_VERSION} or later.` + ) + } + // auto enable clientRouterFilter if not manually set + // when appDir is enabled + if ( + typeof userConfig.experimental.clientRouterFilter === + 'undefined' + ) { + userConfig.experimental.clientRouterFilter = true + } } if ( value[featureName] !== defaultConfig.experimental[featureName] diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 752cb17ccf4db..398eac06e1cbc 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -96,6 +96,7 @@ import { CachedFileReader } from '../future/route-matcher-providers/dev/helpers/ import { DefaultFileReader } from '../future/route-matcher-providers/dev/helpers/file-reader/default-file-reader' import { NextBuildContext } from '../../build/build-context' import { logAppDirError } from './log-app-dir-error' +import { createClientRouterFilter } from '../../lib/create-router-client-filter' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: FunctionComponent @@ -411,6 +412,7 @@ export default class DevServer extends Server { wp.watch({ directories: [this.dir], startTime: 0 }) const fileWatchTimes = new Map() let enabledTypeScript = this.usingTypeScript + let previousClientRouterFilters: any wp.on('aggregated', async () => { let middlewareMatchers: MiddlewareMatcher[] | undefined @@ -578,6 +580,23 @@ export default class DevServer extends Server { Log.error(` "${pagesPath}" - "${appPath}"`) } } + let clientRouterFilters: any + + if (this.nextConfig.experimental.clientRouterFilter) { + clientRouterFilters = createClientRouterFilter( + Object.keys(appPaths), + this.customRoutes.redirects.filter((r) => !(r as any).internal) + ) + + if ( + !previousClientRouterFilters || + JSON.stringify(previousClientRouterFilters) !== + JSON.stringify(clientRouterFilters) + ) { + envChange = true + previousClientRouterFilters = clientRouterFilters + } + } if (!this.usingTypeScript && enabledTypeScript) { // we tolerate the error here as this is best effort @@ -664,6 +683,7 @@ export default class DevServer extends Server { hasRewrites, isNodeServer, isEdgeServer, + clientRouterFilters, }) Object.keys(plugin.definitions).forEach((key) => { diff --git a/packages/next/src/shared/lib/bloom-filter/base-filter.ts b/packages/next/src/shared/lib/bloom-filter/base-filter.ts new file mode 100644 index 0000000000000..8fc03bf9b237f --- /dev/null +++ b/packages/next/src/shared/lib/bloom-filter/base-filter.ts @@ -0,0 +1,119 @@ +/* file : base-filter.ts +MIT License + +Copyright (c) 2017-2020 Thomas Minier & Arnaud Grall + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +import Hashing from './hashing' +import { getDefaultSeed } from './utils' + +/** + * Exported prng type because it is not from seedrandom + * Orignal type can be found in: @types/seedrandom + */ +export interface prng { + (): number + int32(): number + quick(): number +} + +function randomInt32() { + if (typeof window === 'undefined' && process.env.NEXT_RUNTIME === 'nodejs') { + return (require('crypto') as typeof import('crypto')) + .randomBytes(4) + .readUInt32BE(0) + } + return crypto.getRandomValues(new Uint32Array(1))[0] +} + +function seedrandom() { + return { + int32: randomInt32, + quick: randomInt32, + } +} + +/** + * A base class for implementing probailistic filters + * @author Thomas Minier + * @author Arnaud Grall + */ +export default abstract class BaseFilter { + public _seed: number + public _rng: prng + public _hashing: Hashing + + constructor() { + this._seed = getDefaultSeed() + this._rng = seedrandom() as prng + this._hashing = new Hashing() + } + + /** + * Get the seed used in this structure + */ + public get seed(): number { + return this._seed + } + + /** + * Set the seed for this structure + * @param seed the new seed that will be used in this structure + */ + public set seed(seed: number) { + this._seed = seed + this._rng = seedrandom() as prng + } + + /** + * Get a function used to draw random number + * @return A factory function used to draw random integer + */ + public get random(): prng { + return this._rng + } + + /** + * Return a next random seeded int32 integer + * @returns + */ + public nextInt32(): number { + return this._rng.int32() + } + + /** + * Save the current structure as a JSON + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public saveAsJSON(): any { + throw new Error('not-implemented') + } + + /** + * Load an Object from a provided JSON object + * @param json the JSON object to load + * @return Return the Object loaded from the provided JSON object + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars + public static fromJSON(json: JSON): any { + throw new Error(`not-implemented`) + } +} diff --git a/packages/next/src/shared/lib/bloom-filter/base64-arraybuffer.ts b/packages/next/src/shared/lib/bloom-filter/base64-arraybuffer.ts new file mode 100644 index 0000000000000..d25cbd4754568 --- /dev/null +++ b/packages/next/src/shared/lib/bloom-filter/base64-arraybuffer.ts @@ -0,0 +1,65 @@ +// original source: https://github.com/niklasvh/base64-arraybuffer/blob/master/src/index.ts + +const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +// Use a lookup table to find the index. +const lookup = typeof Uint8Array === 'undefined' ? [] : new Uint8Array(256) +for (let i = 0; i < chars.length; i++) { + lookup[chars.charCodeAt(i)] = i +} + +export const encode = (arraybuffer: ArrayBuffer): string => { + let bytes = new Uint8Array(arraybuffer), + i, + len = bytes.length, + base64 = '' + + for (i = 0; i < len; i += 3) { + base64 += chars[bytes[i] >> 2] + base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)] + base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)] + base64 += chars[bytes[i + 2] & 63] + } + + if (len % 3 === 2) { + base64 = base64.substring(0, base64.length - 1) + '=' + } else if (len % 3 === 1) { + base64 = base64.substring(0, base64.length - 2) + '==' + } + + return base64 +} + +export const decode = (base64: string): ArrayBuffer => { + let bufferLength = base64.length * 0.75, + len = base64.length, + i, + p = 0, + encoded1, + encoded2, + encoded3, + encoded4 + + if (base64[base64.length - 1] === '=') { + bufferLength-- + if (base64[base64.length - 2] === '=') { + bufferLength-- + } + } + + const arraybuffer = new ArrayBuffer(bufferLength), + bytes = new Uint8Array(arraybuffer) + + for (i = 0; i < len; i += 4) { + encoded1 = lookup[base64.charCodeAt(i)] + encoded2 = lookup[base64.charCodeAt(i + 1)] + encoded3 = lookup[base64.charCodeAt(i + 2)] + encoded4 = lookup[base64.charCodeAt(i + 3)] + + bytes[p++] = (encoded1 << 2) | (encoded2 >> 4) + bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2) + bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63) + } + + return arraybuffer +} diff --git a/packages/next/src/shared/lib/bloom-filter/bit-set.ts b/packages/next/src/shared/lib/bloom-filter/bit-set.ts new file mode 100644 index 0000000000000..11a82df9bdc3f --- /dev/null +++ b/packages/next/src/shared/lib/bloom-filter/bit-set.ts @@ -0,0 +1,149 @@ +import { encode, decode } from './base64-arraybuffer' + +const bitsPerWord = 8 + +/** + * A memory-efficient Boolean array. Contains just the minimal operations needed for our Bloom filter implementation. + * + * @author David Leppik + */ +export default class BitSet { + public readonly size: number + + // Uint32Array may be slightly faster due to memory alignment, but this avoids endianness when serializing + public array: Uint8Array + + /** + * Constructor. All bits are initially set to false. + * @param size the number of bits that can be stored. (This is NOT required to be a multiple of 8.) + */ + constructor(size: number) { + const diff = bitsPerWord - (size % bitsPerWord) + this.size = size + ([0, 8].includes(diff) ? 0 : diff) + this.array = new Uint8Array(Math.ceil(this.size / bitsPerWord)) + } + + /** + * Returns the value of the bit at the given index + * @param index position of the bit, zero-indexed + */ + public has(index: number): boolean { + const wordIndex = Math.floor(index / bitsPerWord) + const mask = 1 << index % bitsPerWord + return (this.array[wordIndex] & mask) !== 0 + } + + /** + * Set the bit to true + * @param index position of the bit, zero-indexed + */ + public add(index: number) { + const wordIndex = Math.floor(index / bitsPerWord) + const mask = 1 << index % bitsPerWord + this.array[wordIndex] = this.array[wordIndex] | mask + } + + /** + * Returns the maximum true bit. + */ + public max(): number { + for (let i = this.array.length - 1; i >= 0; i--) { + const bits = this.array[i] + if (bits) { + return BitSet.highBit(bits) + i * bitsPerWord + } + } + return 0 + } + + /** + * Returns the number of true bits. + */ + public bitCount(): number { + let result = 0 + for (let i = 0; i < this.array.length; i++) { + result += BitSet.countBits(this.array[i]) // Assumes we never have bits set beyond the end + } + return result + } + + /** + * Returns true if the size and contents are identical. + * @param other another BitSet + */ + public equals(other: BitSet): boolean { + if (other.size !== this.size) { + return false + } + for (let i = 0; i < this.array.length; i++) { + if (this.array[i] !== other.array[i]) { + return false + } + } + return true + } + + /** + * Returns a JSON-encodable object readable by {@link import}. + */ + public export(): { size: number; content: string } { + return { + size: this.size, + content: encode(this.array), + } + } + + /** + * Returns an object written by {@link export}. + * @param data an object written by {@link export} + */ + public static import(data: { size: number; content: string }): BitSet { + if (typeof data.size !== 'number') { + throw Error('BitSet missing size') + } + if (typeof data.content !== 'string') { + throw Error('BitSet: missing content') + } + const result = new BitSet(data.size) + const buffer = decode(data.content) + result.array = new Uint8Array(buffer) + return result + } + + /** + * Returns the index of the maximum bit in the number, or -1 for 0 + * @bits an unsigned 8-bit number + * ```js + * @example + * BitSet.highBit(0) // returns -1 + * BitSet.highBit(5) // returns 2 + * ``` + */ + public static highBit(bits: number): number { + let result = bitsPerWord - 1 + let mask = 1 << result + while (result >= 0 && (mask & bits) !== mask) { + mask >>>= 1 + result-- + } + return result + } + + /** + * Returns the number of true bits in the number + * @bits an unsigned 8-bit number + * @example + * ```js + * BitSet.countBits(0) // returns 0 + * BitSet.countBits(3) // returns 2 + * ``` + */ + public static countBits(bits: number): number { + let result = bits & 1 + while (bits !== 0) { + bits = bits >>> 1 + result += bits & 1 + } + return result + } +} diff --git a/packages/next/src/shared/lib/bloom-filter/formulas.ts b/packages/next/src/shared/lib/bloom-filter/formulas.ts new file mode 100644 index 0000000000000..f1634007fc8c5 --- /dev/null +++ b/packages/next/src/shared/lib/bloom-filter/formulas.ts @@ -0,0 +1,51 @@ +/* file : formulas.ts +MIT License + +Copyright (c) 2017-2020 Thomas Minier & Arnaud Grall + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/** + * Various formulas used with Bloom Filters + * @namespace Formulas + * @private + */ + +/** + * Compute the optimal size of a Bloom Filter + * @param length - The length of the set used to fill the filter + * @param errorRate - The targeted false positive rate + * @return The optimal size of a Bloom Filter + * @memberof Formulas + */ +export function optimalFilterSize(length: number, errorRate: number): number { + return Math.ceil(-((length * Math.log(errorRate)) / Math.pow(Math.log(2), 2))) +} + +/** + * Compute the optimal number of hash functions to be used by a Bloom Filter + * @param size - The size of the filter + * @param length - The length of the set used to fill the filter + * @return The optimal number of hash functions to be used by a Bloom Filter + * @memberof Formulas + */ +export function optimalHashes(size: number, length: number): number { + return Math.ceil((size / length) * Math.log(2)) +} diff --git a/packages/next/src/shared/lib/bloom-filter/hashing.ts b/packages/next/src/shared/lib/bloom-filter/hashing.ts new file mode 100644 index 0000000000000..ba2fedb524727 --- /dev/null +++ b/packages/next/src/shared/lib/bloom-filter/hashing.ts @@ -0,0 +1,243 @@ +import fnv1a from '../fnv1a' +import { getDefaultSeed, numberToHex } from './utils' + +/** + * @typedef {TwoHashes} Two hashes of the same value, as computed by {@link hashTwice}. + * @property {number} first - The result of the first hashing function applied to a value + * @property {number} second - The result of the second hashing function applied to a value + * @memberof Utils + */ +export interface TwoHashes { + first: number + second: number +} + +/** + * Templated TwoHashes type + */ +export interface TwoHashesTemplated { + first: T + second: T +} + +/** + * TwoHashes type in number and int format + */ +export interface TwoHashesIntAndString { + int: TwoHashesTemplated + string: TwoHashesTemplated +} + +/** + * Data type of an hashable value, must be string, ArrayBuffer or Buffer. + */ +export type HashableInput = string + +export default class Hashing implements Hashing { + /** + * Apply enhanced Double Hashing to produce a n-hash + * @see {@link http://peterd.org/pcd-diss.pdf} s6.5.4 + * @param n - The indice of the hash function we want to produce + * @param hashA - The result of the first hash function applied to a value. + * @param hashB - The result of the second hash function applied to a value. + * @param size - The size of the datastructures associated to the hash context (ex: the size of a Bloom Filter) + * @return The result of hash_n applied to a value. + * @memberof Hashing + * @author Thomas Minier + * @author Arnaud Grall + */ + public doubleHashing( + n: number, + hashA: number, + hashB: number, + size: number + ): number { + return Math.abs((hashA + n * hashB + Math.floor((n ** 3 - n) / 6)) % size) + } + + /** + * Generate a set of distinct indexes on interval [0, size) using the double hashing technique + * For generating efficiently distinct indexes we re-hash after detecting a cycle by changing slightly the seed. + * It has the effect of generating faster distinct indexes without loosing entirely the utility of the double hashing. + * For small number of indexes it will work perfectly. For a number close to the size, and size very large + * Advise: do not generate `size` indexes for a large interval. In practice, size should be equal + * to the number of hash functions used and is often low. + * + * @param element - The element to hash + * @param size - the range on which we can generate an index [0, size) = size + * @param number - The number of indexes desired + * @param seed - The seed used + * @return Array + * @author Arnaud Grall + * @author Simon Woolf (SimonWoolf) + */ + public getDistinctIndexes( + element: HashableInput, + size: number, + number: number, + seed?: number + ): Array { + if (seed === undefined) { + seed = getDefaultSeed() + } + let n = 0 + const indexes: Set = new Set() + let hashes = this.hashTwice(element, seed) + // let cycle = 0 + while (indexes.size < number) { + const ind = hashes.first % size + if (!indexes.has(ind)) { + indexes.add(ind) + } + hashes.first = (hashes.first + hashes.second) % size + hashes.second = (hashes.second + n) % size + n++ + + if (n > size) { + // Enhanced double hashing stops cycles of length less than `size` in the case where + // size is coprime with the second hash. But you still get cycles of length `size`. + // So if we reach there and haven't finished, append a prime to the input and + // rehash. + seed++ + hashes = this.hashTwice(element, seed) + } + } + return [...indexes.values()] + } + + /** + * Generate N indexes on range [0, size) + * It uses the double hashing technique to generate the indexes. + * It hash twice the value only once before generating the indexes. + * Warning: you can have a lot of modulo collisions. + * @param element - The element to hash + * @param size - The range on which we can generate the index, exclusive + * @param hashCount - The number of indexes we want + * @return An array of indexes on range [0, size) + */ + public getIndexes( + element: HashableInput, + size: number, + hashCount: number, + seed?: number + ): Array { + if (seed === undefined) { + seed = getDefaultSeed() + } + const arr = [] + const hashes = this.hashTwice(element, seed) + for (let i = 0; i < hashCount; i++) { + arr.push(this.doubleHashing(i, hashes.first, hashes.second, size)) + } + return arr + } + + /** + * @internal + * Hash an element of type {@link HashableInput} into {@link Number} + * Can be overrided as long as you return a value of type {@link Number} + * Don't forget to use the seed when hashing, otherwise if some kind of randomness is in the process + * you may have inconsistent behaviors between 2 runs. + * @param element + * @param seed + * @returns A 64bits floating point {@link Number} + */ + public serialize(element: HashableInput, seed?: number) { + if (!seed) { + seed = getDefaultSeed() + } + return Number(fnv1a(element, { seed })) + } + + /** + * (64-bits only) Hash a value into two values (in hex or integer format) + * @param value - The value to hash + * @param asInt - (optional) If True, the values will be returned as an integer. Otherwise, as hexadecimal values. + * @param seed the seed used for hashing + * @return The results of the hash functions applied to the value (in hex or integer) + * @author Arnaud Grall & Thomas Minier + */ + public hashTwice(value: HashableInput, seed?: number): TwoHashes { + if (seed === undefined) { + seed = getDefaultSeed() + } + return { + first: this.serialize(value, seed + 1), + second: this.serialize(value, seed + 2), + } + } + + /** + * Hash twice an element into their HEX string representations + * @param value + * @param seed + * @returns TwoHashesTemplated + */ + public hashTwiceAsString( + value: HashableInput, + seed?: number + ): TwoHashesTemplated { + const { first, second } = this.hashTwice(value, seed) + return { + first: numberToHex(first), + second: numberToHex(second), + } + } + + /** + * (64-bits only) Same as hashTwice but return Numbers and String equivalent + * @param val the value to hash + * @param seed the seed to change when hashing + * @return TwoHashesIntAndString + * @author Arnaud Grall + */ + public hashTwiceIntAndString( + val: HashableInput, + seed?: number + ): TwoHashesIntAndString { + if (seed === undefined) { + seed = getDefaultSeed() + } + const one = this.hashIntAndString(val, seed + 1) + const two = this.hashIntAndString(val, seed + 2) + return { + int: { + first: one.int, + second: two.int, + }, + string: { + first: one.string, + second: two.string, + }, + } + } + + /** + * Hash an item as an unsigned int + * @param elem - Element to hash + * @param seed - The hash seed. If its is UINT32 make sure to set the length to 32 + * @param length - The length of hashes (defaults to 32 bits) + * @return The hash value as an unsigned int + * @author Arnaud Grall + */ + public hashAsInt(elem: HashableInput, seed?: number): number { + if (seed === undefined) { + seed = getDefaultSeed() + } + return this.serialize(elem, seed) + } + + /** + * Hash an item and return its number and HEX string representation + * @param elem - Element to hash + * @param seed - The hash seed. If its is UINT32 make sure to set the length to 32 + * @param base - The base in which the string will be returned, default: 16 + * @param length - The length of hashes (defaults to 32 bits) + * @return The item hased as an int and a string + * @author Arnaud Grall + */ + public hashIntAndString(elem: HashableInput, seed?: number) { + const hash = this.hashAsInt(elem, seed) + return { int: hash, string: numberToHex(hash) } + } +} diff --git a/packages/next/src/shared/lib/bloom-filter/index.ts b/packages/next/src/shared/lib/bloom-filter/index.ts new file mode 100644 index 0000000000000..6d3470db8faba --- /dev/null +++ b/packages/next/src/shared/lib/bloom-filter/index.ts @@ -0,0 +1,200 @@ +// original source: https://github.com/Callidon/bloom-filters +/* file : bloom-filter.ts +MIT License + +Copyright (c) 2017 Thomas Minier & Arnaud Grall + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +import BaseFilter from './base-filter' +import BitSet from './bit-set' +import { optimalFilterSize, optimalHashes } from './formulas' +import { HashableInput } from './hashing' + +/** + * A Bloom filter is a space-efficient probabilistic data structure, conceived by Burton Howard Bloom in 1970, + * that is used to test whether an element is a member of a set. False positive matches are possible, but false negatives are not. + * + * Reference: Bloom, B. H. (1970). Space/time trade-offs in hash coding with allowable errors. Communications of the ACM, 13(7), 422-426. + * @see {@link http://crystal.uta.edu/~mcguigan/cse6350/papers/Bloom.pdf} for more details about classic Bloom Filters. + * @author Thomas Minier + * @author Arnaud Grall + */ +export class BloomFilter extends BaseFilter { + public _size: number + public _nbHashes: number + public _filter: BitSet + + /** + * Constructor + * @param size - The number of cells + * @param nbHashes - The number of hash functions used + */ + constructor(size: number, nbHashes: number) { + super() + if (nbHashes < 1) { + throw new Error( + `A BloomFilter cannot uses less than one hash function, while you tried to use ${nbHashes}.` + ) + } + this._size = size + this._nbHashes = nbHashes + this._filter = new BitSet(size) + } + + /** + * Create an optimal bloom filter providing the maximum of elements stored and the error rate desired + * @param nbItems - The maximum number of item to store + * @param errorRate - The error rate desired for a maximum of items inserted + * @return A new {@link BloomFilter} + */ + public static create(nbItems: number, errorRate: number): BloomFilter { + const size = optimalFilterSize(nbItems, errorRate) + const hashes = optimalHashes(size, nbItems) + return new this(size, hashes) + } + + /** + * Build a new Bloom Filter from an existing iterable with a fixed error rate + * @param items - The iterable used to populate the filter + * @param errorRate - The error rate, i.e. 'false positive' rate, targeted by the filter + * @param seed - The random number seed (optional) + * @return A new Bloom Filter filled with the iterable's elements + * @example + * ```js + * // create a filter with a false positive rate of 0.1 + * const filter = BloomFilter.from(['alice', 'bob', 'carl'], 0.1); + * ``` + */ + public static from( + items: Iterable, + errorRate: number, + seed?: number + ): BloomFilter { + const array = Array.from(items) + const filter = BloomFilter.create(array.length, errorRate) + if (typeof seed === 'number') { + filter.seed = seed + } + array.forEach((element) => filter.add(element)) + return filter + } + + /** + * Get the optimal size of the filter + * @return The size of the filter + */ + get size(): number { + return this._size + } + + /** + * Get the number of bits currently set in the filter + * @return The filter length + */ + public get length(): number { + return this._filter.bitCount() + } + + /** + * Add an element to the filter + * @param element - The element to add + * @example + * ```js + * const filter = new BloomFilter(15, 0.1); + * filter.add('foo'); + * ``` + */ + public add(element: HashableInput): void { + const indexes = this._hashing.getIndexes( + element, + this._size, + this._nbHashes, + this.seed + ) + for (let i = 0; i < indexes.length; i++) { + this._filter.add(indexes[i]) + } + } + + /** + * Test an element for membership + * @param element - The element to look for in the filter + * @return False if the element is definitively not in the filter, True is the element might be in the filter + * @example + * ```js + * const filter = new BloomFilter(15, 0.1); + * filter.add('foo'); + * console.log(filter.has('foo')); // output: true + * console.log(filter.has('bar')); // output: false + * ``` + */ + public has(element: HashableInput): boolean { + const indexes = this._hashing.getIndexes( + element, + this._size, + this._nbHashes, + this.seed + ) + for (let i = 0; i < indexes.length; i++) { + if (!this._filter.has(indexes[i])) { + return false + } + } + return true + } + + /** + * Get the current false positive rate (or error rate) of the filter + * @return The current false positive rate of the filter + * @example + * ```js + * const filter = new BloomFilter(15, 0.1); + * console.log(filter.rate()); // output: something around 0.1 + * ``` + */ + public rate(): number { + return Math.pow(1 - Math.exp(-this.length / this._size), this._nbHashes) + } + + /** + * Check if another Bloom Filter is equal to this one + * @param other - The filter to compare to this one + * @return True if they are equal, false otherwise + */ + public equals(other: BloomFilter): boolean { + if (this._size !== other._size || this._nbHashes !== other._nbHashes) { + return false + } + return this._filter.equals(other._filter) + } + + public export() { + return { + bitset: this._filter.export(), + hashes: this._nbHashes, + size: this._size, + } + } + + public import(data: ReturnType) { + this._filter = BitSet.import(data.bitset) + } +} diff --git a/packages/next/src/shared/lib/bloom-filter/utils.ts b/packages/next/src/shared/lib/bloom-filter/utils.ts new file mode 100644 index 0000000000000..919a41d881619 --- /dev/null +++ b/packages/next/src/shared/lib/bloom-filter/utils.ts @@ -0,0 +1,181 @@ +/* file : utils.ts +MIT License + +Copyright (c) 2017-2020 Thomas Minier & Arnaud Grall + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/** + * Utilitaries functions + * @namespace Utils + * @private + */ + +/* JSDOC typedef */ +/** + * @typedef {TwoHashes} Two hashes of the same value, as computed by {@link hashTwice}. + * @property {number} first - The result of the first hashing function applied to a value + * @property {number} second - The result of the second hashing function applied to a value + * @memberof Utils + */ +export interface TwoHashes { + first: number + second: number +} + +/** + * Templated TwoHashes type + */ +export interface TwoHashesTemplated { + first: T + second: T +} + +/** + * TwoHashes type in number and int format + */ +export interface TwoHashesIntAndString { + int: TwoHashesTemplated + string: TwoHashesTemplated +} + +/** + * Data type of an hashable value, must be string, ArrayBuffer or Buffer. + */ +export type HashableInput = string | ArrayBuffer | Buffer + +/** + * BufferError + */ +export const BufferError = + 'The buffer class must be available, if you are a browser user use the buffer package (https://www.npmjs.com/package/buffer)' + +/** + * Create a new array fill with a base value + * @param size - The size of the array + * @param defaultValue - The default value used to fill the array. If it's a function, it will be invoked to get the default value. + * @return A newly allocated array + * @memberof Utils + */ +export function allocateArray( + size: number, + defaultValue: T | (() => T) +): Array { + const array: Array = new Array(size) + const getDefault = + typeof defaultValue === 'function' + ? (defaultValue as () => T) + : () => defaultValue + for (let ind = 0; ind < size; ind++) { + array[ind] = getDefault() + } + return array +} + +/** + * Return a number to its Hex format by padding zeroes if length mod 4 != 0 + * @param elem the element to transform in HEX + * @returns the HEX number padded of zeroes + */ +export function numberToHex(elem: number): string { + let e = Number(elem).toString(16) + if (e.length % 4 !== 0) { + e = '0'.repeat(4 - (e.length % 4)) + e + } + return e +} + +/** + * Generate a random int between two bounds (included) + * @param min - The lower bound + * @param max - The upper bound + * @param random - Function used to generate random floats + * @return A random int bewteen lower and upper bound (included) + * @memberof Utils + * @author Thomas Minier + */ +export function randomInt( + min: number, + max: number, + random?: () => number +): number { + if (random === undefined) { + random = Math.random + } + min = Math.ceil(min) + max = Math.floor(max) + const rn = random() + return Math.floor(rn * (max - min + 1)) + min +} + +/** + * Return the non-destructive XOR of two buffers + * @param a - The buffer to copy, then to xor with b + * @param b - The buffer to xor with + * @return The results of the XOR between the two buffers + * @author Arnaud Grall + */ +export function xorBuffer(a: Buffer, b: Buffer): Buffer { + const length = Math.max(a.length, b.length) + const buffer = Buffer.allocUnsafe(length).fill(0) + for (let i = 0; i < length; ++i) { + if (i < a.length && i < b.length) { + buffer[length - i - 1] = a[a.length - i - 1] ^ b[b.length - i - 1] + } else if (i < a.length && i >= b.length) { + buffer[length - i - 1] ^= a[a.length - i - 1] + } else if (i < b.length && i >= a.length) { + buffer[length - i - 1] ^= b[b.length - i - 1] + } + } + // now need to remove leading zeros in the buffer if any + let start = 0 + const it = buffer.values() + let value = it.next() + while (!value.done && value.value === 0) { + start++ + value = it.next() + } + return buffer.slice(start) +} + +/** + * Return true if the buffer is empty, i.e., all value are equals to 0. + * @param buffer - The buffer to inspect + * @return True if the buffer only contains zero, False otherwise + * @author Arnaud Grall + */ +export function isEmptyBuffer(buffer: Buffer | null): boolean { + if (buffer === null || !buffer) return true + for (const value of buffer) { + if (value !== 0) { + return false + } + } + return true +} + +/** + * Return the default seed used in the package + * @return A seed as a floating point number + * @author Arnaud Grall + */ +export function getDefaultSeed(): number { + return 0x1234567890 +} diff --git a/packages/next/src/shared/lib/fnv1a.ts b/packages/next/src/shared/lib/fnv1a.ts new file mode 100644 index 0000000000000..35d1579c24772 --- /dev/null +++ b/packages/next/src/shared/lib/fnv1a.ts @@ -0,0 +1,72 @@ +// source: https://github.com/sindresorhus/fnv1a +// FNV_PRIMES and FNV_OFFSETS from +// http://www.isthe.com/chongo/tech/comp/fnv/index.html#FNV-param + +const FNV_PRIMES = { + 32: BigInt(16_777_619), + 64: BigInt(1_099_511_628_211), + 128: BigInt(309_485_009_821_345_068_724_781_371), + 256: BigInt( + 374_144_419_156_711_147_060_143_317_175_368_453_031_918_731_002_211 + ), + 512: BigInt( + 35_835_915_874_844_867_368_919_076_489_095_108_449_946_327_955_754_392_558_399_825_615_420_669_938_882_575_126_094_039_892_345_713_852_759 + ), + 1024: BigInt( + 5_016_456_510_113_118_655_434_598_811_035_278_955_030_765_345_404_790_744_303_017_523_831_112_055_108_147_451_509_157_692_220_295_382_716_162_651_878_526_895_249_385_292_291_816_524_375_083_746_691_371_804_094_271_873_160_484_737_966_720_260_389_217_684_476_157_468_082_573 + ), +} as const + +const FNV_OFFSETS = { + 32: BigInt(2_166_136_261), + 64: BigInt(14_695_981_039_346_656_037), + 128: BigInt(144_066_263_297_769_815_596_495_629_667_062_367_629), + 256: BigInt( + 100_029_257_958_052_580_907_070_968_620_625_704_837_092_796_014_241_193_945_225_284_501_741_471_925_557 + ), + 512: BigInt( + 9_659_303_129_496_669_498_009_435_400_716_310_466_090_418_745_672_637_896_108_374_329_434_462_657_994_582_932_197_716_438_449_813_051_892_206_539_805_784_495_328_239_340_083_876_191_928_701_583_869_517_785 + ), + 1024: BigInt( + 14_197_795_064_947_621_068_722_070_641_403_218_320_880_622_795_441_933_960_878_474_914_617_582_723_252_296_732_303_717_722_150_864_096_521_202_355_549_365_628_174_669_108_571_814_760_471_015_076_148_029_755_969_804_077_320_157_692_458_563_003_215_304_957_150_157_403_644_460_363_550_505_412_711_285_966_361_610_267_868_082_893_823_963_790_439_336_411_086_884_584_107_735_010_676_915 + ), +} as const + +export default function fnv1a( + inputString: string, + { + size = 32, + seed = 0, + }: { + size?: keyof typeof FNV_PRIMES + seed?: number + } = {} +) { + if (!FNV_PRIMES[size]) { + throw new Error( + 'The `size` option must be one of 32, 64, 128, 256, 512, or 1024' + ) + } + + let hash: bigint = FNV_OFFSETS[size] ^ BigInt(seed) + const fnvPrime = FNV_PRIMES[size] + + // Handle Unicode code points > 0x7f + let isUnicoded = false + + for (let index = 0; index < inputString.length; index++) { + let characterCode = inputString.charCodeAt(index) + + // Non-ASCII characters trigger the Unicode escape logic + if (characterCode > 0x7f && !isUnicoded) { + inputString = unescape(encodeURIComponent(inputString)) + characterCode = inputString.charCodeAt(index) + isUnicoded = true + } + + hash ^= BigInt(characterCode) + hash = BigInt.asUintN(size, hash * fnvPrime) + } + + return hash +} diff --git a/packages/next/src/shared/lib/router/router.ts b/packages/next/src/shared/lib/router/router.ts index e80a26b9fec6f..da27b258242c5 100644 --- a/packages/next/src/shared/lib/router/router.ts +++ b/packages/next/src/shared/lib/router/router.ts @@ -697,6 +697,10 @@ export default class Router implements BaseRouter { isLocaleDomain: boolean isFirstPopStateEvent = true _initialMatchesMiddlewarePromise: Promise + // static entries filter + _bfl_s?: import('../../lib/bloom-filter').BloomFilter + // dynamic entires filter + _bfl_d?: import('../../lib/bloom-filter').BloomFilter private state: Readonly<{ route: string @@ -772,6 +776,34 @@ export default class Router implements BaseRouter { ], } + if (process.env.__NEXT_CLIENT_ROUTER_FILTER_ENABLED) { + const { BloomFilter } = + require('../../lib/bloom-filter') as typeof import('../../lib/bloom-filter') + + const staticFilterData: + | ReturnType + | undefined = process.env.__NEXT_CLIENT_ROUTER_S_FILTER as any + + const dynamicFilterData: typeof staticFilterData = process.env + .__NEXT_CLIENT_ROUTER_D_FILTER as any + + if (staticFilterData?.hashes) { + this._bfl_s = new BloomFilter( + staticFilterData.size, + staticFilterData.hashes + ) + this._bfl_s.import(staticFilterData) + } + + if (dynamicFilterData?.hashes) { + this._bfl_d = new BloomFilter( + dynamicFilterData.size, + dynamicFilterData.hashes + ) + this._bfl_d.import(dynamicFilterData) + } + } + // Backwards compat for Router.router.events // TODO: Should be remove the following major version as it was never documented this.events = Router.events @@ -1035,6 +1067,32 @@ export default class Router implements BaseRouter { // hydration. Your app should _never_ use this property. It may change at // any time without notice. const isQueryUpdating = (options as any)._h === 1 + + if (process.env.__NEXT_CLIENT_ROUTER_FILTER_ENABLED) { + const asNoSlash = removeTrailingSlash(as) + const matchesBflStatic = this._bfl_s?.has(asNoSlash) + let matchesBflDynamic = false + const asNoSlashParts = asNoSlash.split('/') + + // if any sub-path of as matches a dynamic filter path + // it should be hard navigated + for (let i = 0; i < asNoSlashParts.length + 1; i++) { + const currentPart = asNoSlashParts.slice(0, i).join('/') + console.log('checking for', currentPart) + if (this._bfl_d?.has(currentPart)) { + matchesBflDynamic = true + break + } + } + + // if the client router filter is matched then we trigger + // a hard navigation + if (!isQueryUpdating && (matchesBflStatic || matchesBflDynamic)) { + handleHardNavigation({ url: as, router: this }) + return false + } + } + let shouldResolveHref = isQueryUpdating || (options as any)._shouldResolveHref || diff --git a/test/e2e/app-dir/app/index.test.ts b/test/e2e/app-dir/app/index.test.ts index 2ed8375cec618..26aa384422195 100644 --- a/test/e2e/app-dir/app/index.test.ts +++ b/test/e2e/app-dir/app/index.test.ts @@ -16,6 +16,27 @@ createNextDescribe( }, }, ({ next, isNextDev: isDev, isNextStart, isNextDeploy }) => { + it.each([ + { pathname: '/redirect-1' }, + { pathname: '/redirect-2' }, + { pathname: '/blog/old-post' }, + { pathname: '/redirect-3/some/value' }, + { pathname: '/redirect-3/some' }, + { pathname: '/redirect-4' }, + { pathname: '/redirect-4/another' }, + ])( + 'should match redirects in pages correctly $path', + async ({ pathname }) => { + const browser = await next.browser('/') + + await browser.eval(`next.router.push("${pathname}")`) + await check(async () => { + const href = await browser.eval('location.href') + return href.includes('example.vercel.sh') ? 'yes' : href + }, 'yes') + } + ) + if (isDev) { it('should not have duplicate config warnings', async () => { await next.fetch('/') @@ -591,8 +612,8 @@ createNextDescribe( try { // Click the link. await browser.elementById('pages-link').click() - expect(await browser.waitForElementByCss('#pages-text').text()).toBe( - 'hello from pages/dynamic-pages-route-app-overlap/[slug]' + expect(await browser.waitForElementByCss('#app-text').text()).toBe( + 'hello from app/dynamic-pages-route-app-overlap/app-dir/page' ) // When refreshing the browser, the app page should be rendered diff --git a/test/e2e/app-dir/app/next.config.js b/test/e2e/app-dir/app/next.config.js index 60ee39dfb8c9e..12cd00e098ac0 100644 --- a/test/e2e/app-dir/app/next.config.js +++ b/test/e2e/app-dir/app/next.config.js @@ -26,4 +26,34 @@ module.exports = { ], } }, + + redirects: async () => { + return [ + { + source: '/redirect-1', + destination: 'https://example.vercel.sh', + permanent: false, + }, + { + source: '/redirect-2', + destination: 'https://example.vercel.sh', + permanent: false, + }, + { + source: '/blog/old-post', + destination: 'https://example.vercel.sh', + permanent: false, + }, + { + source: '/redirect-3/some/:path*', + destination: 'https://example.vercel.sh', + permanent: false, + }, + { + source: '/redirect-4/:path*', + destination: 'https://example.vercel.sh', + permanent: false, + }, + ] + }, } diff --git a/test/e2e/app-dir/pages-to-app-routing/app/about/page.tsx b/test/e2e/app-dir/pages-to-app-routing/app/about/page.tsx new file mode 100644 index 0000000000000..55edafce41ae2 --- /dev/null +++ b/test/e2e/app-dir/pages-to-app-routing/app/about/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

About

+} diff --git a/test/e2e/app-dir/pages-to-app-routing/app/layout.tsx b/test/e2e/app-dir/pages-to-app-routing/app/layout.tsx new file mode 100644 index 0000000000000..e7077399c03ce --- /dev/null +++ b/test/e2e/app-dir/pages-to-app-routing/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/pages-to-app-routing/middleware.ts b/test/e2e/app-dir/pages-to-app-routing/middleware.ts new file mode 100644 index 0000000000000..e97ded7762422 --- /dev/null +++ b/test/e2e/app-dir/pages-to-app-routing/middleware.ts @@ -0,0 +1,3 @@ +import type { NextRequest } from 'next/server' + +export function middleware(request: NextRequest) {} diff --git a/test/e2e/app-dir/pages-to-app-routing/next.config.js b/test/e2e/app-dir/pages-to-app-routing/next.config.js new file mode 100644 index 0000000000000..bf49894afd400 --- /dev/null +++ b/test/e2e/app-dir/pages-to-app-routing/next.config.js @@ -0,0 +1,8 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { appDir: true }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/pages-to-app-routing/pages-to-app-routing.test.ts b/test/e2e/app-dir/pages-to-app-routing/pages-to-app-routing.test.ts new file mode 100644 index 0000000000000..d5462d53017e2 --- /dev/null +++ b/test/e2e/app-dir/pages-to-app-routing/pages-to-app-routing.test.ts @@ -0,0 +1,23 @@ +import { createNextDescribe } from 'e2e-utils' + +createNextDescribe( + 'pages-to-app-routing', + { + files: __dirname, + }, + ({ next }) => { + it('should work using browser', async () => { + const browser = await next.browser('/abc') + expect(await browser.elementByCss('#params').text()).toBe( + 'Params: {"slug":"abc"}' + ) + + await browser + .elementByCss('#to-about-link') + .click() + .waitForElementByCss('#app-page') + + expect(await browser.elementByCss('#app-page').text()).toBe('About') + }) + } +) diff --git a/test/e2e/app-dir/pages-to-app-routing/pages/[slug].js b/test/e2e/app-dir/pages-to-app-routing/pages/[slug].js new file mode 100644 index 0000000000000..e1364a257f1f8 --- /dev/null +++ b/test/e2e/app-dir/pages-to-app-routing/pages/[slug].js @@ -0,0 +1,20 @@ +import Link from 'next/link' + +export async function getServerSideProps({ params }) { + return { + props: { + params, + }, + } +} + +export default function Page({ params }) { + return ( + <> +

Params: {JSON.stringify(params)}

+ + To About + + + ) +} diff --git a/test/e2e/edge-compiler-can-import-blob-assets/index.test.ts b/test/e2e/edge-compiler-can-import-blob-assets/index.test.ts index ae18317f9e4ea..ede2ce1606c94 100644 --- a/test/e2e/edge-compiler-can-import-blob-assets/index.test.ts +++ b/test/e2e/edge-compiler-can-import-blob-assets/index.test.ts @@ -4,7 +4,6 @@ import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' import path from 'path' import { promises as fs } from 'fs' import { readJson } from 'fs-extra' -import type { MiddlewareManifest } from 'next/src/build/webpack/plugins/middleware-plugin' describe('Edge Compiler can import asset assets', () => { let next: NextInstance @@ -76,7 +75,7 @@ describe('Edge Compiler can import asset assets', () => { next.testDir, '.next/server/middleware-manifest.json' ) - const manifest: MiddlewareManifest = await readJson(manifestPath) + const manifest = await readJson(manifestPath) const orderedAssets = manifest.functions['/api/edge'].assets.sort( (a, z) => { return String(a.name).localeCompare(z.name)