@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/fetch.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
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
|
+
// # The route cache
|
|
32
|
+
//
|
|
33
|
+
// One `GET` at a time, and only where three things line up: the host passed a
|
|
34
|
+
// `cache` whose `route` is on (`rendering.cache.route` in `uf.config.js`), the
|
|
35
|
+
// render stated a lifetime with `cacheLife`, and the render did not read the
|
|
36
|
+
// request. All three are argued in `./cache.js`; what belongs here is the one
|
|
37
|
+
// cost that is this function's rather than the store's.
|
|
38
|
+
//
|
|
39
|
+
// **A cached route is buffered, and an uncached one is streamed.** The body has
|
|
40
|
+
// to be whole before it can be an entry, so the fill reads the document to its
|
|
41
|
+
// last byte before answering — which gives up the thing the streaming path
|
|
42
|
+
// exists for, on the fill. It buys two things back. The hit is the whole
|
|
43
|
+
// document at once with no render at all, which is faster than streaming a
|
|
44
|
+
// render; and the read of `requestStateReads` is only trustworthy *after* the
|
|
45
|
+
// last byte, because a component inside a `<Suspense>` boundary renders long
|
|
46
|
+
// after the shell resolved and `cookies()` in one of those is exactly the read
|
|
47
|
+
// that must stop the entry being stored. A cache that decided at the shell
|
|
48
|
+
// would cache a document whose tail was about one person.
|
|
49
|
+
//
|
|
50
|
+
// So the trade is per route and stated by the route: say nothing and stream as
|
|
51
|
+
// before, call `cacheLife` and buffer once per lifetime. `HEAD` never
|
|
52
|
+
// participates in either direction — it neither fills an entry nor reads one —
|
|
53
|
+
// because a `HEAD` is a request for a status and a length, and letting it fill
|
|
54
|
+
// a document cache would let a request that wants no body pay for one.
|
|
55
|
+
//
|
|
56
|
+
// # What it deliberately does not do
|
|
57
|
+
//
|
|
58
|
+
// Static files. A build's assets and its prerendered documents are the *host's*
|
|
59
|
+
// half — on a CDN-backed target they are not the application's job at all, and
|
|
60
|
+
// on a Node host they are a directory read, which is why `./node.js` has that
|
|
61
|
+
// half and this module has none of it. That split is the whole reason an
|
|
62
|
+
// adapter can be written for a worker: what is left after the files is exactly
|
|
63
|
+
// this function.
|
|
64
|
+
|
|
65
|
+
import { noStore } from "./cache.js";
|
|
66
|
+
import type { Application, DocumentAssets } from "./internal/application.js";
|
|
67
|
+
import type { CacheOptions, CacheOutcome } from "./internal/cache-store.js";
|
|
68
|
+
import { newScope, runInScope } from "./internal/cache-store.js";
|
|
69
|
+
import type { RequestContext } from "./internal/context.js";
|
|
70
|
+
import { currentContext } from "./internal/context.js";
|
|
71
|
+
|
|
72
|
+
export type { Application, DocumentAssets, RenderedDocument } from "./internal/application.js";
|
|
73
|
+
|
|
74
|
+
/** Everything the application half needs to answer a request. */
|
|
75
|
+
export type FetchHandlerOptions = {|
|
|
76
|
+
/** The server bundle, as imported. */
|
|
77
|
+
readonly app: Application,
|
|
78
|
+
/** The script, stylesheet and preload URLs a rendered document references. */
|
|
79
|
+
readonly document: DocumentAssets,
|
|
80
|
+
/**
|
|
81
|
+
* The cache this host installed, from `rendering.cache` in `uf.config.js`.
|
|
82
|
+
*
|
|
83
|
+
* Absent is the default and means no cache at all — every request renders,
|
|
84
|
+
* exactly as before this option existed. A host that passes one is saying
|
|
85
|
+
* two separate things with it, `route` and `fetch`, because the two switches
|
|
86
|
+
* in the configuration are two switches.
|
|
87
|
+
*/
|
|
88
|
+
readonly cache?: CacheOptions,
|
|
89
|
+
|};
|
|
90
|
+
|
|
91
|
+
/** A whole document, as an entry: what a hit answers with without rendering. */
|
|
92
|
+
type CachedDocument = {|
|
|
93
|
+
readonly status: number,
|
|
94
|
+
readonly headers: { readonly [string]: string },
|
|
95
|
+
readonly body: Uint8Array,
|
|
96
|
+
|};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The application half: middleware, then route handlers, then rendering.
|
|
100
|
+
*
|
|
101
|
+
* Returns `null` for nothing, ever — a request that matches no handler and no
|
|
102
|
+
* route is a rendered 404, because the renderer is what knows what the
|
|
103
|
+
* project's `_uf.not-found` page says.
|
|
104
|
+
*
|
|
105
|
+
* The order is the dev server's, and has to stay the dev server's: middleware
|
|
106
|
+
* first, then handlers for every method, because a handler is the only thing
|
|
107
|
+
* that can answer a `POST` and it may also answer a `GET` for a path that has
|
|
108
|
+
* no page. A page cannot answer a `POST`, so a non-navigation that no handler
|
|
109
|
+
* claimed is a 404 rather than a rendered page with a 200.
|
|
110
|
+
*
|
|
111
|
+
* Middleware above both, and not inside either: it guards a path, so it has to
|
|
112
|
+
* run for a page, for a route handler, and for a path under it that matches
|
|
113
|
+
* neither — `/dashboard/typo` is a 404 that the guard on `/dashboard` still
|
|
114
|
+
* answers. `app.runMiddleware` is called rather than tested for, so a server
|
|
115
|
+
* bundle without it is a `TypeError` on the first request instead of an
|
|
116
|
+
* application whose auth check quietly stopped running once it was built.
|
|
117
|
+
* That is the whole of ubugeeei-prod/uf#260, and every host that reaches this
|
|
118
|
+
* function is one more place it could have happened.
|
|
119
|
+
*
|
|
120
|
+
* # It must be called inside a request, and does not begin one
|
|
121
|
+
*
|
|
122
|
+
* `after()` says "once the response has been sent", and this function has a
|
|
123
|
+
* `Response` in hand rather than a response on the wire — for a streamed body
|
|
124
|
+
* those are a document apart. So the host begins the request with
|
|
125
|
+
* `app.beginRequest`, runs this inside `run`, and settles it after the bytes:
|
|
126
|
+
* `./node.js`'s `nodeListener` does that for `uf start` and for the `server.js`
|
|
127
|
+
* an adapter writes, and `@uniflowed/vite`'s `withRequest` does it for `uf dev`
|
|
128
|
+
* and `uf preview`.
|
|
129
|
+
*
|
|
130
|
+
* A worker-shaped host is the one uf does not write, and it has the same two
|
|
131
|
+
* halves to place. `settle` is what to hand `ctx.waitUntil` where there is one;
|
|
132
|
+
* without one, the honest moment is when the response body stream closes — and
|
|
133
|
+
* a runtime that tears the isolate down at that moment drops the callback,
|
|
134
|
+
* which is worth saying out loud rather than leaving to be discovered.
|
|
135
|
+
*
|
|
136
|
+
* A caller that forgets is not left to discover *that*, at least:
|
|
137
|
+
* `app.runMiddleware` refuses outside a request and names what establishes one.
|
|
138
|
+
* See ubugeeei-prod/uf#389.
|
|
139
|
+
*
|
|
140
|
+
* # And the cache, if the host installed one
|
|
141
|
+
*
|
|
142
|
+
* `cache` is `rendering.cache` from `uf.config.js`, and it does two separate
|
|
143
|
+
* things here. It is put on the request before the guard runs, so that a route
|
|
144
|
+
* handler or a server action calling `revalidateTag()` reaches the store that
|
|
145
|
+
* is answering this request; and, when `route` is on, a `GET` goes through
|
|
146
|
+
* [`cachedDocument`] instead of the streaming path. Both halves are argued in
|
|
147
|
+
* the module header and in `./cache.js`. With no `cache` at all this function
|
|
148
|
+
* is what it has always been, one `AsyncLocalStorage.run` aside.
|
|
149
|
+
*/
|
|
150
|
+
export function createFetchHandler(
|
|
151
|
+
options: FetchHandlerOptions,
|
|
152
|
+
): (request: Request) => Promise<Response> {
|
|
153
|
+
const { app, cache, document } = options;
|
|
154
|
+
|
|
155
|
+
return async function handle(request: Request): Promise<Response> {
|
|
156
|
+
// Before the guard, not after it. A route handler and a server action both
|
|
157
|
+
// run inside `dispatch`, and `revalidateTag()` in one of them has to reach
|
|
158
|
+
// the store that is answering this request — a mutation that invalidates
|
|
159
|
+
// nothing is the failure this whole seam exists to prevent, and it would
|
|
160
|
+
// be a silent one.
|
|
161
|
+
const context = currentContext();
|
|
162
|
+
if (context != null && cache != null) {
|
|
163
|
+
context.cache = cache;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const guarded = await app.runMiddleware(request);
|
|
167
|
+
if (guarded != null) return guarded;
|
|
168
|
+
|
|
169
|
+
const handled = await app.dispatch(request);
|
|
170
|
+
if (handled != null) return handled;
|
|
171
|
+
|
|
172
|
+
const method = request.method.toUpperCase();
|
|
173
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
174
|
+
return new Response(null, { status: 404 });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const url = new URL(request.url);
|
|
178
|
+
const target = url.pathname + url.search;
|
|
179
|
+
// Nothing better than the console here: this function is what a worker or
|
|
180
|
+
// a serverless invocation wraps, and it has no terminal of its own. Losing
|
|
181
|
+
// a boundary's exception entirely would be worse — it is the only trace a
|
|
182
|
+
// page that failed after its first byte leaves anywhere.
|
|
183
|
+
const onError = (error: mixed) => {
|
|
184
|
+
console.error(error);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (method === "GET" && cache != null && cache.route === true) {
|
|
188
|
+
return cachedDocument(app, cache, context, url, target, document, onError);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Rendered inside a scope even with no cache in sight, so that a component
|
|
192
|
+
// calling `cacheLife` is a component that states a lifetime nobody is
|
|
193
|
+
// honouring rather than a component that throws. Turning the route cache
|
|
194
|
+
// off must not change what an application is allowed to say.
|
|
195
|
+
const result = await runInScope(newScope({ key: [] }), () =>
|
|
196
|
+
app.render(target, document, { onError }),
|
|
197
|
+
);
|
|
198
|
+
const headers = new Headers(result.headers ?? {});
|
|
199
|
+
headers.set("content-type", "text/html; charset=utf-8");
|
|
200
|
+
// A `HEAD` gets the status and the headers and no body, which is what the
|
|
201
|
+
// renderer cannot know to do for itself. The stream is cancelled rather
|
|
202
|
+
// than dropped, so the render behind it stops instead of filling its queue
|
|
203
|
+
// and waiting for a reader that is never coming.
|
|
204
|
+
if (method === "HEAD") {
|
|
205
|
+
await result.stream().cancel();
|
|
206
|
+
return new Response(null, { status: result.status ?? 200, headers });
|
|
207
|
+
}
|
|
208
|
+
// The body is a stream, so the layouts and any `<Suspense>` fallback reach
|
|
209
|
+
// the browser while the page they surround is still resolving.
|
|
210
|
+
return new Response(result.stream(), { status: result.status ?? 200, headers });
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Answer a `GET` from the route cache, filling it if it has to.
|
|
216
|
+
*
|
|
217
|
+
* The fill is the interesting half, and everything it refuses is refused for a
|
|
218
|
+
* reason it can name:
|
|
219
|
+
*
|
|
220
|
+
* * **The render read the request.** `requestStateReads` is compared across the
|
|
221
|
+
* whole document rather than across the shell; see the module header.
|
|
222
|
+
* * **The render did not answer 200.** A 404 or a 500 is a fact about this
|
|
223
|
+
* moment far more often than it is a fact about the URL, and a cached 500 is
|
|
224
|
+
* an outage that outlives its cause.
|
|
225
|
+
* * **The render set a cookie.** A `Set-Cookie` in a shared entry is one
|
|
226
|
+
* person's session handed to the next reader. This is belt and braces — a
|
|
227
|
+
* render that set a cookie almost certainly read one first — and it is here
|
|
228
|
+
* because the cost of being wrong is not symmetric.
|
|
229
|
+
*
|
|
230
|
+
* A render that states no lifetime is refused by the store itself, which is
|
|
231
|
+
* where "no lifetime, no entry" belongs: it is a property of the cache, not of
|
|
232
|
+
* documents.
|
|
233
|
+
*/
|
|
234
|
+
async function cachedDocument(
|
|
235
|
+
app: Application,
|
|
236
|
+
cache: CacheOptions,
|
|
237
|
+
context: RequestContext | null,
|
|
238
|
+
url: URL,
|
|
239
|
+
target: string,
|
|
240
|
+
document: DocumentAssets,
|
|
241
|
+
onError: (error: mixed) => void,
|
|
242
|
+
): Promise<Response> {
|
|
243
|
+
const result = await cache.store.resolve(
|
|
244
|
+
{ key: ["route", "GET", url.pathname, url.search], path: url.pathname },
|
|
245
|
+
async (): Promise<CachedDocument> => {
|
|
246
|
+
const before = context?.requestStateReads ?? 0;
|
|
247
|
+
const rendered = await app.render(target, document, { onError });
|
|
248
|
+
const body = await drain(rendered.stream());
|
|
249
|
+
const status = rendered.status ?? 200;
|
|
250
|
+
const headers: { [string]: string } = { ...(rendered.headers ?? {}) };
|
|
251
|
+
|
|
252
|
+
if (status !== 200) {
|
|
253
|
+
noStore(`the render answered ${status}`);
|
|
254
|
+
} else if (Object.keys(headers).some((name) => name.toLowerCase() === "set-cookie")) {
|
|
255
|
+
noStore("the render set a cookie");
|
|
256
|
+
} else if ((context?.requestStateReads ?? 0) > before) {
|
|
257
|
+
noStore("the render read cookies(), headers() or draftMode()");
|
|
258
|
+
}
|
|
259
|
+
return { status, headers, body };
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const headers = new Headers(result.value.headers);
|
|
264
|
+
headers.set("content-type", "text/html; charset=utf-8");
|
|
265
|
+
// What this request did to the cache, in one word. It is the only way to see
|
|
266
|
+
// a cache working from outside the process — a benchmark reads it, and so
|
|
267
|
+
// does anybody wondering why a page is fast.
|
|
268
|
+
headers.set("x-uf-cache", label(result.outcome));
|
|
269
|
+
return new Response(result.value.body, { status: result.value.status, headers });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The header word for an outcome. */
|
|
273
|
+
function label(outcome: CacheOutcome): string {
|
|
274
|
+
return match (outcome) {
|
|
275
|
+
"hit" => "HIT",
|
|
276
|
+
"stale" => "STALE",
|
|
277
|
+
"coalesced" => "COALESCED",
|
|
278
|
+
"miss" => "MISS",
|
|
279
|
+
"uncacheable" => "BYPASS",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Every byte of `stream`, as one array.
|
|
285
|
+
*
|
|
286
|
+
* The chunks are collected and joined once rather than concatenated as they
|
|
287
|
+
* arrive: a document is a few hundred chunks, and growing an array per chunk
|
|
288
|
+
* copies the whole document per chunk.
|
|
289
|
+
*/
|
|
290
|
+
async function drain(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
|
291
|
+
const reader = stream.getReader();
|
|
292
|
+
const chunks: Array<Uint8Array> = [];
|
|
293
|
+
let total = 0;
|
|
294
|
+
for (;;) {
|
|
295
|
+
const step = await reader.read();
|
|
296
|
+
if (step.done === true) break;
|
|
297
|
+
const chunk = step.value;
|
|
298
|
+
if (chunk != null) {
|
|
299
|
+
chunks.push(chunk);
|
|
300
|
+
total += chunk.byteLength;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const body = new Uint8Array(total);
|
|
304
|
+
let at = 0;
|
|
305
|
+
for (const chunk of chunks) {
|
|
306
|
+
body.set(chunk, at);
|
|
307
|
+
at += chunk.byteLength;
|
|
308
|
+
}
|
|
309
|
+
return body;
|
|
310
|
+
}
|
package/host.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/server/host`: how a host establishes a request.
|
|
4
|
+
//
|
|
5
|
+
// The other half of this package. `@uniflowed/server` is what an application
|
|
6
|
+
// calls *inside* a request; this is what a renderer, a route dispatcher or a
|
|
7
|
+
// server-action bridge calls to say that a request has begun and, later, that
|
|
8
|
+
// the response has gone.
|
|
9
|
+
//
|
|
10
|
+
// Two subpaths rather than one module, because they have opposite audiences and
|
|
11
|
+
// opposite rules. Everything in the root is safe to call from a component and
|
|
12
|
+
// meaningless outside a request; everything here is meaningless *inside* one
|
|
13
|
+
// and must be called exactly once around it. Mixing them would put
|
|
14
|
+
// `runWithContext` in the same import a page reaches for, which is an invitation
|
|
15
|
+
// to nest one request inside another.
|
|
16
|
+
//
|
|
17
|
+
// It is a subpath rather than `internal/` because a sibling package cannot
|
|
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.
|
|
40
|
+
|
|
41
|
+
export type {
|
|
42
|
+
CookieStore,
|
|
43
|
+
DraftMode,
|
|
44
|
+
HeaderStore,
|
|
45
|
+
RequestContext,
|
|
46
|
+
RequestLifecycle,
|
|
47
|
+
} from "./internal/context.js";
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
beginRequest,
|
|
51
|
+
contextFor,
|
|
52
|
+
drainDeferred,
|
|
53
|
+
insideRequest,
|
|
54
|
+
parseCookies,
|
|
55
|
+
runWithContext,
|
|
56
|
+
} from "./internal/context.js";
|
package/index.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/server`: what a server function may ask about its request.
|
|
4
|
+
//
|
|
5
|
+
// Every binding here takes no arguments and answers about the request being
|
|
6
|
+
// handled, which is only possible because the renderer establishes a context
|
|
7
|
+
// around each one ([`./internal/context.js`]). Outside a request they throw,
|
|
8
|
+
// and each says what it was that had nowhere to look — a component that calls
|
|
9
|
+
// `cookies()` during a static prerender has made a mistake worth naming, not a
|
|
10
|
+
// mistake worth returning `null` for.
|
|
11
|
+
//
|
|
12
|
+
// This module is server-only. Nothing in it is reachable from a client
|
|
13
|
+
// component, `uf:rsc` classifies it that way, and importing it from one is the
|
|
14
|
+
// error that classification exists to produce.
|
|
15
|
+
|
|
16
|
+
import type { CookieStore, DraftMode, HeaderStore } from "./internal/context.js";
|
|
17
|
+
import { currentContext } from "./internal/context.js";
|
|
18
|
+
|
|
19
|
+
export type { CookieStore, DraftMode, HeaderStore } from "./internal/context.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Raised when a server function is called with no request to answer about.
|
|
23
|
+
*
|
|
24
|
+
* Names the binding, because "no request context" on its own leaves a reader
|
|
25
|
+
* hunting for which of the six things they called was the one out of place.
|
|
26
|
+
*/
|
|
27
|
+
export class OutsideRequestError extends Error {
|
|
28
|
+
/** The binding that was called, e.g. `cookies`. */
|
|
29
|
+
binding: string;
|
|
30
|
+
|
|
31
|
+
constructor(binding: string) {
|
|
32
|
+
super(
|
|
33
|
+
`@uniflowed/server: ${binding}() was called outside a request. ` +
|
|
34
|
+
"It answers about the request being handled, and there is not one here — " +
|
|
35
|
+
"a static prerender, a module's top level, or a client component.",
|
|
36
|
+
);
|
|
37
|
+
this.name = "OutsideRequestError";
|
|
38
|
+
this.binding = binding;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The current request's context, or a named failure. */
|
|
43
|
+
function require$Context(binding: string) {
|
|
44
|
+
const context = currentContext();
|
|
45
|
+
if (context == null) {
|
|
46
|
+
throw new OutsideRequestError(binding);
|
|
47
|
+
}
|
|
48
|
+
return context;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The current request's context, counting this as a read of request state.
|
|
53
|
+
*
|
|
54
|
+
* The three bindings below that answer about *this* request go through this
|
|
55
|
+
* one, and `after()` deliberately does not: registering deferred work says
|
|
56
|
+
* nothing about what the response contains. What the count is for is the route
|
|
57
|
+
* cache — `./fetch.js` reads it before the render and again after the last
|
|
58
|
+
* byte, and stores the document only if nothing in between asked who was
|
|
59
|
+
* asking. A page that read a cookie is a page about one person, and a page
|
|
60
|
+
* about one person must not be served to the next person from a cache.
|
|
61
|
+
*
|
|
62
|
+
* Counted at the call rather than at the value, which is conservative in the
|
|
63
|
+
* one direction that is safe: `const store = cookies()` followed by no `get`
|
|
64
|
+
* counts, so such a render is re-rendered rather than cached. Slower, never
|
|
65
|
+
* wrong — and the alternative, instrumenting the getters, would have to decide
|
|
66
|
+
* what `has()` on a name that is absent means, which is a question with no
|
|
67
|
+
* answer that is safe in both directions.
|
|
68
|
+
*/
|
|
69
|
+
function require$VaryingContext(binding: string) {
|
|
70
|
+
const context = require$Context(binding);
|
|
71
|
+
context.requestStateReads += 1;
|
|
72
|
+
return context;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The request's headers, read-only.
|
|
77
|
+
*
|
|
78
|
+
* Read-only because a response header set from inside a render has no defined
|
|
79
|
+
* moment to take effect: the headers may already be on the wire by the time a
|
|
80
|
+
* component deep in the tree renders.
|
|
81
|
+
*/
|
|
82
|
+
export function headers(): HeaderStore {
|
|
83
|
+
return require$VaryingContext("headers").headers;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The request's cookies, read-only.
|
|
88
|
+
*
|
|
89
|
+
* Setting a cookie belongs to a route handler or a server action, which run
|
|
90
|
+
* before a response exists and can say so in it.
|
|
91
|
+
*/
|
|
92
|
+
export function cookies(): CookieStore {
|
|
93
|
+
return require$VaryingContext("cookies").cookies;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether this request is rendering draft content.
|
|
98
|
+
*
|
|
99
|
+
* The flag lives on the request rather than in a module, so two requests being
|
|
100
|
+
* handled at once cannot see each other's answer.
|
|
101
|
+
*/
|
|
102
|
+
export function draftMode(): DraftMode {
|
|
103
|
+
const context = require$VaryingContext("draftMode");
|
|
104
|
+
return {
|
|
105
|
+
isEnabled: context.draft,
|
|
106
|
+
enable: () => {
|
|
107
|
+
context.draft = true;
|
|
108
|
+
},
|
|
109
|
+
disable: () => {
|
|
110
|
+
context.draft = false;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Run `callback` once the response has been sent.
|
|
117
|
+
*
|
|
118
|
+
* For the work a request causes but a response does not wait on: recording a
|
|
119
|
+
* view, flushing a metric, warming a cache. Registered work runs in the order
|
|
120
|
+
* it was registered, and one task failing does not stop the others — deferred
|
|
121
|
+
* work is by definition not what the response depended on.
|
|
122
|
+
*
|
|
123
|
+
* "Sent" is the host's word to keep, and it keeps it: the request is drained
|
|
124
|
+
* after `uf dev` has written the document, after `uf preview` and `uf start`
|
|
125
|
+
* have returned from `send`, and after a compiled binary's `pipe` has resolved
|
|
126
|
+
* on the last byte. One list per request, whether the callback was registered
|
|
127
|
+
* by a middleware, a route handler or a page. It was not always so — see
|
|
128
|
+
* ubugeeei-prod/uf#389 for what it meant before, and
|
|
129
|
+
* `@uniflowed/server/host`'s `beginRequest` for the half a host supplies.
|
|
130
|
+
*/
|
|
131
|
+
export function after(callback: () => mixed | Promise<mixed>): void {
|
|
132
|
+
require$Context("after").deferred.push(callback);
|
|
133
|
+
}
|
|
@@ -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
|
+
|};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/server`: what makes two lookups the same lookup.
|
|
4
|
+
//
|
|
5
|
+
// A cache key here is a list of **strings**, reduced to one string that is the
|
|
6
|
+
// entry's identity. That is narrower than the two keys this repository already
|
|
7
|
+
// has, and the narrowing is the whole design decision, so it is argued rather
|
|
8
|
+
// than asserted.
|
|
9
|
+
//
|
|
10
|
+
// # Where it departs from `@uniflowed/query/key`, and why
|
|
11
|
+
//
|
|
12
|
+
// `hashKey` there takes `$ReadOnlyArray<mixed>` and serialises it with object
|
|
13
|
+
// members sorted, because a *client* key is written inline in two components
|
|
14
|
+
// by two people and `["users", {page: 1, size: 20}]` has to be the same entry
|
|
15
|
+
// as `["users", {size: 20, page: 1}]`. Its own header then spends a paragraph
|
|
16
|
+
// on what that admits: a `Date` or a class instance serialises through
|
|
17
|
+
// whatever `toJSON` it happens to have, `1` and `"1"` are deliberately
|
|
18
|
+
// different entries, and both are traps you are told to avoid rather than
|
|
19
|
+
// prevented from writing.
|
|
20
|
+
//
|
|
21
|
+
// A server cache key is not written that way. It is built by uf — a method, a
|
|
22
|
+
// pathname and a query string — or by a caller naming a URL it is about to
|
|
23
|
+
// fetch. Every one of those is already a string, so admitting `mixed` would
|
|
24
|
+
// buy nothing and would import the whole `toJSON` question into a cache whose
|
|
25
|
+
// wrong answers are *other people's data*. Strings only, and a key that is not
|
|
26
|
+
// a list of strings is a `TypeError` at the call rather than an entry filed
|
|
27
|
+
// under a name nobody can predict.
|
|
28
|
+
//
|
|
29
|
+
// Prefix matching does not come across either, and for a reason rather than an
|
|
30
|
+
// omission: invalidation here is by **tag**, not by key shape. A caller that
|
|
31
|
+
// wants "everything about users" gone says `cacheTag("users")` when the entry
|
|
32
|
+
// is filled and `revalidateTag("users")` when it changes, which is a statement
|
|
33
|
+
// about meaning; `matchesKey`'s structural prefix is a statement about
|
|
34
|
+
// spelling, and the two only agree while one person is writing both ends.
|
|
35
|
+
//
|
|
36
|
+
// # Where it departs from the two disk caches, and why
|
|
37
|
+
//
|
|
38
|
+
// `crates/uf_check`'s cache and `@uniflowed/host`'s transform cache both put
|
|
39
|
+
// **the identity of the `uf` that produced the entry** in the key, and both
|
|
40
|
+
// headers say the same thing about it: a content-addressed cache has no
|
|
41
|
+
// invalidation to get wrong only if every other input is a constant, and the
|
|
42
|
+
// largest one is the compiler. Neither of them can leave it out, because both
|
|
43
|
+
// outlive the process that wrote them — the entry on disk is read by the next
|
|
44
|
+
// build, which may be a different build.
|
|
45
|
+
//
|
|
46
|
+
// This one cannot outlive the process, so the identity of the code that
|
|
47
|
+
// produced an entry is fixed for the whole life of the store and there is
|
|
48
|
+
// nothing to put in the key. That is not a shortcut around their lesson; it is
|
|
49
|
+
// the same lesson pointing the other way, and it is the reason the store is in
|
|
50
|
+
// memory and stays there until something durable exists to hold it. The day an
|
|
51
|
+
// adapter offers a store that survives a restart, this key gains a generation
|
|
52
|
+
// — the build id — before that store is written to, because on that day the
|
|
53
|
+
// entry outlives the build and every word of those two headers applies.
|
|
54
|
+
|
|
55
|
+
/** A cache key, as a caller writes it: `["route", "GET", "/posts"]`. */
|
|
56
|
+
export type CacheKey = $ReadOnlyArray<string>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A key as one string, exactly and totally.
|
|
60
|
+
*
|
|
61
|
+
* `JSON.stringify` over an array of strings is unambiguous — the quoting and
|
|
62
|
+
* escaping are what separate the members — so two different keys cannot
|
|
63
|
+
* collide and one key cannot spell itself two ways. Joining with a separator
|
|
64
|
+
* would do neither: a path containing the separator is a second key wearing
|
|
65
|
+
* the first one's name, and on a route cache that is one URL served the
|
|
66
|
+
* document of another.
|
|
67
|
+
*/
|
|
68
|
+
export function hashCacheKey(key: CacheKey): string {
|
|
69
|
+
for (let index = 0; index < key.length; index += 1) {
|
|
70
|
+
if (typeof key[index] !== "string") {
|
|
71
|
+
throw new TypeError(
|
|
72
|
+
`@uniflowed/server: a cache key is a list of strings, and member ${index} is ` +
|
|
73
|
+
`${typeof key[index]}. Spell it out — String(id) — rather than leaving the ` +
|
|
74
|
+
"entry's name to whatever serialisation the value happens to have.",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return JSON.stringify(key);
|
|
79
|
+
}
|