@uniflowed/server 0.0.0-alpha.7 → 0.0.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/edge.js ADDED
@@ -0,0 +1,158 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/server/edge`: the Cloudflare Workers half of serving a build.
4
+ //
5
+ // [`./fetch.js`] answers the application's half of a request and touches no
6
+ // filesystem, because that is the half a worker runs. This is everything that
7
+ // is left on a worker, and there is much less of it than there is on Node: a
8
+ // worker has no socket to take and no directory to read, so what remains is
9
+ // the request lifecycle and the one decision `./node.js` makes with `stat` —
10
+ // does a file the build already wrote answer this, or does the application.
11
+ //
12
+ // # Where the static half went
13
+ //
14
+ // To the platform. Workers static assets are uploaded beside the script and
15
+ // served by Cloudflare's own asset server; the Worker reaches them through the
16
+ // `ASSETS` binding declared in `wrangler.json`, which answers a `Request` with
17
+ // a `Response` exactly as `createStaticHandler` does — and answers `404` where
18
+ // that returns `null`, which is the only translation this module performs.
19
+ //
20
+ // The Worker asks *first*, and `uf build --adapter edge` writes
21
+ // `"run_worker_first": true` so that it can. That looks like the wrong way
22
+ // round — Cloudflare's default is to serve a matching asset without invoking
23
+ // the script at all, which is faster and resolves a file/handler collision the
24
+ // same way — but the default also puts the resolution order in a platform
25
+ // setting rather than in uf, and `tests/library/deploy.test.js` cannot drive a
26
+ // platform setting. Asking here means one order, written once, checked by the
27
+ // test that checks `uf start`'s. See ubugeeei-prod/uf#391.
28
+ //
29
+ // # Who owns the request, and the one thing a worker cannot promise
30
+ //
31
+ // `after()` says "once the response has been sent", and there is no such line
32
+ // in a worker: [`createWorkerFetch`] has a `Response` in hand, and the platform
33
+ // writes its body after the handler has returned. `ctx.waitUntil` is the
34
+ // documented way to keep the isolate alive for work that outlives the handler,
35
+ // so that is what `settle` is handed — which means an `after()` callback on
36
+ // this target begins when the response has been *decided* rather than when its
37
+ // last byte is out. For a streamed document those are a document apart. It is
38
+ // written down here, in `docs/app/reference/cli/_uf.page.mdx`, and it is the
39
+ // one behavioural difference between this target and the other three.
40
+ //
41
+ // `beginRequest` is passed in rather than imported, for the reason `./node.js`
42
+ // gives at length: the request lives in an `AsyncLocalStorage` belonging to a
43
+ // module instance, and the instance the application reads is the one bundled
44
+ // into the `handler.js` beside the generated `worker.js`. See
45
+ // ubugeeei-prod/uf#389.
46
+
47
+ import type { RequestLifecycle } from "./internal/context.js";
48
+
49
+ export type { RequestLifecycle } from "./internal/context.js";
50
+
51
+ /**
52
+ * The `ASSETS` binding, as much of it as this module uses.
53
+ *
54
+ * One method, declared structurally: a worker's bindings are supplied by the
55
+ * runtime, and naming the whole of Cloudflare's `Fetcher` here would be this
56
+ * package holding a copy of another project's types.
57
+ */
58
+ export type AssetsBinding = {
59
+ readonly fetch: (request: Request) => Promise<Response>,
60
+ ...
61
+ };
62
+
63
+ /**
64
+ * The `env` a Worker's `fetch` is called with.
65
+ *
66
+ * Inexact, because a project's own bindings — a KV namespace, a secret — are
67
+ * in here too and are none of this module's business. `ASSETS` is optional
68
+ * because a Worker deployed without an assets directory has no such binding,
69
+ * and the honest answer for that deployment is "the application answers
70
+ * everything" rather than a `TypeError` on the first request.
71
+ */
72
+ export type EdgeEnvironment = {
73
+ readonly ASSETS?: AssetsBinding,
74
+ ...
75
+ };
76
+
77
+ /**
78
+ * The `ctx` a Worker's `fetch` is called with.
79
+ *
80
+ * `waitUntil` only. `passThroughOnException` exists and is deliberately not
81
+ * used: it serves the origin's response when the script throws, and a Worker
82
+ * that *is* the origin has nothing to pass through to.
83
+ */
84
+ export type ExecutionContext = {
85
+ readonly waitUntil: (promise: Promise<mixed>) => mixed,
86
+ ...
87
+ };
88
+
89
+ /** Everything the worker half needs to answer a request. */
90
+ export type WorkerHandlerOptions = {|
91
+ /** The application, from the generated `handler.js`. */
92
+ readonly handle: (request: Request) => Promise<Response>,
93
+ /** That same module's `beginRequest`; see the header. */
94
+ readonly beginRequest: (request: Request) => RequestLifecycle,
95
+ |};
96
+
97
+ /**
98
+ * A built uf application as a Worker's `fetch`.
99
+ *
100
+ * Static assets first, then the application — the order `uf preview` cannot
101
+ * deviate from and therefore the order every other front door matches. The
102
+ * asset lookup is skipped for anything that is not a `GET` or a `HEAD`, which
103
+ * is what `createStaticHandler` does and for the same reason: a `POST` to a
104
+ * path that happens to have a file under it belongs to a route handler.
105
+ *
106
+ * A `404` from the assets binding means "no such asset", not "the site has no
107
+ * such page": `wrangler.json` sets `"not_found_handling": "none"` so that the
108
+ * miss falls through to here, and the 404 a visitor sees is the project's own
109
+ * `_uf.not-found` rendered by the application. Any other status is the asset's
110
+ * answer and is returned as it stands.
111
+ */
112
+ export function createWorkerFetch(
113
+ options: WorkerHandlerOptions,
114
+ ): (request: Request, env: EdgeEnvironment, ctx?: ExecutionContext) => Promise<Response> {
115
+ const { handle, beginRequest } = options;
116
+
117
+ return async function fetchFromWorker(
118
+ request: Request,
119
+ env: EdgeEnvironment,
120
+ ctx?: ExecutionContext,
121
+ ): Promise<Response> {
122
+ const lifecycle = beginRequest(request);
123
+ try {
124
+ return await lifecycle.run(async () => {
125
+ const assets = env?.ASSETS;
126
+ const method = request.method.toUpperCase();
127
+ if (assets != null && (method === "GET" || method === "HEAD")) {
128
+ const asset = await assets.fetch(request);
129
+ if (asset.status !== 404) return asset;
130
+ }
131
+ return await handle(request);
132
+ });
133
+ } catch (error) {
134
+ // The same 500 `./node.js`'s `nodeListener` writes, and for the same
135
+ // reasons: the body must not carry the stack, because the body goes to
136
+ // whoever asked, and the console is where the operator is already
137
+ // looking. Without this the answer would be Cloudflare's own error page,
138
+ // which is a different answer from `uf start`'s for the same failure —
139
+ // and the whole claim of the seam is that there is one answer.
140
+ console.error(error);
141
+ return new Response("500 Internal Server Error\n", {
142
+ status: 500,
143
+ headers: { "content-type": "text/plain; charset=utf-8" },
144
+ });
145
+ } finally {
146
+ // Scheduled rather than awaited: awaiting it here would hold the
147
+ // response back until every `after()` callback had finished, which is
148
+ // the opposite of what `after()` is for. Where there is no `ctx` — a
149
+ // test, or a host that calls this directly — it is awaited, because
150
+ // dropping the promise would lose both the work and its rejection.
151
+ if (ctx != null) {
152
+ ctx.waitUntil(lifecycle.settle());
153
+ } else {
154
+ await lifecycle.settle();
155
+ }
156
+ }
157
+ };
158
+ }
package/fetch.js ADDED
@@ -0,0 +1,135 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/server/fetch`: a built uf application as `Request` → `Response`.
4
+ //
5
+ // This is the seam every deploy adapter is written against, and it is one
6
+ // function: give it the module `uf build` wrote and the asset URLs from the
7
+ // client manifest, and it answers a request. It touches no filesystem, holds
8
+ // no Node types, and imports nothing from `@uniflowed/vite` — so the same
9
+ // handler runs inside `uf start`'s `node:http` loop, inside the `server.js`
10
+ // that `uf build --adapter node` writes, and inside a worker's `fetch` export.
11
+ //
12
+ // # Why it lives here and not where it was written
13
+ //
14
+ // It was `createApplicationHandler` in `@uniflowed/vite/internal/serve.js`,
15
+ // and `tests/library/serve.test.js` said what was wrong with that: "not a
16
+ // package export, deliberately — `internal/serve.js` is the seam a deploy
17
+ // adapter will need, and naming it in `exports` before one exists would be
18
+ // promising an interface nothing has used yet". An adapter exists now, and it
19
+ // may not import the package named after the bundler: the whole claim of
20
+ // deployable output is that the host needs neither Vite nor the toolchain that
21
+ // produced the build. So the seam moved to the package a deployment already
22
+ // links — `@uniflowed/server` — and `internal/serve.js` calls into it, which
23
+ // keeps `uf preview`, `uf start` and every adapter answering out of one
24
+ // function rather than out of copies that agree until they do not.
25
+ //
26
+ // `./standalone.js` is the one front door that does not come through here, and
27
+ // its own header says why: a compiled binary answers from bytes it carries
28
+ // rather than from a directory, and it writes into a Node response directly so
29
+ // that a document is not converted through a `Response` on its way out.
30
+ //
31
+ // # What it deliberately does not do
32
+ //
33
+ // Static files. A build's assets and its prerendered documents are the *host's*
34
+ // half — on a CDN-backed target they are not the application's job at all, and
35
+ // on a Node host they are a directory read, which is why `./node.js` has that
36
+ // half and this module has none of it. That split is the whole reason an
37
+ // adapter can be written for a worker: what is left after the files is exactly
38
+ // this function.
39
+
40
+ import type { Application, DocumentAssets } from "./internal/application.js";
41
+
42
+ export type { Application, DocumentAssets, RenderedDocument } from "./internal/application.js";
43
+
44
+ /** Everything the application half needs to answer a request. */
45
+ export type FetchHandlerOptions = {|
46
+ /** The server bundle, as imported. */
47
+ readonly app: Application,
48
+ /** The script, stylesheet and preload URLs a rendered document references. */
49
+ readonly document: DocumentAssets,
50
+ |};
51
+
52
+ /**
53
+ * The application half: middleware, then route handlers, then rendering.
54
+ *
55
+ * Returns `null` for nothing, ever — a request that matches no handler and no
56
+ * route is a rendered 404, because the renderer is what knows what the
57
+ * project's `_uf.not-found` page says.
58
+ *
59
+ * The order is the dev server's, and has to stay the dev server's: middleware
60
+ * first, then handlers for every method, because a handler is the only thing
61
+ * that can answer a `POST` and it may also answer a `GET` for a path that has
62
+ * no page. A page cannot answer a `POST`, so a non-navigation that no handler
63
+ * claimed is a 404 rather than a rendered page with a 200.
64
+ *
65
+ * Middleware above both, and not inside either: it guards a path, so it has to
66
+ * run for a page, for a route handler, and for a path under it that matches
67
+ * neither — `/dashboard/typo` is a 404 that the guard on `/dashboard` still
68
+ * answers. `app.runMiddleware` is called rather than tested for, so a server
69
+ * bundle without it is a `TypeError` on the first request instead of an
70
+ * application whose auth check quietly stopped running once it was built.
71
+ * That is the whole of ubugeeei-prod/uf#260, and every host that reaches this
72
+ * function is one more place it could have happened.
73
+ *
74
+ * # It must be called inside a request, and does not begin one
75
+ *
76
+ * `after()` says "once the response has been sent", and this function has a
77
+ * `Response` in hand rather than a response on the wire — for a streamed body
78
+ * those are a document apart. So the host begins the request with
79
+ * `app.beginRequest`, runs this inside `run`, and settles it after the bytes:
80
+ * `./node.js`'s `nodeListener` does that for `uf start` and for the `server.js`
81
+ * an adapter writes, and `@uniflowed/vite`'s `withRequest` does it for `uf dev`
82
+ * and `uf preview`.
83
+ *
84
+ * A worker-shaped host is the one uf does not write, and it has the same two
85
+ * halves to place. `settle` is what to hand `ctx.waitUntil` where there is one;
86
+ * without one, the honest moment is when the response body stream closes — and
87
+ * a runtime that tears the isolate down at that moment drops the callback,
88
+ * which is worth saying out loud rather than leaving to be discovered.
89
+ *
90
+ * A caller that forgets is not left to discover *that*, at least:
91
+ * `app.runMiddleware` refuses outside a request and names what establishes one.
92
+ * See ubugeeei-prod/uf#389.
93
+ */
94
+ export function createFetchHandler(
95
+ options: FetchHandlerOptions,
96
+ ): (request: Request) => Promise<Response> {
97
+ const { app, document } = options;
98
+
99
+ return async function handle(request: Request): Promise<Response> {
100
+ const guarded = await app.runMiddleware(request);
101
+ if (guarded != null) return guarded;
102
+
103
+ const handled = await app.dispatch(request);
104
+ if (handled != null) return handled;
105
+
106
+ const method = request.method.toUpperCase();
107
+ if (method !== "GET" && method !== "HEAD") {
108
+ return new Response(null, { status: 404 });
109
+ }
110
+
111
+ const url = new URL(request.url);
112
+ const result = await app.render(url.pathname + url.search, document, {
113
+ // Nothing better than the console here: this function is what a worker
114
+ // or a serverless invocation wraps, and it has no terminal of its own.
115
+ // Losing a boundary's exception entirely would be worse — it is the only
116
+ // trace a page that failed after its first byte leaves anywhere.
117
+ onError: (error: mixed) => {
118
+ console.error(error);
119
+ },
120
+ });
121
+ const headers = new Headers(result.headers ?? {});
122
+ headers.set("content-type", "text/html; charset=utf-8");
123
+ // A `HEAD` gets the status and the headers and no body, which is what the
124
+ // renderer cannot know to do for itself. The stream is cancelled rather
125
+ // than dropped, so the render behind it stops instead of filling its queue
126
+ // and waiting for a reader that is never coming.
127
+ if (method === "HEAD") {
128
+ await result.stream().cancel();
129
+ return new Response(null, { status: result.status ?? 200, headers });
130
+ }
131
+ // The body is a stream, so the layouts and any `<Suspense>` fallback reach
132
+ // the browser while the page they surround is still resolving.
133
+ return new Response(result.stream(), { status: result.status ?? 200, headers });
134
+ };
135
+ }
package/host.js CHANGED
@@ -15,9 +15,42 @@
15
15
  // to nest one request inside another.
16
16
  //
17
17
  // It is a subpath rather than `internal/` because a sibling package cannot
18
- // reach another's internals: `@uniflowed/router` is where a request begins, and
19
- // it is a different npm package.
18
+ // reach another's internals: `@uniflowed/router` renders and dispatches inside
19
+ // a request, and it is a different npm package.
20
+ //
21
+ // # `beginRequest` is the one a host calls
22
+ //
23
+ // The other exports are what it is made of, and they are public because the
24
+ // suite drives them one at a time and because a host with an unusual shape may
25
+ // need them. A host that reaches for them separately is nonetheless doing the
26
+ // thing that produced ubugeeei-prod/uf#389: `@uniflowed/router` used to build a
27
+ // context in its middleware runner and drain it there, and build a second one
28
+ // in its dispatcher, so a request had up to two contexts and `after()` ran
29
+ // before the response existed. One request is one `beginRequest`, and the pair
30
+ // it returns is deliberately awkward to call from a single place — `run` wraps
31
+ // deciding the response, `settle` follows writing it.
32
+ //
33
+ // # Which module's copy
34
+ //
35
+ // This one holds an `AsyncLocalStorage`, so the context is only shared by code
36
+ // that resolved to the *same* copy of it. A host that serves a bundled
37
+ // application must therefore take `beginRequest` from that bundle —
38
+ // `virtual:uf/server` re-exports it for exactly this reason — and not from its
39
+ // own `node_modules`, where it would be a second storage that sees nothing.
20
40
 
21
- export type { CookieStore, DraftMode, HeaderStore, RequestContext } from "./internal/context.js";
41
+ export type {
42
+ CookieStore,
43
+ DraftMode,
44
+ HeaderStore,
45
+ RequestContext,
46
+ RequestLifecycle,
47
+ } from "./internal/context.js";
22
48
 
23
- export { contextFor, drainDeferred, parseCookies, runWithContext } from "./internal/context.js";
49
+ export {
50
+ beginRequest,
51
+ contextFor,
52
+ drainDeferred,
53
+ insideRequest,
54
+ parseCookies,
55
+ runWithContext,
56
+ } from "./internal/context.js";
package/index.js CHANGED
@@ -95,6 +95,14 @@ export function draftMode(): DraftMode {
95
95
  * view, flushing a metric, warming a cache. Registered work runs in the order
96
96
  * it was registered, and one task failing does not stop the others — deferred
97
97
  * work is by definition not what the response depended on.
98
+ *
99
+ * "Sent" is the host's word to keep, and it keeps it: the request is drained
100
+ * after `uf dev` has written the document, after `uf preview` and `uf start`
101
+ * have returned from `send`, and after a compiled binary's `pipe` has resolved
102
+ * on the last byte. One list per request, whether the callback was registered
103
+ * by a middleware, a route handler or a page. It was not always so — see
104
+ * ubugeeei-prod/uf#389 for what it meant before, and
105
+ * `@uniflowed/server/host`'s `beginRequest` for the half a host supplies.
98
106
  */
99
107
  export function after(callback: () => mixed | Promise<mixed>): void {
100
108
  require$Context("after").deferred.push(callback);
@@ -0,0 +1,90 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/server`: what a built uf application is, as a type.
4
+ //
5
+ // Three shapes, and every front door onto a build needs all three — the asset
6
+ // URLs a rendered document references, what one render answers with, and the
7
+ // module `uf build` writes to `.uf/build/server/server.js`.
8
+ //
9
+ // They are here rather than in whichever module happened to be written first
10
+ // because there are now three readers of them: [`../fetch.js`] for a host that
11
+ // speaks `Request` and `Response`, [`../standalone.js`] for a compiled binary,
12
+ // and `@uniflowed/vite`'s preview and start servers through the first of
13
+ // those. A type re-declared once per reader is how two of them come to
14
+ // disagree about what `render` returns, which is a disagreement no test sees
15
+ // until a deployment answers differently from the preview it was checked with.
16
+
17
+ import type { RequestLifecycle } from "./context.js";
18
+
19
+ export type { RequestLifecycle } from "./context.js";
20
+
21
+ /**
22
+ * Where a document is written, when the host has a Node stream.
23
+ *
24
+ * The two methods React's own `pipe` uses and nothing else, declared
25
+ * structurally so this module holds no Node types: `../fetch.js` is bundled
26
+ * for hosts that have no `node:stream` to import one from.
27
+ */
28
+ export type WritableLike = {
29
+ readonly write: (chunk: string | Uint8Array) => mixed,
30
+ readonly end: () => mixed,
31
+ ...
32
+ };
33
+
34
+ /** The script, stylesheet and preload URLs a rendered document references. */
35
+ export type DocumentAssets = {|
36
+ readonly scripts: $ReadOnlyArray<string>,
37
+ readonly styles: $ReadOnlyArray<string>,
38
+ readonly preloads: $ReadOnlyArray<string>,
39
+ |};
40
+
41
+ /**
42
+ * A document that has begun.
43
+ *
44
+ * `status` and `headers` are known once the shell is ready, which is why a
45
+ * streaming renderer can still answer with a status line. The body arrives
46
+ * afterwards through exactly one of `pipe` and `stream` — each is a single
47
+ * pass over the same chunks, so calling both would read a document twice.
48
+ */
49
+ export type RenderedDocument = {|
50
+ readonly status: number,
51
+ readonly headers?: { readonly [string]: string },
52
+ readonly pipe: (destination: WritableLike) => mixed,
53
+ readonly stream: () => ReadableStream<Uint8Array>,
54
+ |};
55
+
56
+ /** What the project's server bundle exports; see `virtual:uf/server`. */
57
+ export type Application = {|
58
+ /** Render `url`, resolving when the shell is ready. */
59
+ readonly render: (
60
+ url: string,
61
+ assets: DocumentAssets,
62
+ options?: {| readonly onError?: (error: mixed) => void |},
63
+ ) => Promise<RenderedDocument>,
64
+ /** The route handler for this request, or `null` when no handler claims it. */
65
+ readonly dispatch: (request: Request) => Promise<Response | null>,
66
+ /**
67
+ * The guard on the path, run before anything under it answers.
68
+ *
69
+ * Called rather than tested for: a server bundle without it is a `TypeError`
70
+ * on the first request, not an application whose auth check quietly stopped
71
+ * running once it was built. See ubugeeei-prod/uf#260.
72
+ */
73
+ readonly runMiddleware: (request: Request) => Promise<Response | null>,
74
+ /**
75
+ * Begin the request everything above runs inside.
76
+ *
77
+ * A host calls this, runs the whole of answering the request inside `run`,
78
+ * and calls `settle` once the response has been written — which is what
79
+ * `after()` means by "sent" and is a different line in every host.
80
+ *
81
+ * It is on the bundle rather than importable beside this type, and that is
82
+ * the one thing about it that looks wrong and is not: the request lives in an
83
+ * `AsyncLocalStorage` belonging to a module *instance*, and the instance the
84
+ * application reads is the one bundled into its own `server.js`. A host that
85
+ * began a request in any other copy would fail silently — the guard would
86
+ * run, the page would render, and every `cookies()` in it would throw as
87
+ * though no host had run at all. See ubugeeei-prod/uf#389.
88
+ */
89
+ readonly beginRequest: (request: Request) => RequestLifecycle,
90
+ |};
@@ -55,6 +55,26 @@ export type RequestContext = {
55
55
  readonly deferred: Array<() => mixed | Promise<mixed>>,
56
56
  };
57
57
 
58
+ /**
59
+ * One request, from the moment a host has one to the moment its bytes are gone.
60
+ *
61
+ * Two functions rather than one, because they are called from two places and
62
+ * that is the whole point rather than an inconvenience. `run` wraps everything
63
+ * that *decides* the response — the guard, the dispatcher, the render — and
64
+ * `settle` happens after the response has been *written*, which in every host
65
+ * uf has is a different line in a different module. A single
66
+ * `handle(request, body)` that drained when `body` returned would be the bug
67
+ * this exists to fix, spelled once instead of twice.
68
+ */
69
+ export type RequestLifecycle = {|
70
+ /** The context `run` establishes, for a host that needs to read it. */
71
+ readonly context: RequestContext,
72
+ /** Run the whole request inside it. */
73
+ readonly run: <T>(body: () => Promise<T>) => Promise<T>,
74
+ /** The response has gone: run what `after()` deferred. */
75
+ readonly settle: () => Promise<void>,
76
+ |};
77
+
58
78
  const storage: AsyncLocalStorage<RequestContext> = new AsyncLocalStorage();
59
79
 
60
80
  /**
@@ -68,6 +88,19 @@ export function currentContext(): RequestContext | null {
68
88
  return storage.getStore() ?? null;
69
89
  }
70
90
 
91
+ /**
92
+ * Whether a request has been established around this call.
93
+ *
94
+ * For a caller that is not a server function and has nothing to answer about
95
+ * the request — the router's dispatcher and its middleware runner, which need
96
+ * to know that a host established one *before* anything they call asks for
97
+ * cookies. They must not be handed the context itself: a module that can reach
98
+ * it can drain it, which is how the drain came to be in the wrong place.
99
+ */
100
+ export function insideRequest(): boolean {
101
+ return storage.getStore() != null;
102
+ }
103
+
71
104
  /**
72
105
  * Run `body` with `context` as the current request.
73
106
  *
@@ -84,10 +117,19 @@ export function runWithContext<T>(context: RequestContext, body: () => T): T {
84
117
  * touches `cookies().get(…)` as often as it has components that care, and
85
118
  * re-parsing the cookie header each time would be the kind of cost nobody
86
119
  * looks for.
120
+ *
121
+ * Parsed on the first read rather than here, and that changed when the host
122
+ * became the thing that begins a request: a host begins one before it knows
123
+ * whether the path is an embedded chunk or a page, so every asset a compiled
124
+ * binary serves now builds a context. Splitting a `Cookie` header for a
125
+ * request that never asks about cookies is exactly the cost the paragraph
126
+ * above refuses to pay per read, and there is no reason to pay it per request
127
+ * either.
87
128
  */
88
129
  export function contextFor(request: Request): RequestContext {
89
130
  const headers = request.headers;
90
- const cookies = parseCookies(headers.get("cookie"));
131
+ let cookies: { [string]: string } | null = null;
132
+ const parsed = () => (cookies ??= parseCookies(headers.get("cookie")));
91
133
 
92
134
  return {
93
135
  headers: {
@@ -95,14 +137,45 @@ export function contextFor(request: Request): RequestContext {
95
137
  has: (name) => headers.has(name),
96
138
  },
97
139
  cookies: {
98
- get: (name) => (Object.hasOwn(cookies, name) ? cookies[name] : null),
99
- has: (name) => Object.hasOwn(cookies, name),
140
+ get: (name) => (Object.hasOwn(parsed(), name) ? parsed()[name] : null),
141
+ has: (name) => Object.hasOwn(parsed(), name),
100
142
  },
101
143
  draft: false,
102
144
  deferred: [],
103
145
  };
104
146
  }
105
147
 
148
+ /**
149
+ * Begin a request, and hand back the two halves of owning it.
150
+ *
151
+ * The one function a host calls. `contextFor`, `runWithContext` and
152
+ * `drainDeferred` are still here because they are what this is made of and
153
+ * because the suite drives them one at a time, but a *host* reaching for them
154
+ * separately is how uf got two contexts on one request and a drain that ran
155
+ * before the response: the middleware runner built one and drained it, and the
156
+ * dispatcher underneath it built another. See ubugeeei-prod/uf#389.
157
+ *
158
+ * `settle` runs once. A host learns that a response is finished more than once
159
+ * — the body stream closed, and then the socket did — and draining twice would
160
+ * run whatever the first drain's callbacks registered, at a moment nothing
161
+ * asked for.
162
+ */
163
+ export function beginRequest(request: Request): RequestLifecycle {
164
+ const context = contextFor(request);
165
+ let settling: Promise<void> | null = null;
166
+
167
+ function run<T>(body: () => Promise<T>): Promise<T> {
168
+ return runWithContext(context, body);
169
+ }
170
+
171
+ function settle(): Promise<void> {
172
+ settling ??= drainDeferred(context);
173
+ return settling;
174
+ }
175
+
176
+ return { context, run, settle };
177
+ }
178
+
106
179
  /**
107
180
  * Parse a `Cookie` header into a plain object.
108
181
  *