@uniflowed/server 0.0.0-alpha.7 → 0.0.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/edge.js +158 -0
- 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/lambda.js +266 -0
- package/node.js +460 -0
- package/package.json +9 -1
- package/standalone.js +193 -48
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,
|