Skip to content

createEvent is never awaited, so an async one answers 200 while discarding every cookie and header the integration set #3170

Description

@frenzzy

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:

  1. 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.
  2. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions