Skip to content

Commit 7c33ac3

Browse files
authored
fix: blob key collisions (#212)
* fix: ensure blob keys for routes begin with leading slash * chore: update tests * chore: update blobkey tests
1 parent f253aa1 commit 7c33ac3

11 files changed

+50
-64
lines changed

src/build/content/prerendered.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
/**
1616
* Normalize routes by stripping leading slashes and ensuring root path is index
1717
*/
18-
const routeToFilePath = (path: string) => path.replace(/^\//, '') || 'index'
18+
const routeToFilePath = (path: string) => (path === '/' ? '/index' : path)
1919

2020
const buildPagesCacheValue = async (path: string): Promise<PageCacheValue> => ({
2121
kind: 'PAGE',
@@ -78,7 +78,7 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise<void>
7878
// app router 404 pages are not in the prerender manifest
7979
// so we need to check for them manually
8080
if (existsSync(join(ctx.publishDir, `server/app/_not-found.html`))) {
81-
const key = '404'
81+
const key = '/404'
8282
const value = await buildAppCacheValue(join(ctx.publishDir, 'server/app/_not-found'))
8383
await ctx.writeCacheEntry(key, value)
8484
}

src/run/next.cts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export async function getMockedRequestHandlers(...args: Parameters<typeof getReq
1212
const store = getDeployStore()
1313
const ofs = { ...fs }
1414

15-
const { encodeBlobKey } = await import("../shared/blobkey.js")
15+
const { encodeBlobKey } = await import('../shared/blobkey.js')
1616

1717
async function readFileFallbackBlobStore(...fsargs: Parameters<FS['promises']['readFile']>) {
1818
const [path, options] = fsargs

src/shared/blobkey.test.ts

+2-10
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,22 @@
11
import { Buffer } from 'node:buffer'
22

3-
import { expect, describe, it } from 'vitest'
3+
import { describe, expect, it } from 'vitest'
44

55
import { encodeBlobKey } from './blobkey.js'
66

77
describe('encodeBlobKey', () => {
8-
it('ignores leading slashes', async () => {
9-
expect(await encodeBlobKey('/foo')).toEqual(await encodeBlobKey('foo'))
10-
})
11-
128
const longKey = 'long'.repeat(100)
139

1410
it('truncates long keys to 180 characters', async () => {
1511
expect(await encodeBlobKey(longKey)).toHaveLength(180)
1612
})
1713

18-
it('truncated keys also ignore leading slashes', async () => {
19-
expect(await encodeBlobKey(`/${longKey}`)).toEqual(await encodeBlobKey(longKey))
20-
})
21-
2214
it('adds a differentiating hash to truncated keys', async () => {
2315
expect(await encodeBlobKey(`${longKey}a`)).not.toEqual(await encodeBlobKey(`${longKey}b`))
2416
})
2517

2618
it('truncated keys keep having a readable start', async () => {
2719
const key = await encodeBlobKey(`/products/${longKey}`)
28-
expect(Buffer.from(key, 'base64url').toString().startsWith('products/')).toBe(true)
20+
expect(Buffer.from(key, 'base64url').toString().startsWith('/products/')).toBe(true)
2921
})
3022
})

src/shared/blobkey.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const maxLength = 180
1010
* Longer keys are truncated and appended with a hash to ensure uniqueness.
1111
*/
1212
export async function encodeBlobKey(key: string): Promise<string> {
13-
const buffer = Buffer.from(key.replace(/^\//, '') || 'index')
13+
const buffer = Buffer.from(key)
1414
const base64 = buffer.toString('base64url')
1515
if (base64.length <= maxLength) {
1616
return base64

tests/integration/cache-handler.test.ts

+21-21
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ describe('page router', () => {
4141
// check if the blob entries where successful set on the build plugin
4242
const blobEntries = await getBlobEntries(ctx)
4343
expect(blobEntries.map(({ key }) => decodeBlobKey(key.substring(0, 50))).sort()).toEqual([
44+
// the real key is much longer and ends in a hash, but we only assert on the first 50 chars to make it easier
45+
'/products/an-incredibly-long-product-',
46+
'/static/revalidate-automatic',
47+
'/static/revalidate-manual',
48+
'/static/revalidate-slow',
4449
'404.html',
4550
'500.html',
46-
// the real key is much longer and ends in a hash, but we only assert on the first 50 chars to make it easier
47-
'products/an-incredibly-long-product-n',
48-
'static/revalidate-automatic',
49-
'static/revalidate-manual',
50-
'static/revalidate-slow',
5151
])
5252

5353
// test the function call
@@ -116,14 +116,14 @@ describe('app router', () => {
116116
// check if the blob entries where successful set on the build plugin
117117
const blobEntries = await getBlobEntries(ctx)
118118
expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual([
119-
'404',
119+
'/404',
120+
'/index',
121+
'/posts/1',
122+
'/posts/2',
120123
'404.html',
121124
'460ed46cd9a194efa197be9f2571e51b729a039d1cff9834297f416dce5ada29',
122125
'500.html',
123126
'ad74683e49684ff4fe3d01ba1bef627bc0e38b61fa6bd8244145fbaca87f3c49',
124-
'index',
125-
'posts/1',
126-
'posts/2',
127127
])
128128

129129
// test the function call
@@ -138,7 +138,7 @@ describe('app router', () => {
138138
}),
139139
)
140140

141-
expect(await ctx.blobStore.get(decodeBlobKey('posts/3'))).toBeNull()
141+
expect(await ctx.blobStore.get(encodeBlobKey('/posts/3'))).toBeNull()
142142
// this page is not pre-rendered and should result in a cache miss
143143
const post3 = await invokeFunction(ctx, { url: 'posts/3' })
144144
expect(post3.statusCode).toBe(200)
@@ -152,7 +152,7 @@ describe('app router', () => {
152152
// wait to have a stale page
153153
await new Promise<void>((resolve) => setTimeout(resolve, 5_000))
154154
// after the dynamic call of `posts/3` it should be in cache, not this is after the timout as the cache set happens async
155-
expect(await ctx.blobStore.get(encodeBlobKey('posts/3'))).not.toBeNull()
155+
expect(await ctx.blobStore.get(encodeBlobKey('/posts/3'))).not.toBeNull()
156156

157157
const stale = await invokeFunction(ctx, { url: 'posts/1' })
158158
const staleDate = load(stale.body)('[data-testid="date-now"]').text()
@@ -188,20 +188,20 @@ describe('plugin', () => {
188188
// check if the blob entries where successful set on the build plugin
189189
const blobEntries = await getBlobEntries(ctx)
190190
expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual([
191-
'404',
191+
'/404',
192+
'/api/revalidate-handler',
193+
'/index',
194+
'/revalidate-fetch',
195+
'/static-fetch-1',
196+
'/static-fetch-2',
197+
'/static-fetch-3',
198+
'/static-fetch/1',
199+
'/static-fetch/2',
192200
'404.html',
193201
'460ed46cd9a194efa197be9f2571e51b729a039d1cff9834297f416dce5ada29',
194202
'500.html',
195203
'ac26c54e17c3018c17bfe5ae6adc0e6d37dbfaf28445c1f767ff267144264ac9',
196204
'ad74683e49684ff4fe3d01ba1bef627bc0e38b61fa6bd8244145fbaca87f3c49',
197-
'api/revalidate-handler',
198-
'index',
199-
'revalidate-fetch',
200-
'static-fetch-1',
201-
'static-fetch-2',
202-
'static-fetch-3',
203-
'static-fetch/1',
204-
'static-fetch/2',
205205
])
206206
})
207207
})
@@ -212,7 +212,7 @@ describe('route', () => {
212212
await runPlugin(ctx)
213213

214214
// check if the route got prerendered
215-
const blobEntry = await ctx.blobStore.get(encodeBlobKey('api/revalidate-handler'), {
215+
const blobEntry = await ctx.blobStore.get(encodeBlobKey('/api/revalidate-handler'), {
216216
type: 'json',
217217
})
218218
expect(blobEntry).not.toBeNull()

tests/integration/fetch-handler.test.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ test<FixtureTestContext>('if the fetch call is cached correctly', async (ctx) =>
7676

7777
await Promise.all([
7878
// delete the page cache so that it falls back to the fetch call
79-
ctx.blobStore.delete(encodeBlobKey('posts/1')),
79+
ctx.blobStore.delete(encodeBlobKey('/posts/1')),
8080
// delete the original key as we use the fake key only
8181
ctx.blobStore.delete(encodeBlobKey(originalKey)),
8282
ctx.blobStore.setJSON(encodeBlobKey(fakeKey), fetchEntry),
@@ -85,13 +85,13 @@ test<FixtureTestContext>('if the fetch call is cached correctly', async (ctx) =>
8585
const blobEntries = await getBlobEntries(ctx)
8686
expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual(
8787
[
88+
'/404',
89+
'/index',
90+
'/posts/2',
8891
fakeKey,
89-
'ad74683e49684ff4fe3d01ba1bef627bc0e38b61fa6bd8244145fbaca87f3c49',
90-
'404',
91-
'index',
92-
'posts/2',
9392
'404.html',
9493
'500.html',
94+
'ad74683e49684ff4fe3d01ba1bef627bc0e38b61fa6bd8244145fbaca87f3c49',
9595
].sort(),
9696
)
9797
const post1 = await invokeFunction(ctx, {

tests/integration/page-router.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ test<FixtureTestContext>('Should revalidate path with On-demand Revalidation', a
7171

7272
expect(staticPageInitial.statusCode).toBe(200)
7373
expect(staticPageInitial.headers?.['cache-status']).toMatch(/"Next.js"; hit/)
74-
const blobDataInitial = await ctx.blobStore.get(encodeBlobKey('static/revalidate-manual'), {
74+
const blobDataInitial = await ctx.blobStore.get(encodeBlobKey('/static/revalidate-manual'), {
7575
type: 'json',
7676
})
7777
const blobDateInitial = load(blobDataInitial.value.html).html('[data-testid="date-now"]')
@@ -81,7 +81,7 @@ test<FixtureTestContext>('Should revalidate path with On-demand Revalidation', a
8181

8282
await new Promise<void>((resolve) => setTimeout(resolve, 100))
8383

84-
const blobDataRevalidated = await ctx.blobStore.get(encodeBlobKey('static/revalidate-manual'), {
84+
const blobDataRevalidated = await ctx.blobStore.get(encodeBlobKey('/static/revalidate-manual'), {
8585
type: 'json',
8686
})
8787

tests/integration/revalidate-path.test.ts

+2-8
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,7 @@ import {
88
runPlugin,
99
type FixtureTestContext,
1010
} from '../utils/fixture.js'
11-
import {
12-
encodeBlobKey,
13-
generateRandomObjectID,
14-
getBlobEntries,
15-
startMockBlobStore,
16-
} from '../utils/helpers.js'
11+
import { encodeBlobKey, generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js'
1712

1813
// Disable the verbose logging of the lambda-local runtime
1914
getLogger().level = 'alert'
@@ -35,7 +30,7 @@ test<FixtureTestContext>('should revalidate a route by path', async (ctx) => {
3530
await createFixture('server-components', ctx)
3631
await runPlugin(ctx)
3732

38-
expect(await ctx.blobStore.get(encodeBlobKey('static-fetch/1'))).not.toBeNull()
33+
expect(await ctx.blobStore.get(encodeBlobKey('/static-fetch/1'))).not.toBeNull()
3934
expect(await ctx.blobStore.get(encodeBlobKey('_N_T_/static-fetch/[id]/page'))).toBeNull()
4035

4136
// test the function call
@@ -68,7 +63,6 @@ test<FixtureTestContext>('should revalidate a route by path', async (ctx) => {
6863
// it does not wait for the cache.set so we have to manually wait here until the blob storage got populated
6964
await new Promise<void>((resolve) => setTimeout(resolve, 1000))
7065

71-
const entries = await getBlobEntries(ctx)
7266
expect(await ctx.blobStore.get(encodeBlobKey('_N_T_/static-fetch/[id]/page'))).not.toBeNull()
7367

7468
const [post2, post2Route2] = await Promise.all([

tests/integration/revalidate-tags.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ test<FixtureTestContext>('should revalidate a route by tag', async (ctx) => {
3030
await createFixture('server-components', ctx)
3131
await runPlugin(ctx)
3232

33-
expect(await ctx.blobStore.get(encodeBlobKey('static-fetch-1'))).not.toBeNull()
33+
expect(await ctx.blobStore.get(encodeBlobKey('/static-fetch-1'))).not.toBeNull()
3434

3535
// test the function call
3636
const post1 = await invokeFunction(ctx, { url: '/static-fetch-1' })

tests/integration/simple-app.test.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,14 @@ test<FixtureTestContext>('Test that the simple next app is working', async (ctx)
3636
// check if the blob entries where successful set on the build plugin
3737
const blobEntries = await getBlobEntries(ctx)
3838
expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual([
39-
'404',
39+
'/404',
40+
'/image',
41+
'/index',
42+
'/other',
43+
'/redirect',
44+
'/redirect/response',
4045
'404.html',
4146
'500.html',
42-
'image',
43-
'index',
44-
'other',
45-
'redirect',
46-
'redirect/response',
4747
])
4848

4949
// test the function call

tests/integration/static.test.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { existsSync } from 'node:fs'
2-
import { join } from 'node:path'
31
import { load } from 'cheerio'
4-
import { getLogger } from 'lambda-local'
52
import glob from 'fast-glob'
3+
import { getLogger } from 'lambda-local'
4+
import { existsSync } from 'node:fs'
5+
import { join } from 'node:path'
66
import { v4 } from 'uuid'
77
import { beforeEach, expect, test, vi } from 'vitest'
88
import {
@@ -39,13 +39,13 @@ test<FixtureTestContext>('requesting a non existing page route that needs to be
3939

4040
const entries = await getBlobEntries(ctx)
4141
expect(entries.map(({ key }) => decodeBlobKey(key.substring(0, 50))).sort()).toEqual([
42+
'/products/an-incredibly-long-product-',
43+
'/static/revalidate-automatic',
44+
'/static/revalidate-manual',
45+
'/static/revalidate-slow',
4246
'404.html',
4347
'500.html',
4448
// the real key is much longer and ends in a hash, but we only assert on the first 50 chars to make it easier
45-
'products/an-incredibly-long-product-n',
46-
'static/revalidate-automatic',
47-
'static/revalidate-manual',
48-
'static/revalidate-slow',
4949
])
5050

5151
// test that it should request the 404.html file

0 commit comments

Comments
 (0)