@uniflowed/server 0.0.0-alpha.10
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/cache.js +341 -0
- package/edge.js +158 -0
- package/fetch.js +310 -0
- package/host.js +56 -0
- package/index.js +133 -0
- package/internal/application.js +90 -0
- package/internal/cache-key.js +79 -0
- package/internal/cache-store.js +524 -0
- package/internal/context.js +285 -0
- package/lambda.js +266 -0
- package/node.js +460 -0
- package/package.json +34 -0
- package/standalone.js +593 -0
package/standalone.js
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/server/standalone`: the application, serving itself, from one file.
|
|
4
|
+
//
|
|
5
|
+
// `uf build --compile` links this module with the project's server bundle and
|
|
6
|
+
// with an embedded copy of everything `uf build` wrote to `dist/`, then hands
|
|
7
|
+
// the result to a JavaScript runtime that appends itself to it. What comes out
|
|
8
|
+
// is a file that can be copied into an empty directory and run: no Node
|
|
9
|
+
// installation, no `node_modules`, no `dist/` beside it.
|
|
10
|
+
//
|
|
11
|
+
// # Why `node:http`, and not the host's own server
|
|
12
|
+
//
|
|
13
|
+
// Bun has `Bun.serve` and Deno has `Deno.serve`, and both are faster than the
|
|
14
|
+
// interface they emulate. Neither is a standard. Writing this file against
|
|
15
|
+
// either would make it a Bun file or a Deno file, and the runtime that gets
|
|
16
|
+
// embedded would stop being a decision `uf build --compile` makes and start
|
|
17
|
+
// being a decision this module already made. `node:http` is the one server
|
|
18
|
+
// interface all three hosts implement, so it is the one that leaves the choice
|
|
19
|
+
// open — which matters more here than the throughput of a shim that spends
|
|
20
|
+
// almost all of its time inside React.
|
|
21
|
+
//
|
|
22
|
+
// # Why this is not `@uniflowed/vite`'s `internal/serve.js`
|
|
23
|
+
//
|
|
24
|
+
// That module is the handler behind `uf preview` and `uf start`, and it is the
|
|
25
|
+
// obvious thing to import rather than write a second one. It is the wrong
|
|
26
|
+
// thing to import, for two reasons and either would be enough.
|
|
27
|
+
//
|
|
28
|
+
// It answers by opening files under `dist/`, and there is no `dist/` here —
|
|
29
|
+
// the whole claim of a compiled binary is that it was copied into an empty
|
|
30
|
+
// directory. Its static half is therefore not shareable at all, and its
|
|
31
|
+
// application half arrives attached to it. And it lives in `@uniflowed/vite`,
|
|
32
|
+
// so importing it would link the package named after the bundler into the
|
|
33
|
+
// artefact a deployment runs, which is the property `uf start` exists to
|
|
34
|
+
// establish and the one a single file makes strongest.
|
|
35
|
+
//
|
|
36
|
+
// So the code is not shared and the *answer* is. Both resolve a request in the
|
|
37
|
+
// same order — a file the build already wrote, then a route handler for any
|
|
38
|
+
// method, then a render for whatever is left — and that order is not a
|
|
39
|
+
// preference either module gets to hold: `uf preview` is Vite's own server,
|
|
40
|
+
// which runs its file middleware before anything uf mounts behind it, so
|
|
41
|
+
// `internal/serve.js` matches Vite and this matches `internal/serve.js`. A
|
|
42
|
+
// binary that resolved a page/handler collision the other way would behave
|
|
43
|
+
// one way when it was checked with `uf preview` and another way once it was
|
|
44
|
+
// deployed, which is the trap `uf preview` exists to prevent.
|
|
45
|
+
//
|
|
46
|
+
// Both copies are driven by a test: `serve.test.js` and
|
|
47
|
+
// `preview_and_start_serve_the_whole_of_a_build` for that one,
|
|
48
|
+
// `tests/library/standalone.test.js` and
|
|
49
|
+
// `compile_writes_one_file_that_serves_the_site_from_an_empty_directory` for
|
|
50
|
+
// this one.
|
|
51
|
+
//
|
|
52
|
+
// # The one thing this door does not have
|
|
53
|
+
//
|
|
54
|
+
// The route cache. `rendering.cache.route` reaches `createFetchHandler` —
|
|
55
|
+
// which is `uf preview`, `uf start`, and every `--adapter` target through the
|
|
56
|
+
// `handler.js` uf generates — and this module renders every request whatever
|
|
57
|
+
// the configuration says. It is the fourth front door and the only one where
|
|
58
|
+
// the caching is not a shared function but a second copy of the same
|
|
59
|
+
// buffering, the same refusal to store a render that read the request, and the
|
|
60
|
+
// same header, which is exactly the "two copies that agree until they do not"
|
|
61
|
+
// this file's header is otherwise about. It is stated here rather than left to
|
|
62
|
+
// be discovered, and ubugeeei-prod/uf#277 carries it.
|
|
63
|
+
|
|
64
|
+
import { Buffer } from "node:buffer";
|
|
65
|
+
import { createServer } from "node:http";
|
|
66
|
+
|
|
67
|
+
import { send } from "./node.js";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The pieces of a Node request and response this module touches.
|
|
71
|
+
*
|
|
72
|
+
* Declared structurally rather than imported from a `node:http` libdef,
|
|
73
|
+
* because the set is five members wide and naming it here is what lets the
|
|
74
|
+
* same file be read without knowing which host's types are in scope.
|
|
75
|
+
*/
|
|
76
|
+
type NodeRequest = {
|
|
77
|
+
readonly method?: string,
|
|
78
|
+
readonly url?: string,
|
|
79
|
+
readonly headers: { readonly [string]: string | Array<string> | void },
|
|
80
|
+
...
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type NodeResponse = {
|
|
84
|
+
statusCode: number,
|
|
85
|
+
setHeader(name: string, value: string): mixed,
|
|
86
|
+
write(chunk: Uint8Array | string): mixed,
|
|
87
|
+
end(chunk?: Uint8Array | string): mixed,
|
|
88
|
+
// Required rather than optional, because the one case it exists for is the
|
|
89
|
+
// one where nothing else will do: a render that fails after the shell has
|
|
90
|
+
// gone out cannot be answered with a status, and dropping the socket is the
|
|
91
|
+
// only way left to tell the client the document it received is not whole.
|
|
92
|
+
destroy(error?: mixed): mixed,
|
|
93
|
+
// The events a writer has to listen to rather than assume: `drain`, so a body
|
|
94
|
+
// is paced by what the socket will take, and `close`, so a client that hung
|
|
95
|
+
// up stops the producer instead of being written at. Named individually, like
|
|
96
|
+
// `stream.js`'s `NodeDestination`, so that a host missing one of them fails
|
|
97
|
+
// to compile rather than to serve.
|
|
98
|
+
on(event: string, listener: (...args: Array<mixed>) => mixed): mixed,
|
|
99
|
+
once(event: string, listener: (...args: Array<mixed>) => mixed): mixed,
|
|
100
|
+
off(event: string, listener: (...args: Array<mixed>) => mixed): mixed,
|
|
101
|
+
...
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** One file from `dist/`, as `uf build --compile` embedded it. */
|
|
105
|
+
export type EmbeddedAsset = {|
|
|
106
|
+
/** The `content-type` to serve it with, decided at build time. */
|
|
107
|
+
readonly type: string,
|
|
108
|
+
/** The file's bytes, base64. */
|
|
109
|
+
readonly body: string,
|
|
110
|
+
|};
|
|
111
|
+
|
|
112
|
+
/** Every embedded file, keyed by its path relative to the output directory. */
|
|
113
|
+
export type EmbeddedAssets = { readonly [path: string]: EmbeddedAsset };
|
|
114
|
+
|
|
115
|
+
/** The script, stylesheet and preload URLs a rendered document references. */
|
|
116
|
+
export type DocumentAssets = {|
|
|
117
|
+
readonly scripts: $ReadOnlyArray<string>,
|
|
118
|
+
readonly styles: $ReadOnlyArray<string>,
|
|
119
|
+
readonly preloads: $ReadOnlyArray<string>,
|
|
120
|
+
|};
|
|
121
|
+
|
|
122
|
+
/** What the project's server bundle exports; see `virtual:uf/server`. */
|
|
123
|
+
export type StandaloneApp = {|
|
|
124
|
+
/**
|
|
125
|
+
* Render `url`, resolving when the *shell* is ready.
|
|
126
|
+
*
|
|
127
|
+
* The same `{ status, headers?, pipe }` the router hands `uf start` and
|
|
128
|
+
* every adapter — not a finished string. A binary that collected the whole
|
|
129
|
+
* document before answering would be the one deployment target that does not
|
|
130
|
+
* stream, and the reason `renderToString` was replaced is that the wait is
|
|
131
|
+
* the slowest thing on the page.
|
|
132
|
+
*/
|
|
133
|
+
readonly render: (
|
|
134
|
+
url: string,
|
|
135
|
+
assets: DocumentAssets,
|
|
136
|
+
options?: {| readonly onError?: (error: mixed) => void |},
|
|
137
|
+
) => Promise<{|
|
|
138
|
+
readonly status: number,
|
|
139
|
+
readonly headers?: { readonly [string]: string },
|
|
140
|
+
// A promise, and not `void`: `DocumentBody.pipe` resolves on the last byte
|
|
141
|
+
// and rejects when the render fails after the shell. Typing it away was
|
|
142
|
+
// how the rejection below came to be dropped.
|
|
143
|
+
readonly pipe: (destination: NodeResponse) => Promise<void>,
|
|
144
|
+
readonly stream: () => ReadableStream<Uint8Array>,
|
|
145
|
+
|}>,
|
|
146
|
+
readonly dispatch: (request: Request) => Promise<Response | null>,
|
|
147
|
+
/**
|
|
148
|
+
* The guard on the path, run before anything under it answers.
|
|
149
|
+
*
|
|
150
|
+
* Called rather than tested for: a server bundle without it is a `TypeError`
|
|
151
|
+
* on the first request, not an application whose auth check quietly stopped
|
|
152
|
+
* running once it was compiled. See ubugeeei-prod/uf#260, and
|
|
153
|
+
* `@uniflowed/vite`'s `createApplicationHandler`, which says the same thing
|
|
154
|
+
* about `uf preview` and `uf start`.
|
|
155
|
+
*/
|
|
156
|
+
readonly runMiddleware: (request: Request) => Promise<Response | null>,
|
|
157
|
+
/**
|
|
158
|
+
* Begin the request everything above runs inside.
|
|
159
|
+
*
|
|
160
|
+
* From the application bundle rather than from this module's own import of
|
|
161
|
+
* `@uniflowed/server/host`, and that is not a stylistic choice: the request
|
|
162
|
+
* lives in an `AsyncLocalStorage` belonging to one module instance, and the
|
|
163
|
+
* instance that matters is the one the bundled router, middleware and pages
|
|
164
|
+
* resolved to. Beginning a request in a second storage would leave every
|
|
165
|
+
* `cookies()` in the application outside one, silently.
|
|
166
|
+
*
|
|
167
|
+
* `run` wraps everything that decides the response; `settle` is called after
|
|
168
|
+
* the last byte, which here is after `send`, after `sendBytes`, and after
|
|
169
|
+
* `pipe` resolves. See ubugeeei-prod/uf#389.
|
|
170
|
+
*/
|
|
171
|
+
readonly beginRequest: (request: Request) => {|
|
|
172
|
+
readonly run: <T>(body: () => Promise<T>) => Promise<T>,
|
|
173
|
+
readonly settle: () => Promise<void>,
|
|
174
|
+
|},
|
|
175
|
+
|};
|
|
176
|
+
|
|
177
|
+
/** Everything an application needs to answer a request, all of it built in. */
|
|
178
|
+
export type HandlerOptions = {|
|
|
179
|
+
readonly app: StandaloneApp,
|
|
180
|
+
readonly assets: EmbeddedAssets,
|
|
181
|
+
readonly document: DocumentAssets,
|
|
182
|
+
|};
|
|
183
|
+
|
|
184
|
+
/** What [`serve`] needs: the above, and where to listen. */
|
|
185
|
+
export type ServeOptions = {|
|
|
186
|
+
readonly app: StandaloneApp,
|
|
187
|
+
readonly assets: EmbeddedAssets,
|
|
188
|
+
readonly document: DocumentAssets,
|
|
189
|
+
/** Overridden by `--port` and then by `PORT`; defaults to 3000. */
|
|
190
|
+
readonly port?: number,
|
|
191
|
+
/** Overridden by `--host` and then by `HOST`; defaults to loopback. */
|
|
192
|
+
readonly host?: string,
|
|
193
|
+
|};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* How long a browser may keep a file that is not a document.
|
|
197
|
+
*
|
|
198
|
+
* One hour, uniformly, and deliberately not the year-long `immutable` a
|
|
199
|
+
* content-hashed chunk could take: this module cannot tell a hashed chunk from
|
|
200
|
+
* an unhashed file copied out of `public/`, because `dist/` records no such
|
|
201
|
+
* distinction, and getting it wrong in the `immutable` direction pins a stale
|
|
202
|
+
* favicon in every visitor's cache with no way to recall it. A binary behind a
|
|
203
|
+
* CDN should let the CDN decide; an hour is the safe answer for one that is not.
|
|
204
|
+
*/
|
|
205
|
+
const ASSET_CACHE_CONTROL = "public, max-age=3600";
|
|
206
|
+
|
|
207
|
+
/** Documents are revalidated every time, because a deploy replaces them. */
|
|
208
|
+
const DOCUMENT_CACHE_CONTROL = "no-cache";
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Serve the application until the process is stopped.
|
|
212
|
+
*
|
|
213
|
+
* Resolves once the socket is listening, with the address it took — a caller
|
|
214
|
+
* that asked for port 0 has no other way to learn which port it got, and the
|
|
215
|
+
* test that drives a compiled binary needs exactly that.
|
|
216
|
+
*/
|
|
217
|
+
export async function serve(options: ServeOptions): Promise<{|
|
|
218
|
+
readonly host: string,
|
|
219
|
+
readonly port: number,
|
|
220
|
+
readonly close: () => Promise<void>,
|
|
221
|
+
|}> {
|
|
222
|
+
const handle = createHandler(options);
|
|
223
|
+
|
|
224
|
+
// Said before the socket, not after it, and that ordering is the point. It
|
|
225
|
+
// is the one fact about a compiled binary that cannot be checked from
|
|
226
|
+
// outside it — whether `dist/` really came along — and a count printed only
|
|
227
|
+
// on a successful bind is a count nobody can see on a machine that is not
|
|
228
|
+
// allowed to bind. `compile_writes_one_file_that_carries_the_whole_site`
|
|
229
|
+
// reads this line and nothing else.
|
|
230
|
+
process.stdout.write(`uf: ${String(Object.keys(options.assets).length)} embedded files\n`);
|
|
231
|
+
|
|
232
|
+
const server = createServer((request: NodeRequest, response: NodeResponse) => {
|
|
233
|
+
handle(request, response).catch((error: mixed) => {
|
|
234
|
+
// A request that throws is this server's last chance to say so: there is
|
|
235
|
+
// no framework above it and no log drain beside it. Report it on stderr
|
|
236
|
+
// and answer 500, rather than letting the host's unhandled-rejection
|
|
237
|
+
// policy decide whether the process survives.
|
|
238
|
+
process.stderr.write(`uf: ${String(error?.stack ?? error)}\n`);
|
|
239
|
+
try {
|
|
240
|
+
response.statusCode = 500;
|
|
241
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
242
|
+
response.end("internal server error\n");
|
|
243
|
+
} catch {
|
|
244
|
+
// The response was already partly written; nothing left to say.
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const host = options.host ?? argument("--host") ?? process.env.HOST ?? "127.0.0.1";
|
|
250
|
+
const port = options.port ?? Number(argument("--port") ?? process.env.PORT ?? 3000);
|
|
251
|
+
|
|
252
|
+
await new Promise((resolve, reject) => {
|
|
253
|
+
server.once("error", reject);
|
|
254
|
+
server.listen(port, host, resolve);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const address = server.address();
|
|
258
|
+
const bound = typeof address === "object" && address != null ? address.port : port;
|
|
259
|
+
process.stdout.write(`uf: listening on http://${host}:${String(bound)}\n`);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
host,
|
|
263
|
+
port: bound,
|
|
264
|
+
close: () =>
|
|
265
|
+
new Promise((resolve) => {
|
|
266
|
+
server.close(() => resolve());
|
|
267
|
+
}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The embedded files, as a map.
|
|
273
|
+
*
|
|
274
|
+
* A `Map` rather than the generated object itself, because a lookup keyed by a
|
|
275
|
+
* request path must not be able to find `__proto__` or `constructor`. Building
|
|
276
|
+
* it costs one pass over a few hundred entries at startup and removes the
|
|
277
|
+
* question entirely.
|
|
278
|
+
*
|
|
279
|
+
* The bytes are decoded lazily and then kept: a build that embeds a hundred
|
|
280
|
+
* megabytes of sourcemaps should not spend the startup decoding the ones this
|
|
281
|
+
* process will never be asked for.
|
|
282
|
+
*/
|
|
283
|
+
function index(assets: EmbeddedAssets): Map<string, {| +type: string, +bytes: () => Buffer |}> {
|
|
284
|
+
const files = new Map();
|
|
285
|
+
for (const path of Object.keys(assets)) {
|
|
286
|
+
const asset = assets[path];
|
|
287
|
+
let decoded: Buffer | null = null;
|
|
288
|
+
files.set(path, {
|
|
289
|
+
type: asset.type,
|
|
290
|
+
bytes: () => {
|
|
291
|
+
if (decoded == null) {
|
|
292
|
+
// `Buffer.from` rather than `atob`: `atob` answers with a string of
|
|
293
|
+
// char codes, and turning megabytes of that into bytes is a loop in
|
|
294
|
+
// JavaScript. Every host that has `node:http` has `node:buffer`.
|
|
295
|
+
decoded = Buffer.from(asset.body, "base64");
|
|
296
|
+
}
|
|
297
|
+
return decoded;
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return files;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* One request, answered — exported so an application can be mounted rather
|
|
306
|
+
* than only run.
|
|
307
|
+
*
|
|
308
|
+
* `serve` is the whole of a compiled binary, and it is not the whole of what
|
|
309
|
+
* anybody wants: a uf application behind an existing Node server, or beside
|
|
310
|
+
* other routes in one process, needs the request handling without the socket.
|
|
311
|
+
* That is this. It is also what the tests drive, which is not a coincidence —
|
|
312
|
+
* a request handler that can only be reached through a listening socket is one
|
|
313
|
+
* that can only be tested on a machine allowed to bind one.
|
|
314
|
+
*
|
|
315
|
+
* The order is `internal/serve.js`'s, which is Vite's:
|
|
316
|
+
*
|
|
317
|
+
* 1. a file `uf build` already wrote, for `GET` and `HEAD` only — an
|
|
318
|
+
* embedded path that matches exactly, such as `/assets/index-a1b2c3.js`
|
|
319
|
+
* or anything copied out of `public/`, and then the prerendered document
|
|
320
|
+
* for this URL, because `/guide` was written as `guide/index.html`;
|
|
321
|
+
* 2. a route handler, for any method, because a handler is the only thing
|
|
322
|
+
* that answers a `POST` and may also answer a `GET` for a path with no
|
|
323
|
+
* page;
|
|
324
|
+
* 3. and otherwise the renderer, which also produces the 404.
|
|
325
|
+
*
|
|
326
|
+
* The prerendered document is looked up *before* the dispatcher, and that is
|
|
327
|
+
* the one place this used to disagree with `uf preview` and `uf start`. The
|
|
328
|
+
* router allows a handler to sit beside a page in the same directory, so a
|
|
329
|
+
* path can have both — and Vite's preview server serves the file first with no
|
|
330
|
+
* say in the matter, so a binary that let the handler win would answer
|
|
331
|
+
* differently from the command a build is checked with. Answering the same
|
|
332
|
+
* wrong-looking way as the other two is worth more than answering a better way
|
|
333
|
+
* alone.
|
|
334
|
+
*
|
|
335
|
+
* A page never answers a `POST`: letting one try turns a missing handler into
|
|
336
|
+
* a rendered page with a 200 where the caller expected a 405.
|
|
337
|
+
*/
|
|
338
|
+
export function createHandler(
|
|
339
|
+
options: HandlerOptions,
|
|
340
|
+
): (NodeRequest, NodeResponse) => Promise<void> {
|
|
341
|
+
const { app, document } = options;
|
|
342
|
+
const files = index(options.assets);
|
|
343
|
+
|
|
344
|
+
return async function handle(request: NodeRequest, response: NodeResponse): Promise<void> {
|
|
345
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
346
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
347
|
+
const pathname = decodePath(url.pathname);
|
|
348
|
+
|
|
349
|
+
if (pathname != null && (method === "GET" || method === "HEAD")) {
|
|
350
|
+
const file = files.get(assetKey(pathname));
|
|
351
|
+
if (file != null) {
|
|
352
|
+
sendBytes(response, method, 200, file.type, ASSET_CACHE_CONTROL, file.bytes());
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// A document is revalidated where an asset is cached, because a deploy
|
|
356
|
+
// replaces documents and gives assets a new hashed name.
|
|
357
|
+
const page = files.get(documentKey(pathname));
|
|
358
|
+
if (page != null) {
|
|
359
|
+
sendBytes(response, method, 200, page.type, DOCUMENT_CACHE_CONTROL, page.bytes());
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The request begins here rather than at the top of the handler, and the
|
|
365
|
+
// two lookups above are why: an embedded chunk and a prerendered document
|
|
366
|
+
// are answered without any application code running at all, so there is
|
|
367
|
+
// nothing that could ask for cookies and nothing that could defer work.
|
|
368
|
+
// What is below is the application, and it is what a request is for.
|
|
369
|
+
//
|
|
370
|
+
// `app.beginRequest` and not this module's own import: the storage that
|
|
371
|
+
// holds a request belongs to one copy of `@uniflowed/server`, and the copy
|
|
372
|
+
// that matters is the one linked into the bundle beside this file.
|
|
373
|
+
//
|
|
374
|
+
// `settle` is in a `finally` and it is the last thing the handler does, so
|
|
375
|
+
// every `after()` runs after the response has been written — after `send`,
|
|
376
|
+
// after `sendBytes`, and after `pipe` resolves — which is what `after()`
|
|
377
|
+
// promises and what the other three hosts do. A request that failed is
|
|
378
|
+
// still a request that happened, so the drain is owed either way; see
|
|
379
|
+
// ubugeeei-prod/uf#389.
|
|
380
|
+
const asRequest = toRequest(request, url);
|
|
381
|
+
const { run, settle } = app.beginRequest(asRequest);
|
|
382
|
+
try {
|
|
383
|
+
await run(async () => {
|
|
384
|
+
// Middleware above the dispatcher and above the render, and below the two
|
|
385
|
+
// lookups on purpose. It guards a path, so it must run for a page, for a
|
|
386
|
+
// route handler, and for a path under it that matches neither — but an
|
|
387
|
+
// embedded asset and a prerendered document are answered before it, which
|
|
388
|
+
// is exactly what `uf preview` does, because Vite's file middleware runs
|
|
389
|
+
// before anything mounted behind it. The three front doors have to give
|
|
390
|
+
// one answer; that a prerendered page under a guard ships unguarded is
|
|
391
|
+
// true of all of them and is ubugeeei-prod/uf#342.
|
|
392
|
+
const guarded = await app.runMiddleware(asRequest);
|
|
393
|
+
if (guarded != null) {
|
|
394
|
+
await sendUnlessHead(response, method, guarded);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const handled = await app.dispatch(asRequest);
|
|
399
|
+
if (handled != null) {
|
|
400
|
+
await sendUnlessHead(response, method, handled);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
405
|
+
// A page supports exactly `GET` and `HEAD`, which is why the `Allow` the
|
|
406
|
+
// specification requires on every 405 can be written here even though
|
|
407
|
+
// this side of the handler knows nothing about methods. A *handler* path
|
|
408
|
+
// with the wrong method never reaches this line: the dispatcher answers
|
|
409
|
+
// that one itself, with the methods that module really exports.
|
|
410
|
+
response.setHeader("allow", "GET, HEAD");
|
|
411
|
+
sendBytes(
|
|
412
|
+
response,
|
|
413
|
+
method,
|
|
414
|
+
405,
|
|
415
|
+
"text/plain; charset=utf-8",
|
|
416
|
+
DOCUMENT_CACHE_CONTROL,
|
|
417
|
+
Buffer.from("method not allowed\n"),
|
|
418
|
+
);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const rendered = await app.render(url.pathname + url.search, document, {
|
|
423
|
+
// There is no terminal to render into: this is a binary somebody started
|
|
424
|
+
// with `./app`, possibly under a supervisor. The console is where a
|
|
425
|
+
// supervisor looks, and losing a boundary's exception entirely would be
|
|
426
|
+
// worse — it is the only trace a page that failed after its first byte
|
|
427
|
+
// leaves anywhere.
|
|
428
|
+
onError: (error) => {
|
|
429
|
+
console.error(error);
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
response.statusCode = rendered.status;
|
|
433
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
434
|
+
response.setHeader("cache-control", DOCUMENT_CACHE_CONTROL);
|
|
435
|
+
for (const name of Object.keys(rendered.headers ?? {})) {
|
|
436
|
+
response.setHeader(name, (rendered.headers ?? {})[name]);
|
|
437
|
+
}
|
|
438
|
+
// No `content-length`: the length is not known until the last byte, and
|
|
439
|
+
// waiting for it is the whole of what streaming is not. `HEAD` gets the
|
|
440
|
+
// status and the headers, and the stream is cancelled rather than dropped
|
|
441
|
+
// so the render behind it stops instead of filling its queue and waiting
|
|
442
|
+
// for a reader that is never coming.
|
|
443
|
+
if (method === "HEAD") {
|
|
444
|
+
await rendered.stream().cancel();
|
|
445
|
+
response.end();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
// Awaited, because `pipe` rejects: React hands a post-shell failure to the
|
|
449
|
+
// destination's `destroy(error)`, `ChunkQueue.fail` records it, and the
|
|
450
|
+
// generator `pipe` is iterating rethrows it. Called and dropped, that
|
|
451
|
+
// rejection escapes this handler — `serve`'s `handle(…).catch` has already
|
|
452
|
+
// resolved — and lands on the process, where `--unhandled-rejections=throw`
|
|
453
|
+
// is the default and a binary someone started with `./app` exits in the
|
|
454
|
+
// middle of a request that was otherwise recoverable.
|
|
455
|
+
//
|
|
456
|
+
// It cannot become a 500. The shell went out with its status and headers
|
|
457
|
+
// long before this, and `pipe`'s own `finally` has already called `end()`.
|
|
458
|
+
// What is left is to say so where a supervisor looks, and to drop the
|
|
459
|
+
// socket: a chunked response that is closed cleanly is a client being told
|
|
460
|
+
// a truncated document is the whole document, which is the failure this
|
|
461
|
+
// pull request is named after.
|
|
462
|
+
try {
|
|
463
|
+
await rendered.pipe(response);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
process.stderr.write(`uf: ${String(error?.stack ?? error)}\n`);
|
|
466
|
+
response.destroy(error);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
} finally {
|
|
470
|
+
await settle();
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* A request path as an embedded key, or `null` when it cannot be one.
|
|
477
|
+
*
|
|
478
|
+
* Percent-decoding happens here rather than at the lookup, because a path that
|
|
479
|
+
* does not decode is a malformed request and not a missing file.
|
|
480
|
+
*/
|
|
481
|
+
function decodePath(pathname: string): string | null {
|
|
482
|
+
try {
|
|
483
|
+
return decodeURIComponent(pathname);
|
|
484
|
+
} catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** `/assets/x.js` is the embedded `assets/x.js`. */
|
|
490
|
+
function assetKey(pathname: string): string {
|
|
491
|
+
return pathname.replace(/^\/+/, "");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The prerendered document for a URL.
|
|
496
|
+
*
|
|
497
|
+
* `uf build` writes `/guide` as `guide/index.html`, and `/` as `index.html`,
|
|
498
|
+
* so both spellings of a directory URL find the same file.
|
|
499
|
+
*/
|
|
500
|
+
function documentKey(pathname: string): string {
|
|
501
|
+
const key = assetKey(pathname).replace(/\/+$/, "");
|
|
502
|
+
return key === "" ? "index.html" : `${key}/index.html`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* A Node request as a `Request`.
|
|
507
|
+
*
|
|
508
|
+
* The body is passed as a stream where the host allows it, so a handler that
|
|
509
|
+
* accepts an upload does not need the whole thing buffered before it starts.
|
|
510
|
+
* `duplex` is required by the specification whenever a body is a stream, and
|
|
511
|
+
* Node throws without it.
|
|
512
|
+
*/
|
|
513
|
+
function toRequest(incoming: NodeRequest, url: URL): Request {
|
|
514
|
+
const headers = new Headers();
|
|
515
|
+
for (const name of Object.keys(incoming.headers)) {
|
|
516
|
+
const value = incoming.headers[name];
|
|
517
|
+
if (value == null) continue;
|
|
518
|
+
for (const entry of Array.isArray(value) ? value : [value]) {
|
|
519
|
+
headers.append(name, entry);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const host = headers.get("host");
|
|
524
|
+
if (host != null && host !== "") {
|
|
525
|
+
url.host = host;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const method = (incoming.method ?? "GET").toUpperCase();
|
|
529
|
+
const init: { [string]: mixed } = { method, headers };
|
|
530
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
531
|
+
init.body = incoming;
|
|
532
|
+
init.duplex = "half";
|
|
533
|
+
}
|
|
534
|
+
// $FlowFixMe[incompatible-call] - `incoming` is a stream, which `Request` accepts.
|
|
535
|
+
return new Request(url, init);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** `send`, except that a `HEAD` gets the status and the headers and no body. */
|
|
539
|
+
async function sendUnlessHead(
|
|
540
|
+
outgoing: NodeResponse,
|
|
541
|
+
method: string,
|
|
542
|
+
result: Response,
|
|
543
|
+
): Promise<void> {
|
|
544
|
+
if (method === "HEAD") {
|
|
545
|
+
outgoing.statusCode = result.status;
|
|
546
|
+
for (const [name, value] of result.headers) {
|
|
547
|
+
outgoing.setHeader(name, value);
|
|
548
|
+
}
|
|
549
|
+
outgoing.end();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
await send(outgoing, result);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Write a `Response` to a Node response, minding the socket.
|
|
557
|
+
*
|
|
558
|
+
* `send` was written a third time here, with a comment saying the three copies
|
|
559
|
+
* had to answer alike because "a binary that buffered where `uf start` paced
|
|
560
|
+
* would be the one deployment target whose memory profile nobody had
|
|
561
|
+
* measured". They did not stay alike — ubugeeei-prod/uf#400 is the copy in
|
|
562
|
+
* `@uniflowed/server`'s `node.js` losing the pacing while this one kept it.
|
|
563
|
+
*
|
|
564
|
+
* The reason not to share was that importing `@uniflowed/vite` into the
|
|
565
|
+
* artefact a deployment runs is the property `uf start` exists to establish.
|
|
566
|
+
* That reason is gone: the loop lives in `@uniflowed/server` now, which is the
|
|
567
|
+
* package this file is *in*.
|
|
568
|
+
*
|
|
569
|
+
* `HEAD` stays here, at the call site, because it is a decision about a
|
|
570
|
+
* request rather than about writing a body.
|
|
571
|
+
*/
|
|
572
|
+
|
|
573
|
+
/** Write one embedded file, with the length a client needs to reuse a socket. */
|
|
574
|
+
function sendBytes(
|
|
575
|
+
outgoing: NodeResponse,
|
|
576
|
+
method: string,
|
|
577
|
+
status: number,
|
|
578
|
+
type: string,
|
|
579
|
+
cacheControl: string,
|
|
580
|
+
bytes: Buffer,
|
|
581
|
+
): void {
|
|
582
|
+
outgoing.statusCode = status;
|
|
583
|
+
outgoing.setHeader("content-type", type);
|
|
584
|
+
outgoing.setHeader("cache-control", cacheControl);
|
|
585
|
+
outgoing.setHeader("content-length", String(bytes.byteLength));
|
|
586
|
+
outgoing.end(method === "HEAD" ? undefined : bytes);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** The value of a `--flag value` pair on the command line, if it is there. */
|
|
590
|
+
function argument(name: string): string | null {
|
|
591
|
+
const at = process.argv.indexOf(name);
|
|
592
|
+
return at === -1 ? null : (process.argv[at + 1] ?? null);
|
|
593
|
+
}
|