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

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.
Files changed (2) hide show
  1. package/package.json +4 -2
  2. package/standalone.js +436 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/server",
3
- "version": "0.0.0-alpha.6",
3
+ "version": "0.0.0-alpha.7",
4
4
  "description": "Request-scoped server functions for the Unified Toolchain for Flow (React).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,11 +12,13 @@
12
12
  },
13
13
  "exports": {
14
14
  ".": "./index.js",
15
- "./host": "./host.js"
15
+ "./host": "./host.js",
16
+ "./standalone": "./standalone.js"
16
17
  },
17
18
  "files": [
18
19
  "index.js",
19
20
  "host.js",
21
+ "standalone.js",
20
22
  "internal/*.js"
21
23
  ]
22
24
  }
package/standalone.js ADDED
@@ -0,0 +1,436 @@
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
+ import { Buffer } from "node:buffer";
53
+ import { createServer } from "node:http";
54
+
55
+ /**
56
+ * The pieces of a Node request and response this module touches.
57
+ *
58
+ * Declared structurally rather than imported from a `node:http` libdef,
59
+ * because the set is five members wide and naming it here is what lets the
60
+ * same file be read without knowing which host's types are in scope.
61
+ */
62
+ type NodeRequest = {
63
+ readonly method?: string,
64
+ readonly url?: string,
65
+ readonly headers: { readonly [string]: string | Array<string> | void },
66
+ ...
67
+ };
68
+
69
+ type NodeResponse = {
70
+ statusCode: number,
71
+ setHeader(name: string, value: string): mixed,
72
+ write(chunk: Uint8Array | string): mixed,
73
+ end(chunk?: Uint8Array | string): mixed,
74
+ ...
75
+ };
76
+
77
+ /** One file from `dist/`, as `uf build --compile` embedded it. */
78
+ export type EmbeddedAsset = {|
79
+ /** The `content-type` to serve it with, decided at build time. */
80
+ readonly type: string,
81
+ /** The file's bytes, base64. */
82
+ readonly body: string,
83
+ |};
84
+
85
+ /** Every embedded file, keyed by its path relative to the output directory. */
86
+ export type EmbeddedAssets = { readonly [path: string]: EmbeddedAsset };
87
+
88
+ /** The script, stylesheet and preload URLs a rendered document references. */
89
+ export type DocumentAssets = {|
90
+ readonly scripts: $ReadOnlyArray<string>,
91
+ readonly styles: $ReadOnlyArray<string>,
92
+ readonly preloads: $ReadOnlyArray<string>,
93
+ |};
94
+
95
+ /** What the project's server bundle exports; see `virtual:uf/server`. */
96
+ export type StandaloneApp = {|
97
+ readonly render: (
98
+ url: string,
99
+ assets: DocumentAssets,
100
+ ) => Promise<{|
101
+ readonly status: number,
102
+ readonly html: string,
103
+ readonly headers?: { readonly [string]: string },
104
+ |}>,
105
+ readonly dispatch: (request: Request) => Promise<Response | null>,
106
+ |};
107
+
108
+ /** Everything an application needs to answer a request, all of it built in. */
109
+ export type HandlerOptions = {|
110
+ readonly app: StandaloneApp,
111
+ readonly assets: EmbeddedAssets,
112
+ readonly document: DocumentAssets,
113
+ |};
114
+
115
+ /** What [`serve`] needs: the above, and where to listen. */
116
+ export type ServeOptions = {|
117
+ readonly app: StandaloneApp,
118
+ readonly assets: EmbeddedAssets,
119
+ readonly document: DocumentAssets,
120
+ /** Overridden by `--port` and then by `PORT`; defaults to 3000. */
121
+ readonly port?: number,
122
+ /** Overridden by `--host` and then by `HOST`; defaults to loopback. */
123
+ readonly host?: string,
124
+ |};
125
+
126
+ /**
127
+ * How long a browser may keep a file that is not a document.
128
+ *
129
+ * One hour, uniformly, and deliberately not the year-long `immutable` a
130
+ * content-hashed chunk could take: this module cannot tell a hashed chunk from
131
+ * an unhashed file copied out of `public/`, because `dist/` records no such
132
+ * distinction, and getting it wrong in the `immutable` direction pins a stale
133
+ * favicon in every visitor's cache with no way to recall it. A binary behind a
134
+ * CDN should let the CDN decide; an hour is the safe answer for one that is not.
135
+ */
136
+ const ASSET_CACHE_CONTROL = "public, max-age=3600";
137
+
138
+ /** Documents are revalidated every time, because a deploy replaces them. */
139
+ const DOCUMENT_CACHE_CONTROL = "no-cache";
140
+
141
+ /**
142
+ * Serve the application until the process is stopped.
143
+ *
144
+ * Resolves once the socket is listening, with the address it took — a caller
145
+ * that asked for port 0 has no other way to learn which port it got, and the
146
+ * test that drives a compiled binary needs exactly that.
147
+ */
148
+ export async function serve(options: ServeOptions): Promise<{|
149
+ readonly host: string,
150
+ readonly port: number,
151
+ readonly close: () => Promise<void>,
152
+ |}> {
153
+ const handle = createHandler(options);
154
+
155
+ // Said before the socket, not after it, and that ordering is the point. It
156
+ // is the one fact about a compiled binary that cannot be checked from
157
+ // outside it — whether `dist/` really came along — and a count printed only
158
+ // on a successful bind is a count nobody can see on a machine that is not
159
+ // allowed to bind. `compile_writes_one_file_that_carries_the_whole_site`
160
+ // reads this line and nothing else.
161
+ process.stdout.write(`uf: ${String(Object.keys(options.assets).length)} embedded files\n`);
162
+
163
+ const server = createServer((request: NodeRequest, response: NodeResponse) => {
164
+ handle(request, response).catch((error: mixed) => {
165
+ // A request that throws is this server's last chance to say so: there is
166
+ // no framework above it and no log drain beside it. Report it on stderr
167
+ // and answer 500, rather than letting the host's unhandled-rejection
168
+ // policy decide whether the process survives.
169
+ process.stderr.write(`uf: ${String(error?.stack ?? error)}\n`);
170
+ try {
171
+ response.statusCode = 500;
172
+ response.setHeader("content-type", "text/plain; charset=utf-8");
173
+ response.end("internal server error\n");
174
+ } catch {
175
+ // The response was already partly written; nothing left to say.
176
+ }
177
+ });
178
+ });
179
+
180
+ const host = options.host ?? argument("--host") ?? process.env.HOST ?? "127.0.0.1";
181
+ const port = options.port ?? Number(argument("--port") ?? process.env.PORT ?? 3000);
182
+
183
+ await new Promise((resolve, reject) => {
184
+ server.once("error", reject);
185
+ server.listen(port, host, resolve);
186
+ });
187
+
188
+ const address = server.address();
189
+ const bound = typeof address === "object" && address != null ? address.port : port;
190
+ process.stdout.write(`uf: listening on http://${host}:${String(bound)}\n`);
191
+
192
+ return {
193
+ host,
194
+ port: bound,
195
+ close: () =>
196
+ new Promise((resolve) => {
197
+ server.close(() => resolve());
198
+ }),
199
+ };
200
+ }
201
+
202
+ /**
203
+ * The embedded files, as a map.
204
+ *
205
+ * A `Map` rather than the generated object itself, because a lookup keyed by a
206
+ * request path must not be able to find `__proto__` or `constructor`. Building
207
+ * it costs one pass over a few hundred entries at startup and removes the
208
+ * question entirely.
209
+ *
210
+ * The bytes are decoded lazily and then kept: a build that embeds a hundred
211
+ * megabytes of sourcemaps should not spend the startup decoding the ones this
212
+ * process will never be asked for.
213
+ */
214
+ function index(assets: EmbeddedAssets): Map<string, {| +type: string, +bytes: () => Buffer |}> {
215
+ const files = new Map();
216
+ for (const path of Object.keys(assets)) {
217
+ const asset = assets[path];
218
+ let decoded: Buffer | null = null;
219
+ files.set(path, {
220
+ type: asset.type,
221
+ bytes: () => {
222
+ if (decoded == null) {
223
+ // `Buffer.from` rather than `atob`: `atob` answers with a string of
224
+ // char codes, and turning megabytes of that into bytes is a loop in
225
+ // JavaScript. Every host that has `node:http` has `node:buffer`.
226
+ decoded = Buffer.from(asset.body, "base64");
227
+ }
228
+ return decoded;
229
+ },
230
+ });
231
+ }
232
+ return files;
233
+ }
234
+
235
+ /**
236
+ * One request, answered — exported so an application can be mounted rather
237
+ * than only run.
238
+ *
239
+ * `serve` is the whole of a compiled binary, and it is not the whole of what
240
+ * anybody wants: a uf application behind an existing Node server, or beside
241
+ * other routes in one process, needs the request handling without the socket.
242
+ * That is this. It is also what the tests drive, which is not a coincidence —
243
+ * a request handler that can only be reached through a listening socket is one
244
+ * that can only be tested on a machine allowed to bind one.
245
+ *
246
+ * The order is `internal/serve.js`'s, which is Vite's:
247
+ *
248
+ * 1. a file `uf build` already wrote, for `GET` and `HEAD` only — an
249
+ * embedded path that matches exactly, such as `/assets/index-a1b2c3.js`
250
+ * or anything copied out of `public/`, and then the prerendered document
251
+ * for this URL, because `/guide` was written as `guide/index.html`;
252
+ * 2. a route handler, for any method, because a handler is the only thing
253
+ * that answers a `POST` and may also answer a `GET` for a path with no
254
+ * page;
255
+ * 3. and otherwise the renderer, which also produces the 404.
256
+ *
257
+ * The prerendered document is looked up *before* the dispatcher, and that is
258
+ * the one place this used to disagree with `uf preview` and `uf start`. The
259
+ * router allows a handler to sit beside a page in the same directory, so a
260
+ * path can have both — and Vite's preview server serves the file first with no
261
+ * say in the matter, so a binary that let the handler win would answer
262
+ * differently from the command a build is checked with. Answering the same
263
+ * wrong-looking way as the other two is worth more than answering a better way
264
+ * alone.
265
+ *
266
+ * A page never answers a `POST`: letting one try turns a missing handler into
267
+ * a rendered page with a 200 where the caller expected a 405.
268
+ */
269
+ export function createHandler(
270
+ options: HandlerOptions,
271
+ ): (NodeRequest, NodeResponse) => Promise<void> {
272
+ const { app, document } = options;
273
+ const files = index(options.assets);
274
+
275
+ return async function handle(request: NodeRequest, response: NodeResponse): Promise<void> {
276
+ const method = (request.method ?? "GET").toUpperCase();
277
+ const url = new URL(request.url ?? "/", "http://localhost");
278
+ const pathname = decodePath(url.pathname);
279
+
280
+ if (pathname != null && (method === "GET" || method === "HEAD")) {
281
+ const file = files.get(assetKey(pathname));
282
+ if (file != null) {
283
+ sendBytes(response, method, 200, file.type, ASSET_CACHE_CONTROL, file.bytes());
284
+ return;
285
+ }
286
+ // A document is revalidated where an asset is cached, because a deploy
287
+ // replaces documents and gives assets a new hashed name.
288
+ const page = files.get(documentKey(pathname));
289
+ if (page != null) {
290
+ sendBytes(response, method, 200, page.type, DOCUMENT_CACHE_CONTROL, page.bytes());
291
+ return;
292
+ }
293
+ }
294
+
295
+ const handled = await app.dispatch(toRequest(request, url));
296
+ if (handled != null) {
297
+ await send(response, method, handled);
298
+ return;
299
+ }
300
+
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
+ }
318
+
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]);
325
+ }
326
+ const html = Buffer.from(rendered.html, "utf8");
327
+ response.setHeader("content-length", String(html.byteLength));
328
+ response.end(method === "HEAD" ? undefined : html);
329
+ };
330
+ }
331
+
332
+ /**
333
+ * A request path as an embedded key, or `null` when it cannot be one.
334
+ *
335
+ * Percent-decoding happens here rather than at the lookup, because a path that
336
+ * does not decode is a malformed request and not a missing file.
337
+ */
338
+ function decodePath(pathname: string): string | null {
339
+ try {
340
+ return decodeURIComponent(pathname);
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+
346
+ /** `/assets/x.js` is the embedded `assets/x.js`. */
347
+ function assetKey(pathname: string): string {
348
+ return pathname.replace(/^\/+/, "");
349
+ }
350
+
351
+ /**
352
+ * The prerendered document for a URL.
353
+ *
354
+ * `uf build` writes `/guide` as `guide/index.html`, and `/` as `index.html`,
355
+ * so both spellings of a directory URL find the same file.
356
+ */
357
+ function documentKey(pathname: string): string {
358
+ const key = assetKey(pathname).replace(/\/+$/, "");
359
+ return key === "" ? "index.html" : `${key}/index.html`;
360
+ }
361
+
362
+ /**
363
+ * A Node request as a `Request`.
364
+ *
365
+ * The body is passed as a stream where the host allows it, so a handler that
366
+ * accepts an upload does not need the whole thing buffered before it starts.
367
+ * `duplex` is required by the specification whenever a body is a stream, and
368
+ * Node throws without it.
369
+ */
370
+ function toRequest(incoming: NodeRequest, url: URL): Request {
371
+ const headers = new Headers();
372
+ for (const name of Object.keys(incoming.headers)) {
373
+ const value = incoming.headers[name];
374
+ if (value == null) continue;
375
+ for (const entry of Array.isArray(value) ? value : [value]) {
376
+ headers.append(name, entry);
377
+ }
378
+ }
379
+
380
+ const host = headers.get("host");
381
+ if (host != null && host !== "") {
382
+ url.host = host;
383
+ }
384
+
385
+ const method = (incoming.method ?? "GET").toUpperCase();
386
+ const init: { [string]: mixed } = { method, headers };
387
+ if (method !== "GET" && method !== "HEAD") {
388
+ init.body = incoming;
389
+ init.duplex = "half";
390
+ }
391
+ // $FlowFixMe[incompatible-call] - `incoming` is a stream, which `Request` accepts.
392
+ return new Request(url, init);
393
+ }
394
+
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") {
402
+ outgoing.end();
403
+ return;
404
+ }
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();
414
+ }
415
+
416
+ /** Write one embedded file, with the length a client needs to reuse a socket. */
417
+ function sendBytes(
418
+ outgoing: NodeResponse,
419
+ method: string,
420
+ status: number,
421
+ type: string,
422
+ cacheControl: string,
423
+ bytes: Buffer,
424
+ ): void {
425
+ outgoing.statusCode = status;
426
+ outgoing.setHeader("content-type", type);
427
+ outgoing.setHeader("cache-control", cacheControl);
428
+ outgoing.setHeader("content-length", String(bytes.byteLength));
429
+ outgoing.end(method === "HEAD" ? undefined : bytes);
430
+ }
431
+
432
+ /** The value of a `--flag value` pair on the command line, if it is there. */
433
+ function argument(name: string): string | null {
434
+ const at = process.argv.indexOf(name);
435
+ return at === -1 ? null : (process.argv[at + 1] ?? null);
436
+ }