Skip to content

Commit 47453fe

Browse files
committed
split the user-exposed after() from the internal after-context
1 parent 69b58a5 commit 47453fe

File tree

6 files changed

+202
-200
lines changed

6 files changed

+202
-200
lines changed

packages/next/src/client/components/request-async-storage.external.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { ReadonlyRequestCookies } from '../../server/web/spec-extension/ada
99
;('TURBOPACK { transition: next-shared }')
1010
import { requestAsyncStorage } from './request-async-storage-instance'
1111
import type { DeepReadonly } from '../../shared/lib/deep-readonly'
12-
import type { AfterContext } from '../../server/after'
12+
import type { AfterContext } from '../../server/after/after-context'
1313

1414
export interface RequestStore {
1515
readonly headers: ReadonlyHeaders

packages/next/src/server/after/after.test.ts renamed to packages/next/src/server/after/after-context.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { DetachedPromise } from '../../lib/detached-promise'
22
import { AsyncLocalStorage } from 'async_hooks'
33

44
import type { RequestStore } from '../../client/components/request-async-storage.external'
5-
import type { AfterContext } from './after'
5+
import type { AfterContext } from './after-context'
66

77
const createMockRequestStore = (afterContext: AfterContext): RequestStore => {
88
return {
@@ -23,9 +23,10 @@ describe('createAfterContext', () => {
2323
type RASMod =
2424
typeof import('../../client/components/request-async-storage.external')
2525
type AfterMod = typeof import('./after')
26+
type AfterContextMod = typeof import('./after-context')
2627

2728
let requestAsyncStorage: RASMod['requestAsyncStorage']
28-
let createAfterContext: AfterMod['createAfterContext']
29+
let createAfterContext: AfterContextMod['createAfterContext']
2930
let after: AfterMod['unstable_after']
3031

3132
beforeAll(async () => {
@@ -37,8 +38,10 @@ describe('createAfterContext', () => {
3738
)
3839
requestAsyncStorage = RASMod.requestAsyncStorage
3940

41+
const AfterContextMod = await import('./after-context')
42+
createAfterContext = AfterContextMod.createAfterContext
43+
4044
const AfterMod = await import('./after')
41-
createAfterContext = AfterMod.createAfterContext
4245
after = AfterMod.unstable_after
4346
})
4447

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { DetachedPromise } from '../../lib/detached-promise'
2+
import {
3+
requestAsyncStorage,
4+
type RequestStore,
5+
} from '../../client/components/request-async-storage.external'
6+
import { BaseServerSpan } from '../lib/trace/constants'
7+
import { getTracer } from '../lib/trace/tracer'
8+
import type { CacheScope } from './react-cache-scope'
9+
import { ResponseCookies } from '../web/spec-extension/cookies'
10+
import type { RequestLifecycleOpts } from '../base-server'
11+
import type { AfterCallback, AfterTask, WaitUntilFn } from './shared'
12+
13+
export type AfterContext =
14+
| ReturnType<typeof createAfterContext>
15+
| ReturnType<typeof createDisabledAfterContext>
16+
17+
export function createAfterContext({
18+
waitUntil,
19+
onClose,
20+
cacheScope,
21+
}: {
22+
waitUntil: WaitUntilFn
23+
onClose: RequestLifecycleOpts['onClose']
24+
cacheScope?: CacheScope
25+
}) {
26+
const keepAliveLock = createKeepAliveLock(waitUntil)
27+
28+
const afterCallbacks: AfterCallback[] = []
29+
const addCallback = (callback: AfterCallback) => {
30+
if (afterCallbacks.length === 0) {
31+
keepAliveLock.acquire()
32+
firstCallbackAdded.resolve()
33+
}
34+
afterCallbacks.push(callback)
35+
}
36+
37+
// `onClose` has some overhead in WebNextResponse, so we don't want to call it unless necessary.
38+
// we also have to avoid calling it if we're in static generation (because it doesn't exist there).
39+
// (the ordering is a bit convoluted -- in static generation, calling after() will cause a bailout and fail anyway,
40+
// but we can't know that at the point where we call `onClose`.)
41+
// this trick means that we'll only ever try to call `onClose` if an `after()` call successfully went through.
42+
const firstCallbackAdded = new DetachedPromise<void>()
43+
const onCloseLazy = (callback: () => void): Promise<void> => {
44+
return firstCallbackAdded.promise.then(() => onClose(callback))
45+
}
46+
47+
const afterImpl = (task: AfterTask) => {
48+
if (isPromise(task)) {
49+
task.catch(() => {}) // avoid unhandled rejection crashes
50+
waitUntil(task)
51+
} else if (typeof task === 'function') {
52+
// TODO(after): will this trace correctly?
53+
addCallback(() => getTracer().trace(BaseServerSpan.after, () => task()))
54+
} else {
55+
throw new Error('after() must receive a promise or a function')
56+
}
57+
}
58+
59+
const runCallbacks = (requestStore: RequestStore) => {
60+
if (afterCallbacks.length === 0) return
61+
62+
const runCallbacksImpl = () => {
63+
while (afterCallbacks.length) {
64+
const afterCallback = afterCallbacks.shift()!
65+
66+
const onError = (err: unknown) => {
67+
// TODO(after): how do we properly report errors here?
68+
console.error(
69+
'An error occurred in a function passed to after()',
70+
err
71+
)
72+
}
73+
74+
// try-catch in case the callback throws synchronously or does not return a promise.
75+
try {
76+
const ret = afterCallback()
77+
if (isPromise(ret)) {
78+
waitUntil(ret.catch(onError))
79+
}
80+
} catch (err) {
81+
onError(err)
82+
}
83+
}
84+
85+
keepAliveLock.release()
86+
}
87+
88+
const readonlyRequestStore: RequestStore =
89+
wrapRequestStoreForAfterCallbacks(requestStore)
90+
91+
return requestAsyncStorage.run(readonlyRequestStore, () =>
92+
cacheScope ? cacheScope.run(runCallbacksImpl) : runCallbacksImpl()
93+
)
94+
}
95+
96+
return {
97+
enabled: true as const,
98+
after: afterImpl,
99+
run: async <T>(requestStore: RequestStore, callback: () => T) => {
100+
try {
101+
return await (cacheScope
102+
? cacheScope.run(() => callback())
103+
: callback())
104+
} finally {
105+
// NOTE: it's likely that the callback is doing streaming rendering,
106+
// which means that nothing actually happened yet,
107+
// and we have to wait until the request closes to do anything.
108+
// (this also means that this outer try-finally may not catch much).
109+
110+
// don't await -- it may never resolve if no callbacks are passed.
111+
void onCloseLazy(() => runCallbacks(requestStore)).catch(
112+
(err: unknown) => {
113+
console.error(err)
114+
// as a last resort -- if something fails here, something's probably broken really badly,
115+
// so make sure we release the lock -- at least we'll avoid hanging a `waitUntil` forever.
116+
keepAliveLock.release()
117+
}
118+
)
119+
}
120+
},
121+
}
122+
}
123+
124+
export function createDisabledAfterContext() {
125+
return { enabled: false as const }
126+
}
127+
128+
/** Disable mutations of `requestStore` within `after()` and disallow nested after calls. */
129+
function wrapRequestStoreForAfterCallbacks(
130+
requestStore: RequestStore
131+
): RequestStore {
132+
return {
133+
get headers() {
134+
return requestStore.headers
135+
},
136+
get cookies() {
137+
return requestStore.cookies
138+
},
139+
get draftMode() {
140+
return requestStore.draftMode
141+
},
142+
assetPrefix: requestStore.assetPrefix,
143+
reactLoadableManifest: requestStore.reactLoadableManifest,
144+
// make cookie writes go nowhere
145+
mutableCookies: new ResponseCookies(new Headers()),
146+
afterContext: {
147+
enabled: true,
148+
after: () => {
149+
throw new Error('Cannot call after() from within after()')
150+
},
151+
run: () => {
152+
throw new Error('Cannot call run() from within an after() callback')
153+
},
154+
},
155+
}
156+
}
157+
158+
function createKeepAliveLock(waitUntil: WaitUntilFn) {
159+
// callbacks can't go directly into waitUntil,
160+
// and we don't want a function invocation to get stopped *before* we execute the callbacks,
161+
// so block with a dummy promise that we'll resolve when we're done.
162+
let keepAlivePromise: DetachedPromise<void> | undefined
163+
return {
164+
isLocked() {
165+
return !!keepAlivePromise
166+
},
167+
acquire() {
168+
if (!keepAlivePromise) {
169+
keepAlivePromise = new DetachedPromise<void>()
170+
waitUntil(keepAlivePromise.promise)
171+
}
172+
},
173+
release() {
174+
if (keepAlivePromise) {
175+
keepAlivePromise.resolve(undefined)
176+
keepAlivePromise = undefined
177+
}
178+
},
179+
}
180+
}
181+
182+
function isPromise(p: unknown): p is Promise<unknown> {
183+
return (
184+
p !== null &&
185+
typeof p === 'object' &&
186+
'then' in p &&
187+
typeof p.then === 'function'
188+
)
189+
}

0 commit comments

Comments
 (0)