@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 ADDED
@@ -0,0 +1,341 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/server/cache`: the cache, and the two ways to invalidate it.
4
+ //
5
+ // `uf.config.js` has had `rendering.cache` in it for a long time, and until now
6
+ // the four switches under it were read once, copied into
7
+ // `dist/uf-build-manifest.json` and read by nothing. Two of them mean something
8
+ // from here on — `route` and `fetch` — and two of them do not, which is stated
9
+ // rather than implied: `data` and `actions` are refused by the configuration
10
+ // loader, by name, because a key that accepts `true` and changes nothing is
11
+ // worse than a key that is not there. See ubugeeei-prod/uf#277.
12
+ //
13
+ // # What is here
14
+ //
15
+ // * [`createCacheStore`] — the store. Its contract is written out in
16
+ // `./internal/cache-store.js`: what a key is, what an entry is, when an entry
17
+ // is stale, who evicts, and what happens to a request that arrives while an
18
+ // entry is being filled. Read that before this.
19
+ // * [`cacheLife`] and [`cacheTag`] — what a render says about the entry it is
20
+ // filling, from inside the render.
21
+ // * [`noStore`] — what it says when the answer must not be kept.
22
+ // * [`revalidateTag`] and [`revalidatePath`] — what a server action or a route
23
+ // handler says when the data changed.
24
+ // * [`createCachedFetch`] — the fetch cache, as a wrapper.
25
+ //
26
+ // # What is not here, and will not be quietly added
27
+ //
28
+ // **No entry is stored without a stated lifetime.** There is no "cache forever"
29
+ // and no default lifetime. A route that never calls [`cacheLife`] is rendered
30
+ // for every request exactly as it is today, whatever `rendering.cache.route`
31
+ // says — the switch decides whether a *stated* lifetime is honoured, not
32
+ // whether uf starts keeping documents nobody asked it to keep.
33
+ //
34
+ // **A render that read the request is never stored.** `cookies()`, `headers()`
35
+ // and `draftMode()` are how a document comes to be about one person, and a
36
+ // document about one person in a cache shared by every person is the worst bug
37
+ // a framework can have. `./fetch.js` counts those reads across the whole render
38
+ // and refuses to store when the count moved. It is a runtime refusal today;
39
+ // `crates/uf_rsc` already answers "is this call reachable from here" for
40
+ // server-only imports, and turning the same question on a cached scope is what
41
+ // would make it a build error instead. #277 argues that, and it is not done.
42
+ //
43
+ // # Where it lives
44
+ //
45
+ // In memory, in one process. Four processes behind a load balancer hold four of
46
+ // these and disagree; a restart empties it; `revalidateTag` in one of them does
47
+ // not reach the other three. That is the whole truth about it today and there
48
+ // is no configuration that changes it. What would is a durable store behind
49
+ // `resolve`, which is an adapter's to provide.
50
+
51
+ import type {
52
+ CacheLifetime,
53
+ CacheOptions,
54
+ CacheRequest,
55
+ CacheStoreOptions,
56
+ } from "./internal/cache-store.js";
57
+ import { CacheStore, currentScope } from "./internal/cache-store.js";
58
+ import type { CacheKey } from "./internal/cache-key.js";
59
+ import { currentContext } from "./internal/context.js";
60
+
61
+ export type { CacheKey } from "./internal/cache-key.js";
62
+ export type {
63
+ CacheEntry,
64
+ CacheLifetime,
65
+ CacheOptions,
66
+ CacheOutcome,
67
+ CacheRequest,
68
+ CacheResult,
69
+ CacheStats,
70
+ CacheStoreOptions,
71
+ } from "./internal/cache-store.js";
72
+ export { CacheStore } from "./internal/cache-store.js";
73
+
74
+ /**
75
+ * Raised when something that only means anything inside a cached fill is called
76
+ * outside one.
77
+ *
78
+ * Names the binding, for the same reason `OutsideRequestError` does: "no cache
79
+ * scope" leaves a reader hunting for which call was the one out of place.
80
+ */
81
+ export class OutsideCacheScopeError extends Error {
82
+ /** The binding that was called, e.g. `cacheTag`. */
83
+ binding: string;
84
+
85
+ constructor(binding: string) {
86
+ super(
87
+ `@uniflowed/server: ${binding}() was called outside a cached scope. ` +
88
+ "It states something about the entry being filled, and nothing is being filled here — " +
89
+ "a module's top level, a client component, or a request answered by a host that " +
90
+ "established no render scope.",
91
+ );
92
+ this.name = "OutsideCacheScopeError";
93
+ this.binding = binding;
94
+ }
95
+ }
96
+
97
+ /** Raised when the cache is asked about from outside a request that has one. */
98
+ export class OutsideCachedRequestError extends Error {
99
+ /** The binding that was called, e.g. `revalidateTag`. */
100
+ binding: string;
101
+
102
+ constructor(binding: string) {
103
+ super(
104
+ `@uniflowed/server: ${binding}() needs the cache the host installed for this request, ` +
105
+ "and there is not one here. Either this is outside a request, or the host answered it " +
106
+ "without passing `cache` to createFetchHandler — which is what `rendering.cache` in " +
107
+ "uf.config.js turns on.",
108
+ );
109
+ this.name = "OutsideCachedRequestError";
110
+ this.binding = binding;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * A cache.
116
+ *
117
+ * A function rather than the constructor as the front door, so the store can
118
+ * grow a second implementation — a durable one, from an adapter — without every
119
+ * caller having named a class.
120
+ */
121
+ export function createCacheStore(options?: CacheStoreOptions): CacheStore {
122
+ return new CacheStore(options);
123
+ }
124
+
125
+ /**
126
+ * How long the entry this render is filling stays usable.
127
+ *
128
+ * Called from inside the render, by whatever knows the answer — a loader knows
129
+ * how often its data changes and the handler above it does not. Called twice,
130
+ * the *shorter* lifetime wins: a page composed of a thing that changes hourly
131
+ * and a thing that changes by the minute is a page that changes by the minute,
132
+ * and taking the longer one would serve the fast half stale for an hour.
133
+ */
134
+ export function cacheLife(lifetime: CacheLifetime): void {
135
+ const scope = currentScope();
136
+ if (scope == null) {
137
+ throw new OutsideCacheScopeError("cacheLife");
138
+ }
139
+ const held = scope.lifetime;
140
+ if (held == null || lifetime.revalidate < held.revalidate) {
141
+ scope.lifetime = lifetime;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Label the entry this render is filling, so `revalidateTag` can reach it.
147
+ *
148
+ * Tags are what make invalidation a statement about meaning rather than about
149
+ * spelling: a mutation says "posts changed" and every entry that read a post
150
+ * goes, without the mutation knowing which URLs those were.
151
+ */
152
+ export function cacheTag(...tags: $ReadOnlyArray<string>): void {
153
+ const scope = currentScope();
154
+ if (scope == null) {
155
+ throw new OutsideCacheScopeError("cacheTag");
156
+ }
157
+ for (const tag of tags) {
158
+ if (typeof tag !== "string" || tag === "") {
159
+ throw new TypeError(
160
+ "@uniflowed/server: a cache tag is a non-empty string; " +
161
+ `cacheTag received ${JSON.stringify(tag)}.`,
162
+ );
163
+ }
164
+ scope.tags.push(tag);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Refuse to store the entry this render is filling.
170
+ *
171
+ * `reason` is kept because the interesting question about an uncached page is
172
+ * never "is it cached" but "why is it not", and the answer is usually six
173
+ * frames down in somebody else's module. The first reason wins: what stopped an
174
+ * answer being stored is the first thing that did.
175
+ */
176
+ export function noStore(reason: string = "noStore() was called"): void {
177
+ const scope = currentScope();
178
+ if (scope == null) {
179
+ throw new OutsideCacheScopeError("noStore");
180
+ }
181
+ scope.denied ??= reason;
182
+ }
183
+
184
+ /** The cache the host installed for this request, or a named failure. */
185
+ function require$Cache(binding: string): CacheOptions {
186
+ const cache = requestCache();
187
+ if (cache == null) {
188
+ throw new OutsideCachedRequestError(binding);
189
+ }
190
+ return cache;
191
+ }
192
+
193
+ /**
194
+ * Expire every entry filled under `tag`. Answers how many went.
195
+ *
196
+ * Expired, not marked stale: a tag is invalidated because somebody changed the
197
+ * thing it names, so the entry is known wrong rather than possibly old. See
198
+ * `./internal/cache-store.js`, which argues the difference.
199
+ *
200
+ * In this process. A deployment with four of them has four caches and this
201
+ * empties one — which is why the count comes back rather than nothing, so a
202
+ * caller can log what it actually did rather than what it meant to.
203
+ */
204
+ export function revalidateTag(tag: string): number {
205
+ return require$Cache("revalidateTag").store.revalidateTag(tag);
206
+ }
207
+
208
+ /** Expire every entry filled for `path`. Answers how many went. */
209
+ export function revalidatePath(path: string): number {
210
+ return require$Cache("revalidatePath").store.revalidatePath(path);
211
+ }
212
+
213
+ /** The cache answering this request, or `null` where the host installed none. */
214
+ export function requestCache(): CacheOptions | null {
215
+ return currentContext()?.cache ?? null;
216
+ }
217
+
218
+ /** A request, plus what it says about caching itself. */
219
+ export type CachedRequestOptions = {
220
+ readonly method?: string,
221
+ readonly searchParams?: { readonly [string]: string | number | boolean },
222
+ /** Absent means "do not cache this", which is the default and stays it. */
223
+ readonly cache?: FetchCacheOptions,
224
+ ...
225
+ };
226
+
227
+ /**
228
+ * The half of a fetch client this module calls.
229
+ *
230
+ * Declared structurally rather than imported from `@uniflowed/fetch`, and that
231
+ * is a decision rather than an oversight. `@uniflowed/fetch`'s own header says
232
+ * what it is — a failed response that is a failed promise, a timeout, and a
233
+ * retry policy — and a cache is none of those three; putting one inside it
234
+ * would make the thin wrapper thick, and would put a server cache in a package
235
+ * a client bundle imports. So the caching lives on the server side and reaches
236
+ * the client through the one method it calls, exactly as
237
+ * `./internal/application.js` names the two methods of a Node stream rather
238
+ * than importing `node:stream` into a module a worker has to bundle.
239
+ */
240
+ export type CacheableClient = {
241
+ readonly request: <T>(path: string, options?: CachedRequestOptions) => Promise<T>,
242
+ ...
243
+ };
244
+
245
+ /** What one cached request states about its entry. */
246
+ export type FetchCacheOptions = {|
247
+ readonly lifetime: CacheLifetime,
248
+ readonly tags?: $ReadOnlyArray<string>,
249
+ /** Overrides the key built from the client's name, the method and the URL. */
250
+ readonly key?: CacheKey,
251
+ |};
252
+
253
+ /** A client that caches the requests which ask to be cached. */
254
+ export type CachedFetchClient = {|
255
+ readonly request: <T>(path: string, options?: CachedRequestOptions) => Promise<T>,
256
+ |};
257
+
258
+ /** Everything the fetch cache needs. */
259
+ export type CachedFetchOptions = {|
260
+ readonly client: CacheableClient,
261
+ /**
262
+ * What distinguishes this client from another one in the same store.
263
+ *
264
+ * Required, and it is the one piece of ceremony here worth defending: two
265
+ * clients with different `baseURL`s both request `/users`, and a key built
266
+ * from the path alone would file one client's answer under the other's name.
267
+ * A client does not publish its `baseURL`, so the caller names it.
268
+ */
269
+ readonly name: string,
270
+ /** Defaults to the store the host installed for this request. */
271
+ readonly store?: CacheStore,
272
+ |};
273
+
274
+ /**
275
+ * A fetch client whose cacheable requests are cached.
276
+ *
277
+ * Opt-in per call, and that is the whole safety argument: a request with no
278
+ * `cache` option behaves exactly as the underlying client's does, so wrapping a
279
+ * client changes nothing until somebody states a lifetime for one request.
280
+ * There is no "cache every GET" mode, because every GET is not cacheable and a
281
+ * framework guessing which ones are is how a cache serves one person's account
282
+ * page to another.
283
+ *
284
+ * With no store — `rendering.cache.fetch` off, or outside a request — every
285
+ * call passes straight through. Slower, never wrong.
286
+ */
287
+ export function createCachedFetch(options: CachedFetchOptions): CachedFetchClient {
288
+ const { client, name } = options;
289
+
290
+ function storeFor(): CacheStore | null {
291
+ if (options.store != null) {
292
+ return options.store;
293
+ }
294
+ const installed = requestCache();
295
+ return installed != null && installed.fetch === true ? installed.store : null;
296
+ }
297
+
298
+ return {
299
+ request<T>(path: string, requestOptions?: CachedRequestOptions): Promise<T> {
300
+ const caching = requestOptions?.cache;
301
+ const store = caching == null ? null : storeFor();
302
+ if (caching == null || store == null) {
303
+ return client.request(path, requestOptions);
304
+ }
305
+ const request: CacheRequest = {
306
+ key: caching.key ?? [
307
+ "fetch",
308
+ name,
309
+ (requestOptions?.method ?? "GET").toUpperCase(),
310
+ path,
311
+ searchOf(requestOptions?.searchParams),
312
+ ],
313
+ lifetime: caching.lifetime,
314
+ tags: caching.tags,
315
+ };
316
+ return store
317
+ .resolve(request, () => client.request(path, requestOptions))
318
+ .then((result) => result.value);
319
+ },
320
+ };
321
+ }
322
+
323
+ /**
324
+ * The search parameters as one string, in the order the caller wrote them.
325
+ *
326
+ * Not sorted, deliberately, and it is the one place this key is looser than it
327
+ * could be: `?a=1&b=2` and `?b=2&a=1` are two entries. Sorting would merge them
328
+ * — and would also merge two requests to a server that treats repeated or
329
+ * ordered parameters as meaning something, which is a wrong answer where two
330
+ * entries are only a wasted one. A caller that minds passes `key`.
331
+ */
332
+ function searchOf(params?: { readonly [string]: string | number | boolean }): string {
333
+ if (params == null) {
334
+ return "";
335
+ }
336
+ const search = new URLSearchParams();
337
+ for (const key of Object.keys(params)) {
338
+ search.set(key, String(params[key]));
339
+ }
340
+ return search.toString();
341
+ }
package/edge.js ADDED
@@ -0,0 +1,158 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/server/edge`: the Cloudflare Workers half of serving a build.
4
+ //
5
+ // [`./fetch.js`] answers the application's half of a request and touches no
6
+ // filesystem, because that is the half a worker runs. This is everything that
7
+ // is left on a worker, and there is much less of it than there is on Node: a
8
+ // worker has no socket to take and no directory to read, so what remains is
9
+ // the request lifecycle and the one decision `./node.js` makes with `stat` —
10
+ // does a file the build already wrote answer this, or does the application.
11
+ //
12
+ // # Where the static half went
13
+ //
14
+ // To the platform. Workers static assets are uploaded beside the script and
15
+ // served by Cloudflare's own asset server; the Worker reaches them through the
16
+ // `ASSETS` binding declared in `wrangler.json`, which answers a `Request` with
17
+ // a `Response` exactly as `createStaticHandler` does — and answers `404` where
18
+ // that returns `null`, which is the only translation this module performs.
19
+ //
20
+ // The Worker asks *first*, and `uf build --adapter edge` writes
21
+ // `"run_worker_first": true` so that it can. That looks like the wrong way
22
+ // round — Cloudflare's default is to serve a matching asset without invoking
23
+ // the script at all, which is faster and resolves a file/handler collision the
24
+ // same way — but the default also puts the resolution order in a platform
25
+ // setting rather than in uf, and `tests/library/deploy.test.js` cannot drive a
26
+ // platform setting. Asking here means one order, written once, checked by the
27
+ // test that checks `uf start`'s. See ubugeeei-prod/uf#391.
28
+ //
29
+ // # Who owns the request, and the one thing a worker cannot promise
30
+ //
31
+ // `after()` says "once the response has been sent", and there is no such line
32
+ // in a worker: [`createWorkerFetch`] has a `Response` in hand, and the platform
33
+ // writes its body after the handler has returned. `ctx.waitUntil` is the
34
+ // documented way to keep the isolate alive for work that outlives the handler,
35
+ // so that is what `settle` is handed — which means an `after()` callback on
36
+ // this target begins when the response has been *decided* rather than when its
37
+ // last byte is out. For a streamed document those are a document apart. It is
38
+ // written down here, in `docs/app/reference/cli/_uf.page.mdx`, and it is the
39
+ // one behavioural difference between this target and the other three.
40
+ //
41
+ // `beginRequest` is passed in rather than imported, for the reason `./node.js`
42
+ // gives at length: the request lives in an `AsyncLocalStorage` belonging to a
43
+ // module instance, and the instance the application reads is the one bundled
44
+ // into the `handler.js` beside the generated `worker.js`. See
45
+ // ubugeeei-prod/uf#389.
46
+
47
+ import type { RequestLifecycle } from "./internal/context.js";
48
+
49
+ export type { RequestLifecycle } from "./internal/context.js";
50
+
51
+ /**
52
+ * The `ASSETS` binding, as much of it as this module uses.
53
+ *
54
+ * One method, declared structurally: a worker's bindings are supplied by the
55
+ * runtime, and naming the whole of Cloudflare's `Fetcher` here would be this
56
+ * package holding a copy of another project's types.
57
+ */
58
+ export type AssetsBinding = {
59
+ readonly fetch: (request: Request) => Promise<Response>,
60
+ ...
61
+ };
62
+
63
+ /**
64
+ * The `env` a Worker's `fetch` is called with.
65
+ *
66
+ * Inexact, because a project's own bindings — a KV namespace, a secret — are
67
+ * in here too and are none of this module's business. `ASSETS` is optional
68
+ * because a Worker deployed without an assets directory has no such binding,
69
+ * and the honest answer for that deployment is "the application answers
70
+ * everything" rather than a `TypeError` on the first request.
71
+ */
72
+ export type EdgeEnvironment = {
73
+ readonly ASSETS?: AssetsBinding,
74
+ ...
75
+ };
76
+
77
+ /**
78
+ * The `ctx` a Worker's `fetch` is called with.
79
+ *
80
+ * `waitUntil` only. `passThroughOnException` exists and is deliberately not
81
+ * used: it serves the origin's response when the script throws, and a Worker
82
+ * that *is* the origin has nothing to pass through to.
83
+ */
84
+ export type ExecutionContext = {
85
+ readonly waitUntil: (promise: Promise<mixed>) => mixed,
86
+ ...
87
+ };
88
+
89
+ /** Everything the worker half needs to answer a request. */
90
+ export type WorkerHandlerOptions = {|
91
+ /** The application, from the generated `handler.js`. */
92
+ readonly handle: (request: Request) => Promise<Response>,
93
+ /** That same module's `beginRequest`; see the header. */
94
+ readonly beginRequest: (request: Request) => RequestLifecycle,
95
+ |};
96
+
97
+ /**
98
+ * A built uf application as a Worker's `fetch`.
99
+ *
100
+ * Static assets first, then the application — the order `uf preview` cannot
101
+ * deviate from and therefore the order every other front door matches. The
102
+ * asset lookup is skipped for anything that is not a `GET` or a `HEAD`, which
103
+ * is what `createStaticHandler` does and for the same reason: a `POST` to a
104
+ * path that happens to have a file under it belongs to a route handler.
105
+ *
106
+ * A `404` from the assets binding means "no such asset", not "the site has no
107
+ * such page": `wrangler.json` sets `"not_found_handling": "none"` so that the
108
+ * miss falls through to here, and the 404 a visitor sees is the project's own
109
+ * `_uf.not-found` rendered by the application. Any other status is the asset's
110
+ * answer and is returned as it stands.
111
+ */
112
+ export function createWorkerFetch(
113
+ options: WorkerHandlerOptions,
114
+ ): (request: Request, env: EdgeEnvironment, ctx?: ExecutionContext) => Promise<Response> {
115
+ const { handle, beginRequest } = options;
116
+
117
+ return async function fetchFromWorker(
118
+ request: Request,
119
+ env: EdgeEnvironment,
120
+ ctx?: ExecutionContext,
121
+ ): Promise<Response> {
122
+ const lifecycle = beginRequest(request);
123
+ try {
124
+ return await lifecycle.run(async () => {
125
+ const assets = env?.ASSETS;
126
+ const method = request.method.toUpperCase();
127
+ if (assets != null && (method === "GET" || method === "HEAD")) {
128
+ const asset = await assets.fetch(request);
129
+ if (asset.status !== 404) return asset;
130
+ }
131
+ return await handle(request);
132
+ });
133
+ } catch (error) {
134
+ // The same 500 `./node.js`'s `nodeListener` writes, and for the same
135
+ // reasons: the body must not carry the stack, because the body goes to
136
+ // whoever asked, and the console is where the operator is already
137
+ // looking. Without this the answer would be Cloudflare's own error page,
138
+ // which is a different answer from `uf start`'s for the same failure —
139
+ // and the whole claim of the seam is that there is one answer.
140
+ console.error(error);
141
+ return new Response("500 Internal Server Error\n", {
142
+ status: 500,
143
+ headers: { "content-type": "text/plain; charset=utf-8" },
144
+ });
145
+ } finally {
146
+ // Scheduled rather than awaited: awaiting it here would hold the
147
+ // response back until every `after()` callback had finished, which is
148
+ // the opposite of what `after()` is for. Where there is no `ctx` — a
149
+ // test, or a host that calls this directly — it is awaited, because
150
+ // dropping the promise would lose both the work and its rejection.
151
+ if (ctx != null) {
152
+ ctx.waitUntil(lifecycle.settle());
153
+ } else {
154
+ await lifecycle.settle();
155
+ }
156
+ }
157
+ };
158
+ }