@uniflowed/server 0.0.0-alpha.7 → 0.0.0-alpha.8
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/fetch.js +135 -0
- package/host.js +37 -4
- package/index.js +8 -0
- package/internal/application.js +90 -0
- package/internal/context.js +76 -3
- package/node.js +460 -0
- package/package.json +5 -1
- package/standalone.js +193 -48
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`
|
|
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 {
|
|
41
|
+
export type {
|
|
42
|
+
CookieStore,
|
|
43
|
+
DraftMode,
|
|
44
|
+
HeaderStore,
|
|
45
|
+
RequestContext,
|
|
46
|
+
RequestLifecycle,
|
|
47
|
+
} from "./internal/context.js";
|
|
22
48
|
|
|
23
|
-
export {
|
|
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
|
+
|};
|
package/internal/context.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
99
|
-
has: (name) => Object.hasOwn(
|
|
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
|
*
|
package/node.js
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/server/node`: the Node 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, and all of it is host-specific: reading a file out of a directory,
|
|
8
|
+
// translating between Node's request objects and the platform's, and taking a
|
|
9
|
+
// socket.
|
|
10
|
+
//
|
|
11
|
+
// Three things use it, and the point of it being one module is that they
|
|
12
|
+
// cannot answer differently:
|
|
13
|
+
//
|
|
14
|
+
// * `uf start`, through `@uniflowed/vite`'s `internal/serve.js`;
|
|
15
|
+
// * `uf preview`, through the same;
|
|
16
|
+
// * the `server.js` that `uf build --adapter node` writes, which imports
|
|
17
|
+
// this directly — a deployed application must not link the package named
|
|
18
|
+
// after the bundler, and before this module existed the only copy of the
|
|
19
|
+
// static half was inside `@uniflowed/vite`.
|
|
20
|
+
//
|
|
21
|
+
// The exception is `./standalone.js`, which serves from bytes it carries
|
|
22
|
+
// instead of from a directory; its header says why it is a second
|
|
23
|
+
// implementation rather than a caller of this one.
|
|
24
|
+
//
|
|
25
|
+
// # Who owns the request
|
|
26
|
+
//
|
|
27
|
+
// This module does, for every host that reaches it: [`nodeListener`] begins
|
|
28
|
+
// the request, runs the whole of answering it inside `run`, and settles it on
|
|
29
|
+
// the line after the last byte — after its own 500, when it wrote one. That is
|
|
30
|
+
// what `after()` promises and it is not something a `Request` → `Response`
|
|
31
|
+
// handler can promise for itself, because such a handler has a `Response` in
|
|
32
|
+
// hand and not a response on the wire.
|
|
33
|
+
//
|
|
34
|
+
// `beginRequest` is passed in rather than imported from `./internal/context.js`
|
|
35
|
+
// beside this file, and that is the one thing about this module that looks
|
|
36
|
+
// wrong and is not. The request lives in an `AsyncLocalStorage` belonging to a
|
|
37
|
+
// module *instance*, and the instance the application reads is the one bundled
|
|
38
|
+
// into `.uf/build/server/server.js` — not the one this file resolves from the
|
|
39
|
+
// host's `node_modules`. A host that began a request in the wrong storage
|
|
40
|
+
// would fail silently: the guard would run, the page would render, and every
|
|
41
|
+
// `cookies()` in it would throw as though no host had run at all. So the
|
|
42
|
+
// bundle hands it out, `@uniflowed/router/server` re-exports it, and a caller
|
|
43
|
+
// passes it here. See ubugeeei-prod/uf#389.
|
|
44
|
+
//
|
|
45
|
+
// # The order is Vite's
|
|
46
|
+
//
|
|
47
|
+
// Static files first, then the application. That is a compatibility
|
|
48
|
+
// requirement rather than a preference: Vite's preview server runs its own
|
|
49
|
+
// file middleware before anything mounted behind it can see a request, so
|
|
50
|
+
// `uf preview` serves a file first whether or not anything here agrees — and a
|
|
51
|
+
// deployment that disagreed would mean a project whose handler path collides
|
|
52
|
+
// with a file in `public/` behaves one way when it is checked and the other
|
|
53
|
+
// way when it is deployed.
|
|
54
|
+
|
|
55
|
+
import { createReadStream } from "node:fs";
|
|
56
|
+
import { stat } from "node:fs/promises";
|
|
57
|
+
import { createServer } from "node:http";
|
|
58
|
+
import path from "node:path";
|
|
59
|
+
import { Readable } from "node:stream";
|
|
60
|
+
|
|
61
|
+
import type { RequestLifecycle } from "./internal/context.js";
|
|
62
|
+
|
|
63
|
+
export type { RequestLifecycle } from "./internal/context.js";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Content types for what a uf build emits.
|
|
67
|
+
*
|
|
68
|
+
* A closed table rather than a dependency, and deliberately short: every entry
|
|
69
|
+
* is an extension `uf build` actually writes or a project actually puts in
|
|
70
|
+
* `public/`. Anything else is `application/octet-stream`, which a browser
|
|
71
|
+
* downloads rather than executes — the safe answer for a file whose type we do
|
|
72
|
+
* not know, and the reason this is not a guess based on the bytes.
|
|
73
|
+
*/
|
|
74
|
+
const CONTENT_TYPES: { readonly [string]: string } = Object.freeze({
|
|
75
|
+
".avif": "image/avif",
|
|
76
|
+
".css": "text/css; charset=utf-8",
|
|
77
|
+
".gif": "image/gif",
|
|
78
|
+
".html": "text/html; charset=utf-8",
|
|
79
|
+
".ico": "image/x-icon",
|
|
80
|
+
".jpeg": "image/jpeg",
|
|
81
|
+
".jpg": "image/jpeg",
|
|
82
|
+
".js": "text/javascript; charset=utf-8",
|
|
83
|
+
".json": "application/json; charset=utf-8",
|
|
84
|
+
".map": "application/json; charset=utf-8",
|
|
85
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
86
|
+
".png": "image/png",
|
|
87
|
+
".svg": "image/svg+xml",
|
|
88
|
+
".txt": "text/plain; charset=utf-8",
|
|
89
|
+
".webmanifest": "application/manifest+json",
|
|
90
|
+
".webp": "image/webp",
|
|
91
|
+
".woff": "font/woff",
|
|
92
|
+
".woff2": "font/woff2",
|
|
93
|
+
".xml": "application/xml; charset=utf-8",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The pieces of a Node request this module touches.
|
|
98
|
+
*
|
|
99
|
+
* Declared structurally rather than imported from a `node:http` libdef, for
|
|
100
|
+
* the same reason `./standalone.js` does it: the set is small, and naming it
|
|
101
|
+
* here is what lets the file be read without knowing which host's types are in
|
|
102
|
+
* scope. `originalUrl` is Connect's, and it is here because `uf preview` runs
|
|
103
|
+
* this behind Vite's middleware stack, which sets it.
|
|
104
|
+
*/
|
|
105
|
+
export type NodeRequest = {
|
|
106
|
+
readonly method?: string,
|
|
107
|
+
readonly url?: string,
|
|
108
|
+
readonly originalUrl?: string,
|
|
109
|
+
readonly headers: { readonly [string]: string | Array<string> | void },
|
|
110
|
+
...
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** The pieces of a Node response this module writes. */
|
|
114
|
+
export type NodeResponse = {
|
|
115
|
+
statusCode: number,
|
|
116
|
+
statusMessage: string,
|
|
117
|
+
headersSent: boolean,
|
|
118
|
+
setHeader(name: string, value: string): mixed,
|
|
119
|
+
write(chunk: Uint8Array | string): boolean,
|
|
120
|
+
end(chunk?: Uint8Array | string): mixed,
|
|
121
|
+
destroy(error?: mixed): mixed,
|
|
122
|
+
// `send` paces itself against the socket and stops when the client hangs
|
|
123
|
+
// up, so it needs the events as well as the writes. `write` returns a
|
|
124
|
+
// `boolean` for the same reason — `mixed` would have made the back-pressure
|
|
125
|
+
// check unwritable, which is one way this was lost.
|
|
126
|
+
on(event: string, listener: () => mixed): mixed,
|
|
127
|
+
once(event: string, listener: () => mixed): mixed,
|
|
128
|
+
off(event: string, listener: () => mixed): mixed,
|
|
129
|
+
...
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A Node request as a `Request`.
|
|
134
|
+
*
|
|
135
|
+
* The body is passed as a stream where the host allows it, so a handler that
|
|
136
|
+
* accepts an upload does not need the whole thing buffered before it starts.
|
|
137
|
+
* `duplex` is required by the specification whenever a body is a stream, and
|
|
138
|
+
* Node throws without it.
|
|
139
|
+
*/
|
|
140
|
+
export function toRequest(
|
|
141
|
+
incoming: NodeRequest,
|
|
142
|
+
options?: {| readonly secure?: boolean |},
|
|
143
|
+
): Request {
|
|
144
|
+
const host = incoming.headers.host;
|
|
145
|
+
const authority = typeof host === "string" && host !== "" ? host : "localhost";
|
|
146
|
+
const protocol = options?.secure === true ? "https" : "http";
|
|
147
|
+
const url = new URL(incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${authority}`);
|
|
148
|
+
|
|
149
|
+
const headers = new Headers();
|
|
150
|
+
for (const name of Object.keys(incoming.headers)) {
|
|
151
|
+
const value = incoming.headers[name];
|
|
152
|
+
if (value == null) continue;
|
|
153
|
+
for (const entry of Array.isArray(value) ? value : [value]) {
|
|
154
|
+
headers.append(name, entry);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const method = (incoming.method ?? "GET").toUpperCase();
|
|
159
|
+
const init: { [string]: mixed } = { method, headers };
|
|
160
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
161
|
+
init.body = incoming;
|
|
162
|
+
init.duplex = "half";
|
|
163
|
+
}
|
|
164
|
+
// $FlowFixMe[incompatible-call] - `incoming` is a stream, which `Request` accepts.
|
|
165
|
+
return new Request(url, init);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Write a `Response` to a Node response. */
|
|
169
|
+
export async function send(outgoing: NodeResponse, result: Response): Promise<void> {
|
|
170
|
+
outgoing.statusCode = result.status;
|
|
171
|
+
if (result.statusText !== "") {
|
|
172
|
+
outgoing.statusMessage = result.statusText;
|
|
173
|
+
}
|
|
174
|
+
for (const [name, value] of result.headers) {
|
|
175
|
+
outgoing.setHeader(name, value);
|
|
176
|
+
}
|
|
177
|
+
if (result.body == null) {
|
|
178
|
+
outgoing.end();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Streamed rather than buffered, so a handler returning a large or
|
|
182
|
+
// open-ended body is not read into memory first.
|
|
183
|
+
//
|
|
184
|
+
// Which was only half true while this loop read as fast as the body would
|
|
185
|
+
// give: `write` answers `false` when the kernel buffer is full and the rest
|
|
186
|
+
// is being held in *this process's* memory, and a reader that ignores that
|
|
187
|
+
// turns a slow client into a heap the size of everything it has not
|
|
188
|
+
// acknowledged. Streaming in shape and buffering in fact — the same failure
|
|
189
|
+
// `ChunkQueue` exists to avoid a layer up, in the renderer.
|
|
190
|
+
const reader = result.body.getReader();
|
|
191
|
+
// And a client that hangs up is the other half. Nothing written after that
|
|
192
|
+
// goes anywhere, and the producer behind the body — a render, a proxied
|
|
193
|
+
// upstream, an event stream — keeps producing for a reader that is never
|
|
194
|
+
// coming back. `cancel()` is what tells it to stop; `releaseLock()` would
|
|
195
|
+
// only detach this end.
|
|
196
|
+
let open = true;
|
|
197
|
+
const onClose = () => {
|
|
198
|
+
open = false;
|
|
199
|
+
};
|
|
200
|
+
outgoing.on("close", onClose);
|
|
201
|
+
try {
|
|
202
|
+
while (open) {
|
|
203
|
+
const { done, value } = await reader.read();
|
|
204
|
+
if (done === true || !open) break;
|
|
205
|
+
if (value != null && outgoing.write(value) === false) {
|
|
206
|
+
await writable(outgoing);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
outgoing.off("close", onClose);
|
|
211
|
+
}
|
|
212
|
+
if (open) {
|
|
213
|
+
outgoing.end();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// Best effort, and the only place in this function where a rejection is
|
|
217
|
+
// dropped: the connection is already gone, so there is nobody left to report
|
|
218
|
+
// to and no response left to fail.
|
|
219
|
+
await reader.cancel().catch(() => {});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolve once `outgoing` can take more — or once it cannot ever again.
|
|
224
|
+
*
|
|
225
|
+
* `drain` alone would be a deadlock waiting to happen: a client that hangs up
|
|
226
|
+
* while the buffer is full emits `close` and never `drain`, and a writer
|
|
227
|
+
* waiting only for the latter waits for the life of the process, holding the
|
|
228
|
+
* body's producer open with it.
|
|
229
|
+
*/
|
|
230
|
+
function writable(outgoing: NodeResponse): Promise<void> {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
const settle = () => {
|
|
233
|
+
outgoing.off("drain", settle);
|
|
234
|
+
outgoing.off("close", settle);
|
|
235
|
+
resolve();
|
|
236
|
+
};
|
|
237
|
+
outgoing.once("drain", settle);
|
|
238
|
+
outgoing.once("close", settle);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The static half: a file under `root`, or `null` for the caller to carry on.
|
|
244
|
+
*
|
|
245
|
+
* `GET` and `HEAD` only. A `POST` to a path that happens to have a file under
|
|
246
|
+
* it belongs to a route handler, and answering it with the file's bytes would
|
|
247
|
+
* be the same mistake as rendering a page for it.
|
|
248
|
+
*
|
|
249
|
+
* # The path is checked once, after it is resolved
|
|
250
|
+
*
|
|
251
|
+
* `docs/security.md` rule 2: never authorize against a raw request string or a
|
|
252
|
+
* partially decoded path. The pathname is decoded first, then resolved against
|
|
253
|
+
* the root, and *then* checked to be inside it — so `%2e%2e%2f`, a backslash
|
|
254
|
+
* on Windows, and a symlinked directory all reduce to the same question, asked
|
|
255
|
+
* once, of the value that is actually opened.
|
|
256
|
+
*/
|
|
257
|
+
export function createStaticHandler(options: {|
|
|
258
|
+
readonly root: string,
|
|
259
|
+
|}): (request: Request) => Promise<Response | null> {
|
|
260
|
+
const rootDir = path.resolve(options.root);
|
|
261
|
+
|
|
262
|
+
return async function serveStatic(request: Request): Promise<Response | null> {
|
|
263
|
+
const method = request.method.toUpperCase();
|
|
264
|
+
if (method !== "GET" && method !== "HEAD") return null;
|
|
265
|
+
|
|
266
|
+
const pathname = decodePathname(new URL(request.url).pathname);
|
|
267
|
+
if (pathname == null) return null;
|
|
268
|
+
|
|
269
|
+
const resolved = path.resolve(rootDir, `.${pathname}`);
|
|
270
|
+
if (resolved !== rootDir && !resolved.startsWith(rootDir + path.sep)) return null;
|
|
271
|
+
|
|
272
|
+
// `/guide/` and `/guide` are the same prerendered document, and neither
|
|
273
|
+
// spelling is the one a person types. `<path>.html` is last because a
|
|
274
|
+
// build writes `guide/index.html`, and only a hand-placed file in
|
|
275
|
+
// `public/` is ever `guide.html`.
|
|
276
|
+
const candidates =
|
|
277
|
+
pathname.endsWith("/") === true
|
|
278
|
+
? [path.join(resolved, "index.html")]
|
|
279
|
+
: [resolved, path.join(resolved, "index.html"), `${resolved}.html`];
|
|
280
|
+
|
|
281
|
+
for (const candidate of candidates) {
|
|
282
|
+
const info = await statFile(candidate);
|
|
283
|
+
if (info == null || !info.isFile()) continue;
|
|
284
|
+
const headers = {
|
|
285
|
+
"content-type":
|
|
286
|
+
CONTENT_TYPES[path.extname(candidate).toLowerCase()] ?? "application/octet-stream",
|
|
287
|
+
"content-length": String(info.size),
|
|
288
|
+
};
|
|
289
|
+
if (method === "HEAD") return new Response(null, { headers });
|
|
290
|
+
// Streamed rather than read into memory, so serving a large asset costs
|
|
291
|
+
// a buffer rather than the file.
|
|
292
|
+
// $FlowFixMe[incompatible-call] - a Node web stream is a `BodyInit`.
|
|
293
|
+
return new Response(Readable.toWeb(createReadStream(candidate)), { headers });
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function decodePathname(pathname: string): string | null {
|
|
300
|
+
try {
|
|
301
|
+
const decoded = decodeURIComponent(pathname);
|
|
302
|
+
// A NUL truncates the name every C-level `open` sees, so a path holding
|
|
303
|
+
// one is refused rather than normalised into something shorter.
|
|
304
|
+
return decoded.includes("\0") ? null : decoded;
|
|
305
|
+
} catch {
|
|
306
|
+
// A percent escape that is not one. There is no file behind it.
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function statFile(file: string) {
|
|
312
|
+
try {
|
|
313
|
+
return await stat(file);
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A `Request`/`Response` handler as a Node request listener.
|
|
321
|
+
*
|
|
322
|
+
* The handler contract is the platform's, so this adapter belongs here rather
|
|
323
|
+
* than in every host that wants to run one.
|
|
324
|
+
*
|
|
325
|
+
* `beginRequest` is required, and it comes from the application bundle for the
|
|
326
|
+
* reason in "Who owns the request" above. A listener built without one fails on
|
|
327
|
+
* its first request, which is the same trade `createFetchHandler` makes about
|
|
328
|
+
* `app.runMiddleware`: an optional lifecycle is a lifecycle somebody forgets,
|
|
329
|
+
* and what is lost when they do is every `after()` in the application.
|
|
330
|
+
*
|
|
331
|
+
* A handler that throws is answered with a bare 500 and reported on stderr:
|
|
332
|
+
* the body must not carry the stack, because the body goes to whoever asked,
|
|
333
|
+
* and stderr is where the operator is already looking. The drain is in a
|
|
334
|
+
* `finally` below the `catch`, so a middleware that logged the request sees its
|
|
335
|
+
* callback run once that 500 is on the wire rather than once the handler gave
|
|
336
|
+
* up — and a request that failed is still a request that happened, which is why
|
|
337
|
+
* it is drained at all.
|
|
338
|
+
*/
|
|
339
|
+
export function nodeListener(
|
|
340
|
+
handle: (request: Request) => Promise<Response>,
|
|
341
|
+
options: {|
|
|
342
|
+
readonly beginRequest: (request: Request) => RequestLifecycle,
|
|
343
|
+
readonly secure?: boolean,
|
|
344
|
+
|},
|
|
345
|
+
): (incoming: NodeRequest, outgoing: NodeResponse) => Promise<void> {
|
|
346
|
+
return async function listener(incoming: NodeRequest, outgoing: NodeResponse): Promise<void> {
|
|
347
|
+
// Declared out here because `toRequest` is inside the `try`: a request that
|
|
348
|
+
// could not even be built has no lifecycle to settle.
|
|
349
|
+
let lifecycle: RequestLifecycle | null = null;
|
|
350
|
+
try {
|
|
351
|
+
const request = toRequest(incoming, options);
|
|
352
|
+
lifecycle = options.beginRequest(request);
|
|
353
|
+
await lifecycle.run(async () => {
|
|
354
|
+
await send(outgoing, await handle(request));
|
|
355
|
+
});
|
|
356
|
+
} catch (error) {
|
|
357
|
+
console.error(error);
|
|
358
|
+
if (outgoing.headersSent) {
|
|
359
|
+
outgoing.destroy();
|
|
360
|
+
} else {
|
|
361
|
+
outgoing.statusCode = 500;
|
|
362
|
+
outgoing.setHeader("content-type", "text/plain; charset=utf-8");
|
|
363
|
+
outgoing.end("500 Internal Server Error\n");
|
|
364
|
+
}
|
|
365
|
+
} finally {
|
|
366
|
+
if (lifecycle != null) await lifecycle.settle();
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Static files, then the application: the whole of what a built uf app serves.
|
|
373
|
+
*
|
|
374
|
+
* `staticDir` is the directory `uf build` wrote — `dist/` in a checkout, and
|
|
375
|
+
* the `static/` copied beside `server.js` in an adapter's output.
|
|
376
|
+
*/
|
|
377
|
+
export function createServeHandler(options: {|
|
|
378
|
+
readonly staticDir: string,
|
|
379
|
+
readonly handle: (request: Request) => Promise<Response>,
|
|
380
|
+
|}): (request: Request) => Promise<Response> {
|
|
381
|
+
const serveStatic = createStaticHandler({ root: options.staticDir });
|
|
382
|
+
return async function handle(request: Request): Promise<Response> {
|
|
383
|
+
return (await serveStatic(request)) ?? (await options.handle(request));
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Serve the application until the process is stopped.
|
|
389
|
+
*
|
|
390
|
+
* What `uf build --adapter node` writes calls this and nothing else. It
|
|
391
|
+
* resolves once the socket is listening, with the address it took, because a
|
|
392
|
+
* caller that asked for port 0 has no other way to learn which port it got —
|
|
393
|
+
* and because a test that has to drive a deployed directory needs exactly
|
|
394
|
+
* that.
|
|
395
|
+
*
|
|
396
|
+
* `PORT` and `HOST` are read from the environment because that is how every
|
|
397
|
+
* process manager and container platform says which socket to take, and a
|
|
398
|
+
* production server that could only be told on the command line would need a
|
|
399
|
+
* wrapper script everywhere it ran. The command line wins over both, and the
|
|
400
|
+
* default address is every interface: a container that bound loopback would be
|
|
401
|
+
* a container nothing outside it can reach.
|
|
402
|
+
*/
|
|
403
|
+
export async function serve(options: {|
|
|
404
|
+
readonly staticDir: string,
|
|
405
|
+
readonly handle: (request: Request) => Promise<Response>,
|
|
406
|
+
/**
|
|
407
|
+
* The application bundle's own `beginRequest`.
|
|
408
|
+
*
|
|
409
|
+
* The generated `handler.js` re-exports it beside `fetch` so that
|
|
410
|
+
* `server.js` has one to pass; see "Who owns the request" above for why it
|
|
411
|
+
* cannot be imported here instead.
|
|
412
|
+
*/
|
|
413
|
+
readonly beginRequest: (request: Request) => RequestLifecycle,
|
|
414
|
+
readonly host?: string,
|
|
415
|
+
readonly port?: number,
|
|
416
|
+
|}): Promise<{|
|
|
417
|
+
readonly host: string,
|
|
418
|
+
readonly port: number,
|
|
419
|
+
readonly close: () => Promise<void>,
|
|
420
|
+
|}> {
|
|
421
|
+
const listener = nodeListener(
|
|
422
|
+
createServeHandler({ staticDir: options.staticDir, handle: options.handle }),
|
|
423
|
+
{ beginRequest: options.beginRequest },
|
|
424
|
+
);
|
|
425
|
+
const server = createServer((request, response) => {
|
|
426
|
+
void listener(request, response);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const host = options.host ?? argument("--host") ?? process.env.HOST ?? "0.0.0.0";
|
|
430
|
+
const port = options.port ?? Number(argument("--port") ?? process.env.PORT ?? 3000);
|
|
431
|
+
|
|
432
|
+
await new Promise((resolve, reject) => {
|
|
433
|
+
server.once("error", reject);
|
|
434
|
+
server.listen(port, host, resolve);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const address = server.address();
|
|
438
|
+
const bound = typeof address === "object" && address != null ? address.port : port;
|
|
439
|
+
// `0.0.0.0` is not a URL anybody can open, so the loopback spelling is what
|
|
440
|
+
// is printed — the same split `uf dev` and `uf start` print, and for the
|
|
441
|
+
// same reason: one of the two is a link and the other is a fact about the
|
|
442
|
+
// socket.
|
|
443
|
+
const shown = host === "0.0.0.0" || host === "::" ? "localhost" : host;
|
|
444
|
+
process.stdout.write(`uf: listening on http://${shown}:${String(bound)}\n`);
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
host,
|
|
448
|
+
port: bound,
|
|
449
|
+
close: () =>
|
|
450
|
+
new Promise((resolve) => {
|
|
451
|
+
server.close(() => resolve());
|
|
452
|
+
}),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The value of a `--flag value` pair on the command line, if it is there. */
|
|
457
|
+
function argument(name: string): string | null {
|
|
458
|
+
const at = process.argv.indexOf(name);
|
|
459
|
+
return at === -1 ? null : (process.argv[at + 1] ?? null);
|
|
460
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/server",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.8",
|
|
4
4
|
"description": "Request-scoped server functions for the Unified Toolchain for Flow (React).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,12 +12,16 @@
|
|
|
12
12
|
},
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./index.js",
|
|
15
|
+
"./fetch": "./fetch.js",
|
|
15
16
|
"./host": "./host.js",
|
|
17
|
+
"./node": "./node.js",
|
|
16
18
|
"./standalone": "./standalone.js"
|
|
17
19
|
},
|
|
18
20
|
"files": [
|
|
21
|
+
"fetch.js",
|
|
19
22
|
"index.js",
|
|
20
23
|
"host.js",
|
|
24
|
+
"node.js",
|
|
21
25
|
"standalone.js",
|
|
22
26
|
"internal/*.js"
|
|
23
27
|
]
|
package/standalone.js
CHANGED
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
import { Buffer } from "node:buffer";
|
|
53
53
|
import { createServer } from "node:http";
|
|
54
54
|
|
|
55
|
+
import { send } from "./node.js";
|
|
56
|
+
|
|
55
57
|
/**
|
|
56
58
|
* The pieces of a Node request and response this module touches.
|
|
57
59
|
*
|
|
@@ -71,6 +73,19 @@ type NodeResponse = {
|
|
|
71
73
|
setHeader(name: string, value: string): mixed,
|
|
72
74
|
write(chunk: Uint8Array | string): mixed,
|
|
73
75
|
end(chunk?: Uint8Array | string): mixed,
|
|
76
|
+
// Required rather than optional, because the one case it exists for is the
|
|
77
|
+
// one where nothing else will do: a render that fails after the shell has
|
|
78
|
+
// gone out cannot be answered with a status, and dropping the socket is the
|
|
79
|
+
// only way left to tell the client the document it received is not whole.
|
|
80
|
+
destroy(error?: mixed): mixed,
|
|
81
|
+
// The events a writer has to listen to rather than assume: `drain`, so a body
|
|
82
|
+
// is paced by what the socket will take, and `close`, so a client that hung
|
|
83
|
+
// up stops the producer instead of being written at. Named individually, like
|
|
84
|
+
// `stream.js`'s `NodeDestination`, so that a host missing one of them fails
|
|
85
|
+
// to compile rather than to serve.
|
|
86
|
+
on(event: string, listener: (...args: Array<mixed>) => mixed): mixed,
|
|
87
|
+
once(event: string, listener: (...args: Array<mixed>) => mixed): mixed,
|
|
88
|
+
off(event: string, listener: (...args: Array<mixed>) => mixed): mixed,
|
|
74
89
|
...
|
|
75
90
|
};
|
|
76
91
|
|
|
@@ -94,15 +109,57 @@ export type DocumentAssets = {|
|
|
|
94
109
|
|
|
95
110
|
/** What the project's server bundle exports; see `virtual:uf/server`. */
|
|
96
111
|
export type StandaloneApp = {|
|
|
112
|
+
/**
|
|
113
|
+
* Render `url`, resolving when the *shell* is ready.
|
|
114
|
+
*
|
|
115
|
+
* The same `{ status, headers?, pipe }` the router hands `uf start` and
|
|
116
|
+
* every adapter — not a finished string. A binary that collected the whole
|
|
117
|
+
* document before answering would be the one deployment target that does not
|
|
118
|
+
* stream, and the reason `renderToString` was replaced is that the wait is
|
|
119
|
+
* the slowest thing on the page.
|
|
120
|
+
*/
|
|
97
121
|
readonly render: (
|
|
98
122
|
url: string,
|
|
99
123
|
assets: DocumentAssets,
|
|
124
|
+
options?: {| readonly onError?: (error: mixed) => void |},
|
|
100
125
|
) => Promise<{|
|
|
101
126
|
readonly status: number,
|
|
102
|
-
readonly html: string,
|
|
103
127
|
readonly headers?: { readonly [string]: string },
|
|
128
|
+
// A promise, and not `void`: `DocumentBody.pipe` resolves on the last byte
|
|
129
|
+
// and rejects when the render fails after the shell. Typing it away was
|
|
130
|
+
// how the rejection below came to be dropped.
|
|
131
|
+
readonly pipe: (destination: NodeResponse) => Promise<void>,
|
|
132
|
+
readonly stream: () => ReadableStream<Uint8Array>,
|
|
104
133
|
|}>,
|
|
105
134
|
readonly dispatch: (request: Request) => Promise<Response | null>,
|
|
135
|
+
/**
|
|
136
|
+
* The guard on the path, run before anything under it answers.
|
|
137
|
+
*
|
|
138
|
+
* Called rather than tested for: a server bundle without it is a `TypeError`
|
|
139
|
+
* on the first request, not an application whose auth check quietly stopped
|
|
140
|
+
* running once it was compiled. See ubugeeei-prod/uf#260, and
|
|
141
|
+
* `@uniflowed/vite`'s `createApplicationHandler`, which says the same thing
|
|
142
|
+
* about `uf preview` and `uf start`.
|
|
143
|
+
*/
|
|
144
|
+
readonly runMiddleware: (request: Request) => Promise<Response | null>,
|
|
145
|
+
/**
|
|
146
|
+
* Begin the request everything above runs inside.
|
|
147
|
+
*
|
|
148
|
+
* From the application bundle rather than from this module's own import of
|
|
149
|
+
* `@uniflowed/server/host`, and that is not a stylistic choice: the request
|
|
150
|
+
* lives in an `AsyncLocalStorage` belonging to one module instance, and the
|
|
151
|
+
* instance that matters is the one the bundled router, middleware and pages
|
|
152
|
+
* resolved to. Beginning a request in a second storage would leave every
|
|
153
|
+
* `cookies()` in the application outside one, silently.
|
|
154
|
+
*
|
|
155
|
+
* `run` wraps everything that decides the response; `settle` is called after
|
|
156
|
+
* the last byte, which here is after `send`, after `sendBytes`, and after
|
|
157
|
+
* `pipe` resolves. See ubugeeei-prod/uf#389.
|
|
158
|
+
*/
|
|
159
|
+
readonly beginRequest: (request: Request) => {|
|
|
160
|
+
readonly run: <T>(body: () => Promise<T>) => Promise<T>,
|
|
161
|
+
readonly settle: () => Promise<void>,
|
|
162
|
+
|},
|
|
106
163
|
|};
|
|
107
164
|
|
|
108
165
|
/** Everything an application needs to answer a request, all of it built in. */
|
|
@@ -292,40 +349,114 @@ export function createHandler(
|
|
|
292
349
|
}
|
|
293
350
|
}
|
|
294
351
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
352
|
+
// The request begins here rather than at the top of the handler, and the
|
|
353
|
+
// two lookups above are why: an embedded chunk and a prerendered document
|
|
354
|
+
// are answered without any application code running at all, so there is
|
|
355
|
+
// nothing that could ask for cookies and nothing that could defer work.
|
|
356
|
+
// What is below is the application, and it is what a request is for.
|
|
357
|
+
//
|
|
358
|
+
// `app.beginRequest` and not this module's own import: the storage that
|
|
359
|
+
// holds a request belongs to one copy of `@uniflowed/server`, and the copy
|
|
360
|
+
// that matters is the one linked into the bundle beside this file.
|
|
361
|
+
//
|
|
362
|
+
// `settle` is in a `finally` and it is the last thing the handler does, so
|
|
363
|
+
// every `after()` runs after the response has been written — after `send`,
|
|
364
|
+
// after `sendBytes`, and after `pipe` resolves — which is what `after()`
|
|
365
|
+
// promises and what the other three hosts do. A request that failed is
|
|
366
|
+
// still a request that happened, so the drain is owed either way; see
|
|
367
|
+
// ubugeeei-prod/uf#389.
|
|
368
|
+
const asRequest = toRequest(request, url);
|
|
369
|
+
const { run, settle } = app.beginRequest(asRequest);
|
|
370
|
+
try {
|
|
371
|
+
await run(async () => {
|
|
372
|
+
// Middleware above the dispatcher and above the render, and below the two
|
|
373
|
+
// lookups on purpose. It guards a path, so it must run for a page, for a
|
|
374
|
+
// route handler, and for a path under it that matches neither — but an
|
|
375
|
+
// embedded asset and a prerendered document are answered before it, which
|
|
376
|
+
// is exactly what `uf preview` does, because Vite's file middleware runs
|
|
377
|
+
// before anything mounted behind it. The three front doors have to give
|
|
378
|
+
// one answer; that a prerendered page under a guard ships unguarded is
|
|
379
|
+
// true of all of them and is ubugeeei-prod/uf#342.
|
|
380
|
+
const guarded = await app.runMiddleware(asRequest);
|
|
381
|
+
if (guarded != null) {
|
|
382
|
+
await sendUnlessHead(response, method, guarded);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
300
385
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
// that one itself, with the methods that module really exports.
|
|
307
|
-
response.setHeader("allow", "GET, HEAD");
|
|
308
|
-
sendBytes(
|
|
309
|
-
response,
|
|
310
|
-
method,
|
|
311
|
-
405,
|
|
312
|
-
"text/plain; charset=utf-8",
|
|
313
|
-
DOCUMENT_CACHE_CONTROL,
|
|
314
|
-
Buffer.from("method not allowed\n"),
|
|
315
|
-
);
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
386
|
+
const handled = await app.dispatch(asRequest);
|
|
387
|
+
if (handled != null) {
|
|
388
|
+
await sendUnlessHead(response, method, handled);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
318
391
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
392
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
393
|
+
// A page supports exactly `GET` and `HEAD`, which is why the `Allow` the
|
|
394
|
+
// specification requires on every 405 can be written here even though
|
|
395
|
+
// this side of the handler knows nothing about methods. A *handler* path
|
|
396
|
+
// with the wrong method never reaches this line: the dispatcher answers
|
|
397
|
+
// that one itself, with the methods that module really exports.
|
|
398
|
+
response.setHeader("allow", "GET, HEAD");
|
|
399
|
+
sendBytes(
|
|
400
|
+
response,
|
|
401
|
+
method,
|
|
402
|
+
405,
|
|
403
|
+
"text/plain; charset=utf-8",
|
|
404
|
+
DOCUMENT_CACHE_CONTROL,
|
|
405
|
+
Buffer.from("method not allowed\n"),
|
|
406
|
+
);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const rendered = await app.render(url.pathname + url.search, document, {
|
|
411
|
+
// There is no terminal to render into: this is a binary somebody started
|
|
412
|
+
// with `./app`, possibly under a supervisor. The console is where a
|
|
413
|
+
// supervisor looks, and losing a boundary's exception entirely would be
|
|
414
|
+
// worse — it is the only trace a page that failed after its first byte
|
|
415
|
+
// leaves anywhere.
|
|
416
|
+
onError: (error) => {
|
|
417
|
+
console.error(error);
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
response.statusCode = rendered.status;
|
|
421
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
422
|
+
response.setHeader("cache-control", DOCUMENT_CACHE_CONTROL);
|
|
423
|
+
for (const name of Object.keys(rendered.headers ?? {})) {
|
|
424
|
+
response.setHeader(name, (rendered.headers ?? {})[name]);
|
|
425
|
+
}
|
|
426
|
+
// No `content-length`: the length is not known until the last byte, and
|
|
427
|
+
// waiting for it is the whole of what streaming is not. `HEAD` gets the
|
|
428
|
+
// status and the headers, and the stream is cancelled rather than dropped
|
|
429
|
+
// so the render behind it stops instead of filling its queue and waiting
|
|
430
|
+
// for a reader that is never coming.
|
|
431
|
+
if (method === "HEAD") {
|
|
432
|
+
await rendered.stream().cancel();
|
|
433
|
+
response.end();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
// Awaited, because `pipe` rejects: React hands a post-shell failure to the
|
|
437
|
+
// destination's `destroy(error)`, `ChunkQueue.fail` records it, and the
|
|
438
|
+
// generator `pipe` is iterating rethrows it. Called and dropped, that
|
|
439
|
+
// rejection escapes this handler — `serve`'s `handle(…).catch` has already
|
|
440
|
+
// resolved — and lands on the process, where `--unhandled-rejections=throw`
|
|
441
|
+
// is the default and a binary someone started with `./app` exits in the
|
|
442
|
+
// middle of a request that was otherwise recoverable.
|
|
443
|
+
//
|
|
444
|
+
// It cannot become a 500. The shell went out with its status and headers
|
|
445
|
+
// long before this, and `pipe`'s own `finally` has already called `end()`.
|
|
446
|
+
// What is left is to say so where a supervisor looks, and to drop the
|
|
447
|
+
// socket: a chunked response that is closed cleanly is a client being told
|
|
448
|
+
// a truncated document is the whole document, which is the failure this
|
|
449
|
+
// pull request is named after.
|
|
450
|
+
try {
|
|
451
|
+
await rendered.pipe(response);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
process.stderr.write(`uf: ${String(error?.stack ?? error)}\n`);
|
|
454
|
+
response.destroy(error);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
} finally {
|
|
458
|
+
await settle();
|
|
325
459
|
}
|
|
326
|
-
const html = Buffer.from(rendered.html, "utf8");
|
|
327
|
-
response.setHeader("content-length", String(html.byteLength));
|
|
328
|
-
response.end(method === "HEAD" ? undefined : html);
|
|
329
460
|
};
|
|
330
461
|
}
|
|
331
462
|
|
|
@@ -392,27 +523,41 @@ function toRequest(incoming: NodeRequest, url: URL): Request {
|
|
|
392
523
|
return new Request(url, init);
|
|
393
524
|
}
|
|
394
525
|
|
|
395
|
-
/**
|
|
396
|
-
async function
|
|
397
|
-
outgoing
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (
|
|
526
|
+
/** `send`, except that a `HEAD` gets the status and the headers and no body. */
|
|
527
|
+
async function sendUnlessHead(
|
|
528
|
+
outgoing: NodeResponse,
|
|
529
|
+
method: string,
|
|
530
|
+
result: Response,
|
|
531
|
+
): Promise<void> {
|
|
532
|
+
if (method === "HEAD") {
|
|
533
|
+
outgoing.statusCode = result.status;
|
|
534
|
+
for (const [name, value] of result.headers) {
|
|
535
|
+
outgoing.setHeader(name, value);
|
|
536
|
+
}
|
|
402
537
|
outgoing.end();
|
|
403
538
|
return;
|
|
404
539
|
}
|
|
405
|
-
|
|
406
|
-
// open-ended body is not read into memory first.
|
|
407
|
-
const reader = result.body.getReader();
|
|
408
|
-
for (;;) {
|
|
409
|
-
const { done, value } = await reader.read();
|
|
410
|
-
if (done) break;
|
|
411
|
-
outgoing.write(value);
|
|
412
|
-
}
|
|
413
|
-
outgoing.end();
|
|
540
|
+
await send(outgoing, result);
|
|
414
541
|
}
|
|
415
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Write a `Response` to a Node response, minding the socket.
|
|
545
|
+
*
|
|
546
|
+
* `send` was written a third time here, with a comment saying the three copies
|
|
547
|
+
* had to answer alike because "a binary that buffered where `uf start` paced
|
|
548
|
+
* would be the one deployment target whose memory profile nobody had
|
|
549
|
+
* measured". They did not stay alike — ubugeeei-prod/uf#400 is the copy in
|
|
550
|
+
* `@uniflowed/server`'s `node.js` losing the pacing while this one kept it.
|
|
551
|
+
*
|
|
552
|
+
* The reason not to share was that importing `@uniflowed/vite` into the
|
|
553
|
+
* artefact a deployment runs is the property `uf start` exists to establish.
|
|
554
|
+
* That reason is gone: the loop lives in `@uniflowed/server` now, which is the
|
|
555
|
+
* package this file is *in*.
|
|
556
|
+
*
|
|
557
|
+
* `HEAD` stays here, at the call site, because it is a decision about a
|
|
558
|
+
* request rather than about writing a body.
|
|
559
|
+
*/
|
|
560
|
+
|
|
416
561
|
/** Write one embedded file, with the length a client needs to reuse a socket. */
|
|
417
562
|
function sendBytes(
|
|
418
563
|
outgoing: NodeResponse,
|