Skip to content

Commit 3d2c429

Browse files
piehascorbic
andauthored
perf: shim next's telemetry module to improve startup performance (#365)
* perf: shim next's telemetry module to improve startup performance * Update src/build/content/server.ts Co-authored-by: Matt Kane <[email protected]> * fix: don't break the build if failed to apply perf patch * feat: also run on latest canaries * test: setup next patching for tests * test: refactor version checking a bit to allow canaries for ongoing always * test: update var names in tests, make potential assertion failures more readable by checking object with version so failure would log version as well * tmp: make minVersion lower to check that tests would suggest bumping it * test: add test checking that patch is being applied and wether upper bound should be bumped * test: normalize path for win32 (?) * test: normalize path for win32 (vol2) * fix: max version * chore: specify type of patch in error message --------- Co-authored-by: Matt Kane <[email protected]>
1 parent 36b1bb3 commit 3d2c429

File tree

5 files changed

+344
-3
lines changed

5 files changed

+344
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { Telemetry } from 'next/dist/telemetry/storage.js'
2+
3+
type PublicOf<T> = { [K in keyof T]: T[K] }
4+
5+
export class TelemetryShim implements PublicOf<Telemetry> {
6+
sessionId = 'shim'
7+
8+
get anonymousId(): string {
9+
return 'shim'
10+
}
11+
12+
get salt(): string {
13+
return 'shim'
14+
}
15+
16+
setEnabled(): string | null {
17+
return null
18+
}
19+
20+
get isEnabled(): boolean {
21+
return false
22+
}
23+
24+
oneWayHash(): string {
25+
return 'shim'
26+
}
27+
28+
record(): Promise<{
29+
isFulfilled: boolean
30+
isRejected: boolean
31+
value?: unknown
32+
reason?: unknown
33+
}> {
34+
return Promise.resolve({ isFulfilled: true, isRejected: false })
35+
}
36+
37+
flush(): Promise<
38+
{ isFulfilled: boolean; isRejected: boolean; value?: unknown; reason?: unknown }[] | null
39+
> {
40+
return Promise.resolve(null)
41+
}
42+
43+
flushDetached(): void {
44+
// no-op
45+
}
46+
}

src/build/content/server.test.ts

+109-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { expect, test, vi, describe, beforeEach } from 'vitest'
77
import { mockFileSystem } from '../../../tests/index.js'
88
import { PluginContext, RequiredServerFilesManifest } from '../plugin-context.js'
99

10-
import { copyNextServerCode, verifyHandlerDirStructure } from './server.js'
10+
import { copyNextServerCode, getPatchesToApply, verifyHandlerDirStructure } from './server.js'
1111

1212
vi.mock('node:fs', async () => {
1313
// eslint-disable-next-line @typescript-eslint/no-explicit-any, unicorn/no-await-expression-member
@@ -267,3 +267,111 @@ describe('verifyHandlerDirStructure', () => {
267267
)
268268
})
269269
})
270+
271+
describe(`getPatchesToApply`, () => {
272+
beforeEach(() => {
273+
delete process.env.NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES
274+
})
275+
test('ongoing: false', () => {
276+
const shouldPatchBeApplied = {
277+
'13.4.1': false, // before supported next version
278+
'13.5.1': true, // first stable supported version
279+
'14.1.4-canary.1': true, // canary version before stable with maxStableVersion - should be applied
280+
'14.1.4': true, // latest stable tested version
281+
'14.2.0': false, // untested stable version
282+
'14.2.0-canary.38': false, // not ongoing patch so should not be applied
283+
}
284+
285+
const nextModule = 'test'
286+
287+
const patches = [
288+
{
289+
ongoing: false,
290+
minVersion: '13.5.0-canary.0',
291+
maxStableVersion: '14.1.4',
292+
nextModule,
293+
shimModule: 'not-used-in-test',
294+
},
295+
]
296+
297+
for (const [nextVersion, telemetryShimShouldBeApplied] of Object.entries(
298+
shouldPatchBeApplied,
299+
)) {
300+
const patchesToApply = getPatchesToApply(nextVersion, patches)
301+
const hasTelemetryShim = patchesToApply.some((patch) => patch.nextModule === nextModule)
302+
expect({ nextVersion, apply: hasTelemetryShim }).toEqual({
303+
nextVersion,
304+
apply: telemetryShimShouldBeApplied,
305+
})
306+
}
307+
})
308+
309+
test('ongoing: true', () => {
310+
const shouldPatchBeApplied = {
311+
'13.4.1': false, // before supported next version
312+
'13.5.1': true, // first stable supported version
313+
'14.1.4-canary.1': true, // canary version before stable with maxStableVersion - should be applied
314+
'14.1.4': true, // latest stable tested version
315+
'14.2.0': false, // untested stable version
316+
'14.2.0-canary.38': true, // ongoing patch so should be applied on prerelease versions
317+
}
318+
319+
const nextModule = 'test'
320+
321+
const patches = [
322+
{
323+
ongoing: true,
324+
minVersion: '13.5.0-canary.0',
325+
maxStableVersion: '14.1.4',
326+
nextModule,
327+
shimModule: 'not-used-in-test',
328+
},
329+
]
330+
331+
for (const [nextVersion, telemetryShimShouldBeApplied] of Object.entries(
332+
shouldPatchBeApplied,
333+
)) {
334+
const patchesToApply = getPatchesToApply(nextVersion, patches)
335+
const hasTelemetryShim = patchesToApply.some((patch) => patch.nextModule === nextModule)
336+
expect({ nextVersion, apply: hasTelemetryShim }).toEqual({
337+
nextVersion,
338+
apply: telemetryShimShouldBeApplied,
339+
})
340+
}
341+
})
342+
343+
test('ongoing: true + NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES', () => {
344+
process.env.NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES = 'true'
345+
const shouldPatchBeApplied = {
346+
'13.4.1': false, // before supported next version
347+
'13.5.1': true, // first stable supported version
348+
'14.1.4-canary.1': true, // canary version before stable with maxStableVersion - should be applied
349+
'14.1.4': true, // latest stable tested version
350+
'14.2.0': true, // untested stable version but NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES is used
351+
'14.2.0-canary.38': true, // ongoing patch so should be applied on prerelease versions
352+
}
353+
354+
const nextModule = 'test'
355+
356+
const patches = [
357+
{
358+
ongoing: true,
359+
minVersion: '13.5.0-canary.0',
360+
maxStableVersion: '14.1.4',
361+
nextModule,
362+
shimModule: 'not-used-in-test',
363+
},
364+
]
365+
366+
for (const [nextVersion, telemetryShimShouldBeApplied] of Object.entries(
367+
shouldPatchBeApplied,
368+
)) {
369+
const patchesToApply = getPatchesToApply(nextVersion, patches)
370+
const hasTelemetryShim = patchesToApply.some((patch) => patch.nextModule === nextModule)
371+
expect({ nextVersion, apply: hasTelemetryShim }).toEqual({
372+
nextVersion,
373+
apply: telemetryShimShouldBeApplied,
374+
})
375+
}
376+
})
377+
})

src/build/content/server.ts

+90
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { dirname, join, resolve, sep } from 'node:path'
1515
import { sep as posixSep, join as posixJoin } from 'node:path/posix'
1616

1717
import glob from 'fast-glob'
18+
import { prerelease, lt as semverLowerThan, lte as semverLowerThanOrEqual } from 'semver'
1819

1920
import { RUN_CONFIG } from '../../run/constants.js'
2021
import { PluginContext } from '../plugin-context.js'
@@ -149,6 +150,93 @@ async function recreateNodeModuleSymlinks(src: string, dest: string, org?: strin
149150
)
150151
}
151152

153+
export type NextInternalModuleReplacement = {
154+
/**
155+
* Minimum Next.js version that this patch should be applied to
156+
*/
157+
minVersion: string
158+
/**
159+
* Maximum Next.js version that this patch should be applied to, note that for ongoing patches
160+
* we will continue to apply patch for prerelease versions also as canary versions are released
161+
* very frequently and trying to target canary versions is not practical. If user is using
162+
* canary next versions they should be aware of the risks
163+
*/
164+
maxStableVersion: string
165+
/**
166+
* If the reason to patch was not addressed in Next.js we mark this as ongoing
167+
* to continue to test latest versions to know wether we should bump `maxStableVersion`
168+
*/
169+
ongoing?: boolean
170+
/**
171+
* Module that should be replaced
172+
*/
173+
nextModule: string
174+
/**
175+
* Location of replacement module (relative to `<runtime>/dist/build/content`)
176+
*/
177+
shimModule: string
178+
}
179+
180+
const nextInternalModuleReplacements: NextInternalModuleReplacement[] = [
181+
{
182+
// standalone is loading expensive Telemetry module that is not actually used
183+
// so this replace that module with lightweight no-op shim that doesn't load additional modules
184+
// see https://github.com/vercel/next.js/pull/63574 that will remove need for this shim
185+
ongoing: true,
186+
minVersion: '13.5.0-canary.0',
187+
maxStableVersion: '14.1.4',
188+
nextModule: 'next/dist/telemetry/storage.js',
189+
shimModule: './next-shims/telemetry-storage.cjs',
190+
},
191+
]
192+
193+
export function getPatchesToApply(
194+
nextVersion: string,
195+
patches: NextInternalModuleReplacement[] = nextInternalModuleReplacements,
196+
) {
197+
return patches.filter(({ minVersion, maxStableVersion, ongoing }) => {
198+
// don't apply patches for next versions below minVersion
199+
if (semverLowerThan(nextVersion, minVersion)) {
200+
return false
201+
}
202+
203+
// apply ongoing patches when used next version is prerelease or NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES env var is used
204+
if (
205+
ongoing &&
206+
(prerelease(nextVersion) || process.env.NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES)
207+
) {
208+
return true
209+
}
210+
211+
// apply patches for next versions below maxStableVersion
212+
return semverLowerThanOrEqual(nextVersion, maxStableVersion)
213+
})
214+
}
215+
216+
async function patchNextModules(
217+
ctx: PluginContext,
218+
nextVersion: string,
219+
serverHandlerRequireResolve: NodeRequire['resolve'],
220+
): Promise<void> {
221+
// apply only those patches that target used Next version
222+
const moduleReplacementsToApply = getPatchesToApply(nextVersion)
223+
224+
if (moduleReplacementsToApply.length !== 0) {
225+
await Promise.all(
226+
moduleReplacementsToApply.map(async ({ nextModule, shimModule }) => {
227+
try {
228+
const nextModulePath = serverHandlerRequireResolve(nextModule)
229+
const shimModulePath = posixJoin(ctx.pluginDir, 'dist', 'build', 'content', shimModule)
230+
231+
await cp(shimModulePath, nextModulePath, { force: true })
232+
} catch {
233+
// this is perf optimization, so failing it shouldn't break the build
234+
}
235+
}),
236+
)
237+
}
238+
}
239+
152240
export const copyNextDependencies = async (ctx: PluginContext): Promise<void> => {
153241
const entries = await readdir(ctx.standaloneDir)
154242
const promises: Promise<void>[] = entries.map(async (entry) => {
@@ -199,6 +287,8 @@ export const copyNextDependencies = async (ctx: PluginContext): Promise<void> =>
199287

200288
if (nextVersion) {
201289
verifyNextVersion(ctx, nextVersion)
290+
291+
await patchNextModules(ctx, nextVersion, serverHandlerRequire.resolve)
202292
}
203293

204294
try {

tests/integration/simple-app.test.ts

+95-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { load } from 'cheerio'
22
import { getLogger } from 'lambda-local'
3+
import { cp } from 'node:fs/promises'
4+
import { createRequire } from 'node:module'
5+
import { join } from 'node:path'
36
import { gunzipSync } from 'node:zlib'
7+
import { gt, prerelease } from 'semver'
48
import { v4 } from 'uuid'
5-
import { beforeEach, describe, expect, test, vi } from 'vitest'
9+
import { Mock, afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
610
import {
711
createFixture,
812
invokeFunction,
913
runPlugin,
14+
getFixtureSourceDirectory,
1015
type FixtureTestContext,
1116
} from '../utils/fixture.js'
1217
import {
@@ -15,6 +20,20 @@ import {
1520
getBlobEntries,
1621
startMockBlobStore,
1722
} from '../utils/helpers.js'
23+
import { getPatchesToApply } from '../../src/build/content/server.js'
24+
25+
const mockedCp = cp as Mock<
26+
Parameters<(typeof import('node:fs/promises'))['cp']>,
27+
ReturnType<(typeof import('node:fs/promises'))['cp']>
28+
>
29+
30+
vi.mock('node:fs/promises', async (importOriginal) => {
31+
const fsPromisesModule = (await importOriginal()) as typeof import('node:fs/promises')
32+
return {
33+
...fsPromisesModule,
34+
cp: vi.fn(fsPromisesModule.cp.bind(fsPromisesModule)),
35+
}
36+
})
1837

1938
// Disable the verbose logging of the lambda-local runtime
2039
getLogger().level = 'alert'
@@ -183,3 +202,78 @@ test<FixtureTestContext>('Test that a simple next app with PPR is working', asyn
183202
expect(home.statusCode).toBe(200)
184203
expect(load(home.body)('h1').text()).toBe('Home')
185204
})
205+
206+
describe('next patching', async () => {
207+
const { cp: originalCp, appendFile } = (await vi.importActual(
208+
'node:fs/promises',
209+
)) as typeof import('node:fs/promises')
210+
211+
const { version: nextVersion } = createRequire(
212+
`${getFixtureSourceDirectory('simple-next-app')}/:internal:`,
213+
)('next/package.json')
214+
215+
beforeAll(() => {
216+
process.env.NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES = 'true'
217+
})
218+
219+
afterAll(() => {
220+
delete process.env.NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES
221+
})
222+
223+
beforeEach(() => {
224+
mockedCp.mockClear()
225+
mockedCp.mockRestore()
226+
})
227+
228+
test<FixtureTestContext>(`expected patches are applied and used (next version: "${nextVersion}")`, async (ctx) => {
229+
const patches = getPatchesToApply(nextVersion)
230+
231+
await createFixture('simple-next-app', ctx)
232+
233+
const fieldNamePrefix = `TEST_${Date.now()}`
234+
235+
mockedCp.mockImplementation(async (...args) => {
236+
const returnValue = await originalCp(...args)
237+
if (typeof args[1] === 'string') {
238+
for (const patch of patches) {
239+
if (args[1].includes(join(patch.nextModule))) {
240+
// we append something to assert that patch file was actually used
241+
await appendFile(
242+
args[1],
243+
`;globalThis['${fieldNamePrefix}_${patch.nextModule}'] = 'patched'`,
244+
)
245+
}
246+
}
247+
}
248+
249+
return returnValue
250+
})
251+
252+
await runPlugin(ctx)
253+
254+
// patched files was not used before function invocation
255+
for (const patch of patches) {
256+
expect(globalThis[`${fieldNamePrefix}_${patch.nextModule}`]).not.toBeDefined()
257+
}
258+
259+
const home = await invokeFunction(ctx)
260+
// make sure the function does work
261+
expect(home.statusCode).toBe(200)
262+
expect(load(home.body)('h1').text()).toBe('Home')
263+
264+
let shouldUpdateUpperBoundMessage = ''
265+
266+
// file was used during function invocation
267+
for (const patch of patches) {
268+
expect(globalThis[`${fieldNamePrefix}_${patch.nextModule}`]).toBe('patched')
269+
270+
if (patch.ongoing && !prerelease(nextVersion) && gt(nextVersion, patch.maxStableVersion)) {
271+
shouldUpdateUpperBoundMessage += `Ongoing ${shouldUpdateUpperBoundMessage ? '\n' : ''}"${patch.nextModule}" patch still works on "${nextVersion}" which is higher than currently set maxStableVersion ("${patch.maxStableVersion}"). Update maxStableVersion in "src/build/content/server.ts" for this patch to at least "${nextVersion}".`
272+
}
273+
}
274+
275+
if (shouldUpdateUpperBoundMessage) {
276+
expect.fail(shouldUpdateUpperBoundMessage)
277+
}
278+
})
279+
})

0 commit comments

Comments
 (0)