Summary
createEvent's return value is never awaited. An async createEvent therefore yields a Promise where a RequestEvent is expected: the server function still runs and the caller still gets HTTP 200, while every header and cookie the integration wrote on the event's response stub is silently discarded.
Tested against next @ a536e29b, built from source, Node 24.19.
const event = options.createEvent ? options.createEvent(request) : { request, locals: {} };
Reproduction
The natural enterprise shape — resolve a session, open a handle, then seed a cookie:
handleServerFunctionRequest(request, {
createEvent: async q => {
const event = createRequestEvent(q);
event.response.headers.append("Set-Cookie", "sid=x");
return event;
}
});
status 200
body "FN-OK"
server function ran 1
Set-Cookie on wire [] ← the cookie the integration wrote is gone
Nothing throws, nothing is logged, and the client sees an ordinary success.
Why this is worth a guard rather than a docs line
The docblock types createEvent as synchronous, so an async one is out of contract — I am not claiming the runtime promised to await it. Two things make it worth handling anyway:
- The failure direction is the worst available. The request succeeds. The function's side effects commit. Only the integration's response state vanishes. Whatever
createEvent was doing — session rotation, a CSRF token, a Set-Cookie for a refreshed credential — is lost on the requests that appear to have worked. The next request then carries stale state, and the symptom surfaces somewhere unrelated.
- The mistake is the likely one on this surface.
createEvent is where an integration resolves the session, which is usually asynchronous. Making it async is the first thing an author reaches for, and TypeScript only helps until the config is authored in JavaScript, generated, or widened through a helper.
Related, and sharper if the hook rejects rather than resolves: a rejected promise takes the same path — 200 to the client, side effects committed — and the rejection is never handled. Under Node's default --unhandled-rejections=throw that terminates the process while the client is told everything is fine.
Compare the sibling hook: provideEvent establishes the same scope, is documented as async-capable, and is awaited. The asymmetry between the two is not obvious from either signature.
Options
- Await it.
const event = await (options.createEvent ? options.createEvent(request) : …). Makes the hook async-capable, which is what integrations want anyway, and removes the class entirely. Cost: one microtask on every request, and the docblock's synchronous contract becomes a widening rather than a constraint.
- Refuse a thenable — if the returned value has a
then, answer 500 (or throw in dev) naming the hook. Keeps the contract as written and makes the violation loud instead of silent. Smallest behavioural change for correct integrations.
- Validate the shape — require
request and response on the returned object and refuse otherwise. Catches the promise case and also undefined, a plain object, and a partially-built event. Broadest guard; the most opinionated about what an event must look like.
- Document that it must be synchronous and say what happens if it is not. Cheapest, but the consequence is invisible at the point of the mistake, which is exactly why it is worth more than a docs line.
I'd suggest (1) if async support is acceptable — it is what the hook is used for — otherwise (2), which preserves the contract and costs one typeof value.then === "function" check.
Happy to send a PR either way. The regression test shape: an async createEvent seeding a Set-Cookie, asserting the cookie reaches the wire (option 1) or that the request is refused with the hook named (option 2), with a synchronous createEvent as the control that already passes.
Related
Same shape at three seams — an integration hook's return is accepted unvalidated where the mistake is invisible: #3172 and #3174. Different packages and different fixes, so filed separately, but the enforcement question is one question and is cheaper to settle once.
Summary
createEvent's return value is never awaited. Anasync createEventtherefore yields aPromisewhere aRequestEventis expected: the server function still runs and the caller still gets HTTP 200, while every header and cookie the integration wrote on the event's response stub is silently discarded.Tested against
next@a536e29b, built from source, Node 24.19.Reproduction
The natural enterprise shape — resolve a session, open a handle, then seed a cookie:
Nothing throws, nothing is logged, and the client sees an ordinary success.
Why this is worth a guard rather than a docs line
The docblock types
createEventas synchronous, so anasyncone is out of contract — I am not claiming the runtime promised to await it. Two things make it worth handling anyway:createEventwas doing — session rotation, a CSRF token, aSet-Cookiefor a refreshed credential — is lost on the requests that appear to have worked. The next request then carries stale state, and the symptom surfaces somewhere unrelated.createEventis where an integration resolves the session, which is usually asynchronous. Making itasyncis the first thing an author reaches for, and TypeScript only helps until the config is authored in JavaScript, generated, or widened through a helper.Related, and sharper if the hook rejects rather than resolves: a rejected promise takes the same path — 200 to the client, side effects committed — and the rejection is never handled. Under Node's default
--unhandled-rejections=throwthat terminates the process while the client is told everything is fine.Compare the sibling hook:
provideEventestablishes the same scope, is documented as async-capable, and is awaited. The asymmetry between the two is not obvious from either signature.Options
const event = await (options.createEvent ? options.createEvent(request) : …). Makes the hook async-capable, which is what integrations want anyway, and removes the class entirely. Cost: one microtask on every request, and the docblock's synchronous contract becomes a widening rather than a constraint.then, answer 500 (or throw in dev) naming the hook. Keeps the contract as written and makes the violation loud instead of silent. Smallest behavioural change for correct integrations.requestandresponseon the returned object and refuse otherwise. Catches the promise case and alsoundefined, a plain object, and a partially-built event. Broadest guard; the most opinionated about what an event must look like.I'd suggest (1) if async support is acceptable — it is what the hook is used for — otherwise (2), which preserves the contract and costs one
typeof value.then === "function"check.Happy to send a PR either way. The regression test shape: an
async createEventseeding aSet-Cookie, asserting the cookie reaches the wire (option 1) or that the request is refused with the hook named (option 2), with a synchronouscreateEventas the control that already passes.Related
Same shape at three seams — an integration hook's return is accepted unvalidated where the mistake is invisible: #3172 and #3174. Different packages and different fixes, so filed separately, but the enforcement question is one question and is cheaper to settle once.