@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/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
- const handled = await app.dispatch(toRequest(request, url));
296
- if (handled != null) {
297
- await send(response, method, handled);
298
- return;
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
- if (method !== "GET" && method !== "HEAD") {
302
- // A page supports exactly `GET` and `HEAD`, which is why the `Allow` the
303
- // specification requires on every 405 can be written here even though
304
- // this side of the handler knows nothing about methods. A *handler* path
305
- // with the wrong method never reaches this line: the dispatcher answers
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
- const rendered = await app.render(url.pathname + url.search, document);
320
- response.statusCode = rendered.status;
321
- response.setHeader("content-type", "text/html; charset=utf-8");
322
- response.setHeader("cache-control", DOCUMENT_CACHE_CONTROL);
323
- for (const name of Object.keys(rendered.headers ?? {})) {
324
- response.setHeader(name, (rendered.headers ?? {})[name]);
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
- /** Write a `Response` to a Node response. */
396
- async function send(outgoing: NodeResponse, method: string, result: Response): Promise<void> {
397
- outgoing.statusCode = result.status;
398
- for (const [name, value] of result.headers) {
399
- outgoing.setHeader(name, value);
400
- }
401
- if (result.body == null || method === "HEAD") {
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
- // Streamed rather than buffered, so a handler returning a large or
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,