@uniflowed/server
class
OutsideRequestError
export class OutsideRequestError extends Error { ... }
Raised when a server function is called with no request to answer about.
Names the binding, because "no request context" on its own leaves a reader hunting for which of the six things they called was the one out of place.
function
export function headers(): HeaderStore { ... }
The request's headers, read-only.
Read-only because a response header set from inside a render has no defined moment to take effect: the headers may already be on the wire by the time a component deep in the tree renders.
function
cookies
export function cookies(): CookieStore { ... }
The request's cookies, read-only.
Setting a cookie belongs to a route handler or a server action, which run before a response exists and can say so in it.
function
nonce
export function nonce(): string { ... }
This response's Content-Security-Policy nonce.
The string to put in the policy *and* on any inline <script> the application writes itself, so that the two cannot disagree: they are the same value read twice rather than two values generated separately. uf puts it on every script it emits for this response without being asked.
# Asking for it is what turns it on
A request has no nonce until something calls this, and that is deliberate. A nonce is worth nothing to a response whose policy does not name one, and a document that carries one can never be served from a shared route cache — so a project that never calls this gets the documents it has always had and a cache that still works. app.router.headers calls it on the project's behalf when a rule's value names {uf.nonce}, which is the short way to set the header and the markup from one place.
# It counts as reading request state
For the reason requestId() does, and with more force. A nonce is valid for exactly one response; a route cache that stored a document carrying one would hand every later visitor a nonce minted for somebody else, and a nonce two responses share is a nonce that has stopped being a nonce. So a render that read this is never stored, which is the promise docs/security.md makes in the row this binding exists for.
# Where it does not exist
A static prerender has no request and therefore no nonce, and this throws there rather than inventing one — uf build writes a file, and a per-request value written into a file is a value that is wrong for every request after the first. A prerendered route under a nonce policy needs hashes instead; docs/security.md says so and infra/cloudflare/workers/docs.js is a worked example.
function
draftMode
export function draftMode(): DraftMode { ... }
Whether this request is rendering draft content, and how to change that.
The flag lives on the request rather than in a module, so two requests being handled at once cannot see each other's answer. It is read from a signed cookie when the request begins, so a guard, a route handler and the page underneath them agree about it — and so that it is true at all, which it never was before ubugeeei-prod/uf#282: nothing wrote the flag to a response and nothing read it from one, so enable() mutated an object that was discarded when the response was sent.
# enable() is a route handler's to call, or an action's
It writes a cookie, and a cookie is part of a response. headers() and cookies() above are read-only for the reason in their own paragraphs — a response header set from inside a render has no defined moment to take effect — and draft mode is the one case that needs the exception, so the exception is given exactly where a response is being produced and refused everywhere else with [DraftModeError]. That is Next's rule and it is Next's reason; what differs is that uf can name the two places in the error.
The flow it exists for is one route handler:
// app/api/preview/$route.js export function GET(request: Request): Response { const url = new URL(request.url); if (url.searchParams.get("token") !== process.env.CMS_PREVIEW_TOKEN) { return new Response("no", { status: 401 }); } draftMode().enable(); return Response.redirect(new URL(url.searchParams.get("to") ?? "/", request.url), 307); }
uf checks that the cookie it later receives is one it issued and has not expired. It does **not** check who asked for it: the handler above is the authorization, and the token comparison in it is the application's to write, because only the application knows what a CMS editor is. A handler that enables draft mode with no check is an open door, and this documentation is the only place that can say so.
# What it changes
A draft request is never answered from the route cache and never from a prerendered document on disk, because both are answers about a moment before the draft existed. packages/server/fetch.js has the first half and ./node.js's static handler the second.
function
requestId
export function requestId(): string { ... }
What this request is called, everywhere it is mentioned.
The same string the host puts in the request's log line, so a page that renders it into an error message gives whoever hit the error something they can quote and an operator something they can search for. It is created when the request arrives and is readable from a loader, from a route handler and from a server component's render, without any of them being handed a request — which is only possible because it lives on the request context rather than in a module, and is the whole of ubugeeei-prod/uf#506's harder half.
# It counts as reading request state, and logger() does not
This one goes through require$VaryingContext, which is the difference between the two bindings and is not a detail. A component that renders the request id has rendered a document that is true of exactly one request; a route cache that stored it would answer every later visitor with the first one's id, which is the same failure as a cached Set-Cookie in a smaller hat. So reading it makes the render uncacheable, exactly as cookies() does.
logger() below does not count, because the id it binds goes into a log line rather than into the document, and a page that logs must not thereby become a page uf refuses to cache.
function
logger
export function logger(): Logger { ... }
Somewhere to say something about this request.
The process logger — whatever @uniflowed/server/log's installLogger was last given — with this request's id and matched route already bound, so a line written from six levels down inside a render can be joined to the request that caused it without anybody threading anything through.
# It does not throw outside a request
Every other binding in this module does, and the argument for that is in the header: they answer *about* a request, and outside one there is no honest answer to give. A logger is the exception because outside a request there is an honest answer — the same logger, writing the same line, without a request id on it. The alternative is a package whose logging call is the one call you cannot make from the code that handles a failure, which is where logging is worth the most.
The route is read at the moment a line is written rather than bound once, and so is the process logger. Both change under a caller that is holding one of these: a loader logs before the render has begun and a component logs after the router has matched, so a route captured at construction would be null on lines of a request whose route is perfectly well known — and a host that calls installLogger after something has already taken a logger would otherwise be sending part of its output to the sink it replaced.
function
after
export function after(callback: () => mixed | Promise<mixed>): void { ... }
Run callback once the response has been sent.
For the work a request causes but a response does not wait on: recording a view, flushing a metric, warming a cache. Registered work runs in the order it was registered, and one task failing does not stop the others — deferred work is by definition not what the response depended on.
It is the small version of a queue and the line between them is durability, not size. Deferred work lives in this process, is not written down anywhere, and is gone when the process is — which is right for a metric and wrong for anything a user would notice missing. @uniflowed/server/queue is the other side of that line, and says what a deployment has to bring to it.
"Sent" is the host's word to keep, and it keeps it: the request is drained after uf dev has written the document, after uf preview and uf start have returned from send, and after a compiled binary's pipe has resolved on the last byte. One list per request, whether the callback was registered by a middleware, a route handler or a page. It was not always so — see ubugeeei-prod/uf#389 for what it meant before, and @uniflowed/server/host's beginRequest for the half a host supplies.
type
export type HeaderStore = {
readonly get: (name: string) => string | null,
readonly has: (name: string) => boolean,
};
A read-only view of one request's headers.
type
CookieStore
export type CookieStore = {
readonly get: (name: string) => string | null,
readonly has: (name: string) => boolean,
};
A read-only view of one request's cookies.
type
DraftMode
export type DraftMode = {
readonly isEnabled: boolean,
readonly enable: () => void,
readonly disable: () => void,
};
Whether this request is rendering draft content, and how to change that.
type
LogLevel
export type LogLevel = "debug" | "info" | "warn" | "error";
How much a record has to matter before it is written.
type
LogFields
export type LogFields = { +[string]: mixed };
The varying half of a record: everything that is not the constant message.
type
Logger
export type Logger = {|
readonly debug: (message: string, fields?: LogFields) => void,
readonly info: (message: string, fields?: LogFields) => void,
readonly warn: (message: string, fields?: LogFields) => void,
readonly error: (message: string, fields?: LogFields) => void,
/** A logger that adds `fields` to everything written through it. */
readonly child: (fields: LogFields) => Logger,
/** The threshold this logger writes at, for a caller that wants to skip work. */
readonly level: LogLevel,
|};
Somewhere to say something.
child is the reason this is an object rather than four functions: a request has an id and a route that belong on every line it produces, and the alternative to binding them once is passing them at every call, which is the same as not having them.
class
DraftModeError
export class DraftModeError extends Error { ... }
Raised when enable() or disable() is called where no response is owned.
Named rather than generic because the fix is a move, not an edit: the call belongs in a route handler or a server action, and the message says so. The same refusal Next makes, for the same reason — a render has no defined moment at which a response header takes effect, because the headers may already be on the wire by the time a component six levels down runs.
class
PostponedReadError
export class PostponedReadError extends Error { ... }
A read of the request, made while uf build was prerendering a page it may finish per request.
Nothing should catch it. A component that does has rendered something other than what it would render with a request, into a document every request is then sent, and the boundary it was meant to leave as a hole is not one.
function
authorizeNativeAction
export function authorizeNativeAction(
request: Request,
verify: (token: string, actionId: string) => Promise<boolean>,
): Promise<boolean> { ... }
Application-owned token verification, bound to exactly one action request.
@uniflowed/server/adapter
variable
HANDLER_FILE
export const HANDLER_FILE = "handler.js";
The file in an artefact that is the application.
variable
STATIC_DIRECTORY
export const STATIC_DIRECTORY = "static";
The directory in an artefact that holds the files a host serves first.
type
HandlerModule
export type HandlerModule = {|
readonly fetch: (request: Request) => Promise<Response>,
readonly beginRequest: (request: Request) => RequestLifecycle,
readonly routing?: RoutingRules,
|};
What handler.js exports, as its default export and as named exports.
fetch is the application: middleware, server actions, payloads, route handlers and rendering, in that order, over a Request. It touches no filesystem and holds no socket, so it runs on any runtime that has Request, Response and ReadableStream. It refuses a request that names another build with a 409 on its own — a host has nothing to do for version skew beyond serving its files first.
beginRequest is the handler's own, and a host must use this one: the request store belongs to the copy of @uniflowed/server bundled into the application, and a request begun through any other copy is one the application cannot see — cookies() would throw inside every page.
routing is app.router's redirects, rewrites, headers, base path and trailing-slash policy, as the build read them, for a host that puts them in front of its own files. Absent means none.
type
HostAnswer
export type HostAnswer = (
request: Request,
sent: (settled: () => Promise<void>) => void,
) => Promise<Response>;
One request through the application, the way every host in this repository answers it: begun, run, and settled once the caller says the body is out.
sent is the host's line for "the response has been written" — the end of a Node response, or the platform's waitUntil. It is handed the promise of the settle rather than awaited here, because only the host knows where that line is.
function
handlerModule
export function handlerModule(loaded: mixed): HandlerModule { ... }
The module a host loaded, checked to be a uf handler.
Throws naming every field that is missing or of the wrong kind, so a host that loaded the wrong file — the wrapper instead of the handler, or a build from before beginRequest was exported — fails when it starts rather than on its first request.
function
answerWith
export function answerWith(handler: HandlerModule): HostAnswer { ... }
Obligations 3 and 4 over a handler: begin the request with the handler's own beginRequest, run fetch inside it, and hand the settle to the host.
The files (obligations 1 and 2) are the host's, before this; the cookies and the streaming (5) are how the host writes what this returns; and a thrown error (6) is answered here with the bare 500 every host in this repository writes, so the host has one shape to handle rather than two.
type
RequestLifecycle
export type RequestLifecycle = {|
/** The context `run` establishes, for a host that needs to read it. */
readonly context: RequestContext,
/** Run the whole request inside it. */
readonly run: <T>(body: () => Promise<T>) => Promise<T>,
/** The response has gone: run what `after()` deferred. */
readonly settle: () => Promise<void>,
|};
One request, from the moment a host has one to the moment its bytes are gone.
Two functions rather than one, because they are called from two places and that is the whole point rather than an inconvenience. run wraps everything that *decides* the response — the guard, the dispatcher, the render — and settle happens after the response has been *written*, which in every host uf has is a different line in a different module. A single handle(request, body) that drained when body returned would be the bug this exists to fix, spelled once instead of twice.
type
RoutingRules
export type RoutingRules = {|
readonly redirects?: $ReadOnlyArray<RedirectRule>,
readonly rewrites?: $ReadOnlyArray<RewriteRule>,
readonly headers?: $ReadOnlyArray<HeaderRule>,
readonly basePath?: string,
readonly trailingSlash?: TrailingSlash,
|};
What the server bundle exports as routing.
@uniflowed/server/cache
class
OutsideCacheScopeError
export class OutsideCacheScopeError extends Error { ... }
Raised when something that only means anything inside a cached fill is called outside one.
Names the binding, for the same reason OutsideRequestError does: "no cache scope" leaves a reader hunting for which call was the one out of place.
class
OutsideCachedRequestError
export class OutsideCachedRequestError extends Error { ... }
Raised when the cache is asked about from outside a request that has one.
function
createCacheStore
export function createCacheStore(options?: CacheStoreOptions): CacheStore { ... }
A cache.
A function rather than the constructor as the front door, so the store can grow a second implementation — a durable one, from an adapter — without every caller having named a class. That second implementation turned out to be an option rather than a class: pass provider and build and the same store keeps its entries where the provider puts them.
function
cacheFunction
export function cacheFunction<Args extends $ReadOnlyArray<mixed>, Result>(
name: string,
produce: (...args: Args) => Promise<Result>,
options: CachedFunctionOptions,
): (...args: Args) => Promise<Result> { ... }
Cache public function results across routes, with an explicit stable identity.
function
updateTag
export function updateTag(tag: string): Promise<void> { ... }
Expire a tag and finish its durable invalidation before a read-after-write.
function
cacheLife
export function cacheLife(lifetime: CacheLifetime): void { ... }
How long the entry this render is filling stays usable.
Called from inside the render, by whatever knows the answer — a loader knows how often its data changes and the handler above it does not. Called twice, the *shorter* lifetime wins: a page composed of a thing that changes hourly and a thing that changes by the minute is a page that changes by the minute, and taking the longer one would serve the fast half stale for an hour.
function
cacheTag
export function cacheTag(...tags: $ReadOnlyArray<string>): void { ... }
Label the entry this render is filling, so revalidateTag can reach it.
Tags are what make invalidation a statement about meaning rather than about spelling: a mutation says "posts changed" and every entry that read a post goes, without the mutation knowing which URLs those were.
function
noStore
export function noStore(reason: string = "noStore() was called"): void { ... }
Refuse to store the entry this render is filling.
reason is kept because the interesting question about an uncached page is never "is it cached" but "why is it not", and the answer is usually six frames down in somebody else's module. The first reason wins: what stopped an answer being stored is the first thing that did.
type
CacheDeclarations
export type CacheDeclarations<T> = {|
readonly value: T,
/** The shortest lifetime `cacheLife` was called with, or `null`. */
readonly lifetime: CacheLifetime | null,
/** Every tag `cacheTag` named, once each. */
readonly tags: $ReadOnlyArray<string>,
/** Why `noStore` was called, or `null` when it was not. */
readonly denied: string | null,
|};
What a render said about the entry it would fill, beside what it produced.
function
collectCacheDeclarations
export function collectCacheDeclarations<T>(
body: () => Promise<T>,
): Promise<CacheDeclarations<T>> { ... }
Run body as a fill that stores nothing, and answer what it declared.
For a host that renders outside any request and still has to honour what the render said, which is uf build prerendering a page. Inside this, cacheLife, cacheTag and noStore are statements the build can read rather than calls that throw for want of a scope — and a page that stated a lifetime is one the build can write for regeneration instead of as a document that never changes.
Nothing is kept. What to do with a declaration is the caller's decision, and a build that stored entries in a process that is about to exit would be doing work for nobody.
function
revalidateTag
export function revalidateTag(tag: string): number { ... }
Expire every entry filled under tag. Answers how many went **here**.
Expired, not marked stale: a tag is invalidated because somebody changed the thing it names, so the entry is known wrong rather than possibly old. See ./internal/cache-store.js, which argues the difference.
The number is this process's, and with a durable store that is a smaller number than the invalidation. The store this process shares with the other three loses every entry carrying the tag; what this counts is the copies in *this* process's memory, because that is the only quantity available without making a mutation handler wait on a disk to find out an integer it is going to put in a log. Nothing is lost by that: the durable half is finished before the request is, which is what [carryDurableWork] arranges.
With no durable store this is exactly what it always was — a count of what one process forgot, in a deployment where that is all there is to forget.
function
revalidatePath
export function revalidatePath(path: string): number { ... }
Expire every entry filled for path. Answers how many went here.
function
requestCache
export function requestCache(): CacheOptions | null { ... }
The cache answering this request, or null where the host installed none.
type
CachedRequestOptions
export type CachedRequestOptions = {
readonly method?: string,
readonly searchParams?: { readonly [string]: string | number | boolean },
/** Absent means "do not cache this", which is the default and stays it. */
readonly cache?: FetchCacheOptions,
...
};
A request, plus what it says about caching itself.
type
CacheableClient
export type CacheableClient = {
readonly request: <T>(path: string, options?: CachedRequestOptions) => Promise<T>,
...
};
The half of a fetch client this module calls.
Declared structurally rather than imported from @uniflowed/fetch, and that is a decision rather than an oversight. @uniflowed/fetch's own header says what it is — a failed response that is a failed promise, a timeout, and a retry policy — and a cache is none of those three; putting one inside it would make the thin wrapper thick, and would put a server cache in a package a client bundle imports. So the caching lives on the server side and reaches the client through the one method it calls, exactly as ./internal/application.js names the two methods of a Node stream rather than importing node:stream into a module a worker has to bundle.
type
FetchCacheOptions
export type FetchCacheOptions = {|
readonly lifetime: CacheLifetime,
readonly tags?: $ReadOnlyArray<string>,
/** Overrides the key built from the client's name, the method and the URL. */
readonly key?: CacheKey,
|};
What one cached request states about its entry.
type
CachedFetchClient
export type CachedFetchClient = {|
readonly request: <T>(path: string, options?: CachedRequestOptions) => Promise<T>,
|};
A client that caches the requests which ask to be cached.
type
CachedFetchOptions
export type CachedFetchOptions = {|
readonly client: CacheableClient,
/**
* What distinguishes this client from another one in the same store.
*
* Required, and it is the one piece of ceremony here worth defending: two
* clients with different `baseURL`s both request `/users`, and a key built
* from the path alone would file one client's answer under the other's name.
* A client does not publish its `baseURL`, so the caller names it.
*/
readonly name: string,
/** Defaults to the store the host installed for this request. */
readonly store?: CacheStore,
|};
Everything the fetch cache needs.
function
createCachedFetch
export function createCachedFetch(options: CachedFetchOptions): CachedFetchClient { ... }
A fetch client whose cacheable requests are cached.
Opt-in per call, and that is the whole safety argument: a request with no cache option behaves exactly as the underlying client's does, so wrapping a client changes nothing until somebody states a lifetime for one request. There is no "cache every GET" mode, because every GET is not cacheable and a framework guessing which ones are is how a cache serves one person's account page to another.
With no store — rendering.cache.fetch off, or outside a request — every call passes straight through. Slower, never wrong.
type
CacheKey
export type CacheKey = $ReadOnlyArray<string>;
A cache key, as a caller writes it: ["route", "GET", "/posts"].
type
CacheLifetime
export type CacheLifetime = {|
/** Seconds after which the entry is stale. */
readonly revalidate: number,
/**
* Seconds after which it may not be served.
*
* Defaults to `revalidate` — no stale-while-revalidate unless asked for.
*/
readonly expire?: number,
|};
How long an entry stays fresh, and how long it may be served at all.
type
CacheEntry
export type CacheEntry<T> = {|
readonly value: T,
readonly storedAt: number,
readonly revalidateAt: number,
readonly expiresAt: number,
readonly tags: $ReadOnlyArray<string>,
readonly path: string | null,
|};
One stored answer.
type
CacheRequest
export type CacheRequest = {|
readonly key: CacheKey,
readonly lifetime?: CacheLifetime,
readonly tags?: $ReadOnlyArray<string>,
readonly path?: string,
/**
* An entry to start this key from when neither memory nor the provider holds
* one.
*
* For a prerendered page, the document the build wrote, dated the moment it
* was rendered: fresh until its own `revalidate`, then refreshed like any
* other entry. Asked at most once per key for the life of the store, because
* an entry that was refreshed or invalidated must never come back from the
* build; see [`CacheStore.seedFrom`].
*/
readonly seed?: () => Promise<CacheEntry<mixed> | null>,
/**
* Keep an entry servable past `revalidate` until a refresh replaces it.
*
* What a fill that states no `expire` gets instead of `expire = revalidate`.
* It is incremental static regeneration's contract rather than this store's
* default, and the difference is one fact: a prerendered page always has an
* answer, because the build wrote one, so a reader after the lifetime gets
* the current document and starts one refresh instead of waiting on a
* render. A fill that does state `expire` still means it.
*/
readonly staleUntilReplaced?: boolean,
|};
What a caller says about the entry before it is filled.
type
CacheOutcome
export type CacheOutcome = "hit" | "stale" | "miss" | "coalesced" | "uncacheable";
How a value was arrived at, for a caller that wants to report it.
type
CacheResult
export type CacheResult<T> = {|
readonly value: T,
readonly outcome: CacheOutcome,
/** Whether this call left an entry behind. */
readonly stored: boolean,
|};
A value, and how it was arrived at.
type
CacheStats
export type CacheStats = {|
readonly hits: number,
readonly stale: number,
readonly misses: number,
readonly coalesced: number,
readonly fills: number,
readonly evictions: number,
readonly invalidations: number,
/**
* Entries this process took out of a durable provider rather than rendering.
*
* Counted separately from `hits` rather than folded into them, because the
* two answer different questions: `hits` is whether the cache is working and
* this is whether *persistence* is — a process that restarts into a warm
* store and one that restarts into an empty one look identical on `hits`
* alone, and which of the two happened is the whole claim of a durable cache.
* Always zero with no provider.
*/
readonly restored: number,
/**
* Entries this process handed to a durable provider. Zero with none.
*
* Counted when the write is started rather than when it lands, because it is
* not awaited — see [`CacheStore.persist`] — and a counter that waited would
* be a counter that made the request wait. A write that then failed is
* reported through `onError`, which is where a failure belongs.
*/
readonly persisted: number,
/**
* Entries this process started from a request's `seed` rather than from a
* render: for a regenerated page, the document the build wrote. Zero when no
* request carried one.
*/
readonly seeded: number,
|};
Running totals, for a benchmark or a report.
type
CacheOptions
export type CacheOptions = {|
readonly store: CacheStore,
/** `rendering.cache.route`: whether a rendered document may be stored. */
readonly route?: boolean,
/** `rendering.cache.fetch`: whether a cached fetch client may use the store. */
readonly fetch?: boolean,
/** `rendering.cache.data`: cache explicitly wrapped public functions. */
readonly data?: boolean,
|};
What a host installed for one request, from rendering.cache.
Declared here rather than in ../cache.js so that ./context.js can name it without importing the module that imports ./context.js. A type-only cycle is harmless and an import cycle between two modules a request goes through is not worth finding out about later.
type
CacheStoreOptions
export type CacheStoreOptions = {|
/**
* The clock, in milliseconds.
*
* Injectable because staleness is the only thing in here that is a fact
* about time, and a test that drives it with a real clock is a test that
* sleeps — which is a test that flakes on a loaded machine. Defaults to
* `Date.now`.
*/
readonly now?: () => number,
/** Most entries held at once. Defaults to 1024. */
readonly maxEntries?: number,
/**
* Where a background refresh's failure goes.
*
* It has nowhere else to go: nobody is awaiting it, so without this it is an
* unhandled rejection that takes the process down on a strict runtime. A
* durable write's failure and a value that cannot be encoded go here too, for
* the same reason and with the same consequence: the answer was correct, the
* cache is colder than it meant to be, and nothing that a request can see
* changed.
*/
readonly onError?: (error: mixed) => void,
/**
* Somewhere entries outlive this process.
*
* Absent is the default and is the whole store as it was: memory, one
* process, emptied by a restart. Present makes it incremental static
* regeneration — see the module header — and obliges `build` below.
*
* A provider is an object rather than a name, and that is the replaceability
* red line rather than a convenience. `docs/red-lines.md` line 3 says every
* built-in provider must be replaceable and that "a provider a project can
* replace has to be a name it can write"; here the *host* writes it, in
* JavaScript, at the point it constructs the store — so a deployment adapter
* with a Redis, a KV namespace or an S3 bucket puts it behind this seam
* without uf shipping a release, an enumeration, or the word Redis.
*/
readonly provider?: CacheProvider,
/**
* The identity of the build whose code fills these entries.
*
* Required whenever `provider` is given, refused as empty, and unused
* without one. `./cache-key.js` argues why at length: a durable entry
* outlives the build that produced it, so the build is the first member of
* every durable key, and a deploy therefore reads a cold cache rather than
* the previous build's documents.
*
* Two processes of the *same* build must agree on it — that is what makes
* four instances one cache — so it is minted once where a build is minted and
* carried in the artefact, not generated per process. `uf build` writes one;
* `UF_BUILD_ID` overrides it, exactly as it does for the build id
* `crates/uf_rsc` derives server action ids from.
*/
readonly build?: string,
|};
How a store behaves.
class
CacheStore
export class CacheStore { ... }
A cache: resolve a key, invalidate by tag or by path.
Explicit rather than a module-level default, for the reason @uniflowed/query's cache is: a singleton is shared with every other test in the process, and one test's cached answer then decides another's. On a server it is worse than a flake — two requests being answered at once must not be able to reach each other's data by accident — so a store is constructed by whoever owns the process and handed to whoever answers a request.
type
DurableCacheEntry
export type DurableCacheEntry = {|
/** The value, through [`encodeCacheValue`]. Opaque to the provider. */
readonly value: string,
readonly storedAt: number,
readonly revalidateAt: number,
readonly expiresAt: number,
/** What `invalidateTag` matches on. */
readonly tags: $ReadOnlyArray<string>,
/** What `invalidatePath` matches on. */
readonly path: string | null,
|};
One stored answer, as it survives a process.
type
CacheProvider
export type CacheProvider = {
/**
* What this provider is, for a log and for `uf explain`.
*
* Required so that "the cache is durable" is a claim somebody can check from
* outside the process rather than a promise in a config file.
*/
readonly name: string,
/** The entry under `key`, or `null`. Never throws for a miss. */
read(key: string): Promise<DurableCacheEntry | null>,
/** Put `entry` under `key`, replacing whatever was there. */
write(key: string, entry: DurableCacheEntry): Promise<void>,
/** Drop `key`. Not an error when there was nothing under it. */
remove(key: string): Promise<void>,
/** Expire every entry carrying `tag`. Answers how many went. */
invalidateTag(tag: string): Promise<number>,
/** Expire every entry filled for `path`. Answers how many went. */
invalidatePath(path: string): Promise<number>,
/** Drop everything. For a test, and for a host tearing a store down. */
clear(): Promise<void>,
...
};
Somewhere entries outlive the process.
Five methods, all asynchronous, none of them optional. Asynchronous because the implementations worth having are a disk, a socket and an HTTP request, and an interface that let one of them be synchronous would be an interface the other two cannot satisfy. Not optional because a provider missing one is a cache that silently stops invalidating, which is the failure the whole seam exists to make impossible — a method that is expensive for some implementation is still a method it has to answer, and answering it badly is that implementation's decision to defend rather than uf's to guess.
Nothing here throws for a miss. A read that finds nothing answers null, and a remove for a key that is not there is not an error — both are ordinary in a store several processes are writing to at once.
A provider *may* throw for a real failure — a disk that is full, a Redis that is gone — and the store treats that the way it treats a failed background refresh: reported through onError, and the request answered from the render rather than from the cache.
class
UnserialisableCacheValueError
export class UnserialisableCacheValueError extends Error { ... }
Raised when a value cannot be stored durably without being changed.
function
encodeCacheValue
export function encodeCacheValue(value: mixed): string { ... }
A value as one string, or a refusal.
JSON, with one addition and one rule, over a walk of uf's own rather than JSON.stringify's replacer.
The addition is Uint8Array, which is what a rendered document's body is and which plain JSON turns into an object of six thousand numeric keys with no error anywhere.
The rule is that **anything JSON would change on the way through is refused**. JSON.stringify drops a function from an object without saying so, turns a Date into a string through its toJSON, and turns a Map into {} — and ./cache-key.js has already argued this exact point about @uniflowed/query's key: a serialisation that quietly does something is a trap you are told to avoid rather than prevented from writing. In memory none of it matters, because the entry *is* the value. Durably it is the difference between the answer a route was cached with and a different answer wearing the same name, so it throws and ./cache-store.js keeps the entry in memory instead.
# Why the walk is written out and not a replacer
Two reasons, and the second one was a bug before it was a reason.
A replacer is handed a member *after* toJSON has run, so a Date arrives as a string and the refusal above cannot see it. Reaching back through the replacer's this for the original works and is the sort of thing that is true until somebody tidies it.
And escaping has to be top-down. An object that would decode as one of uf's own tagged shapes is wrapped in another one — see [tagged] — and JSON.parse's reviver runs *bottom-up*, so it would unwrap the inner shape before it ever saw the wrapper and hand back something that was never stored. A walk that controls its own order has no such corner.
undefined is not refused: it is a value a loader legitimately produces, and wrapping in {v: …} rather than stringifying the value directly is what makes it survive.
function
decodeCacheValue
export function decodeCacheValue(text: string): mixed { ... }
The value [encodeCacheValue] wrote.
Without a doc comment
@uniflowed/server/cache/kv
variable
KV_BINDING
export const KV_BINDING = "UF_CACHE";
The binding uf build --adapter edge writes into wrangler.json.
type
KvNamespace
export type KvNamespace = {
get(key: string, type: "text"): Promise<string | null>,
put(key: string, value: string, options?: {| expiration?: number |}): Promise<void>,
delete(key: string): Promise<void>,
list(options: {| prefix: string, cursor?: string |}): Promise<{
readonly keys: $ReadOnlyArray<{ readonly name: string, ... }>,
readonly list_complete: boolean,
readonly cursor?: string,
...
}>,
...
};
The part of a Workers KV namespace this provider uses.
Declared structurally, as ./edge.js declares the assets binding: the runtime supplies the object, and naming the whole of Cloudflare's type here would be this package holding a copy of another project's types.
type
KvCacheOptions
export type KvCacheOptions = {|
/** The binding the namespace is under. [`KV_BINDING`] unless given. */
readonly binding?: string,
/**
* A namespace to use instead of the request's binding.
*
* For a test, and for a host that is not a Worker but has a KV client of its
* own. A Worker passes nothing and is answered from `env`.
*/
readonly namespace?: KvNamespace,
/** What every key starts with, so one namespace can hold more than this. */
readonly prefix?: string,
|};
How a KV provider finds its namespace and names its keys.
class
KvBindingMissingError
export class KvBindingMissingError extends Error { ... }
Raised when a Worker has no namespace under the binding a provider was told to use.
function
createKvCache
export function createKvCache(options?: KvCacheOptions): CacheProvider { ... }
A durable cache provider over the Workers KV namespace options names.
function
createCacheProvider
export function createCacheProvider(_options?: {|
readonly build?: string,
readonly directory?: string,
|}): CacheProvider { ... }
The module seam's spelling of [createKvCache].
rendering.cache.store names a module exporting createCacheProvider, and this is that export: the binding is [KV_BINDING] and the directory, which means nothing to a namespace, is ignored.
type
DurableCacheEntry
export type DurableCacheEntry = {|
/** The value, through [`encodeCacheValue`]. Opaque to the provider. */
readonly value: string,
readonly storedAt: number,
readonly revalidateAt: number,
readonly expiresAt: number,
/** What `invalidateTag` matches on. */
readonly tags: $ReadOnlyArray<string>,
/** What `invalidatePath` matches on. */
readonly path: string | null,
|};
One stored answer, as it survives a process.
type
CacheProvider
export type CacheProvider = {
/**
* What this provider is, for a log and for `uf explain`.
*
* Required so that "the cache is durable" is a claim somebody can check from
* outside the process rather than a promise in a config file.
*/
readonly name: string,
/** The entry under `key`, or `null`. Never throws for a miss. */
read(key: string): Promise<DurableCacheEntry | null>,
/** Put `entry` under `key`, replacing whatever was there. */
write(key: string, entry: DurableCacheEntry): Promise<void>,
/** Drop `key`. Not an error when there was nothing under it. */
remove(key: string): Promise<void>,
/** Expire every entry carrying `tag`. Answers how many went. */
invalidateTag(tag: string): Promise<number>,
/** Expire every entry filled for `path`. Answers how many went. */
invalidatePath(path: string): Promise<number>,
/** Drop everything. For a test, and for a host tearing a store down. */
clear(): Promise<void>,
...
};
Somewhere entries outlive the process.
Five methods, all asynchronous, none of them optional. Asynchronous because the implementations worth having are a disk, a socket and an HTTP request, and an interface that let one of them be synchronous would be an interface the other two cannot satisfy. Not optional because a provider missing one is a cache that silently stops invalidating, which is the failure the whole seam exists to make impossible — a method that is expensive for some implementation is still a method it has to answer, and answering it badly is that implementation's decision to defend rather than uf's to guess.
Nothing here throws for a miss. A read that finds nothing answers null, and a remove for a key that is not there is not an error — both are ordinary in a store several processes are writing to at once.
A provider *may* throw for a real failure — a disk that is full, a Redis that is gone — and the store treats that the way it treats a failed background refresh: reported through onError, and the request answered from the render rather than from the cache.
@uniflowed/server/edge
function
edgeCapabilities
export function edgeCapabilities(options?: CapabilityOptions): ServerCapabilities { ... }
What a Worker can do, plus whatever the deployment supplied.
Streams, and does not persist — the one split in this package where the two flags disagree, which is why they are two flags. A Worker writes a body as it is produced, so an event stream is exactly as good here as it is on Node. It is also an isolate the platform may tear down the moment the response is out, which is the whole reason after() on this target goes through ctx.waitUntil rather than being awaited — so an in-process queue is work pushed into something that may not be there to drain it, and ./internal/capabilities.js refuses one.
websocket is the deployment's. Cloudflare's upgrade is new WebSocketPair, server.accept() and a Response carrying webSocket — four lines, using two globals nothing outside a real Worker can produce, which is the same reason ./lambda.js does not implement response streaming.
function
installWorkerLogger
export function installWorkerLogger(userAgent: string | null = runtimeUserAgent()): void { ... }
Log through the console method each level is named for, as a Worker should.
The process logger's default writes every level to console.error, because in the process that runs uf start stdout is a protocol (see ./internal/log.js). A Worker has no such process, and its console is the log store: wrangler tail, Workers Logs and wrangler dev all file a line by the method that wrote it. Measured under wrangler dev, the default turned every request's info access line into an ERROR — a Worker answering 200s that its own platform reported as failing on every request.
Called by the generated worker.js after its imports, which is after the application's modules have been evaluated, and only takes effect when nothing chose a logger first: an application that installed its own keeps it.
# Only on a Worker
Nothing is installed unless userAgent is Cloudflare-Workers, which is what workerd's navigator.userAgent answers at the compatibility date uf writes. The same worker.js is also imported under Node: by uf's own test that every adapter answers what the node adapter answers, and by any application that tests its build that way. There console.info is stdout, so the default's reason applies again, and a harness reading answers from stdout would find access lines between them. userAgent is the runtime's unless a caller passes one, which is what a test does.
type
AssetsBinding
export type AssetsBinding = {
readonly fetch: (request: Request) => Promise<Response>,
...
};
The ASSETS binding, as much of it as this module uses.
One method, declared structurally: a worker's bindings are supplied by the runtime, and naming the whole of Cloudflare's Fetcher here would be this package holding a copy of another project's types.
type
EdgeEnvironment
export type EdgeEnvironment = {
readonly ASSETS?: AssetsBinding,
...
};
The env a Worker's fetch is called with.
Inexact, because a project's own bindings — a KV namespace, a secret — are in here too and are none of this module's business. ASSETS is optional because a Worker deployed without an assets directory has no such binding, and the honest answer for that deployment is "the application answers everything" rather than a TypeError on the first request.
type
ExecutionContext
export type ExecutionContext = {
readonly waitUntil: (promise: Promise<mixed>) => mixed,
...
};
The ctx a Worker's fetch is called with.
waitUntil only. passThroughOnException exists and is deliberately not used: it serves the origin's response when the script throws, and a Worker that *is* the origin has nothing to pass through to.
type
WorkerHandlerOptions
export type WorkerHandlerOptions = {|
/** The application, from the generated `handler.js`. */
readonly handle: (request: Request) => Promise<Response>,
/** That same module's `beginRequest`; see the header. */
readonly beginRequest: (request: Request) => RequestLifecycle,
/**
* That same module's `routing`: its redirects answer before the assets
* binding is asked, and its headers go on whatever answers. See
* `./internal/routing.js`.
*/
readonly routing?: RoutingRules,
|};
Everything the worker half needs to answer a request.
function
createWorkerFetch
export function createWorkerFetch(
options: WorkerHandlerOptions,
): (request: Request, env: EdgeEnvironment, ctx?: ExecutionContext) => Promise<Response> { ... }
A built uf application as a Worker's fetch.
Static assets first, then the application — the order uf preview cannot deviate from and therefore the order every other front door matches. The asset lookup is skipped for anything that is not a GET or a HEAD, which is what createStaticHandler does and for the same reason: a POST to a path that happens to have a file under it belongs to a route handler.
A 404 from the assets binding means "no such asset", not "the site has no such page": wrangler.json sets "not_found_handling": "none" so that the miss falls through to here, and the 404 a visitor sees is the project's own $not-found rendered by the application. Any other status is the asset's answer and is returned as it stands.
Except a **document** answering a **draft** request, which is ./internal/draft.js's prerenderedMayAnswer — the rule the other three front doors apply, applied here too, or draft mode would be a property of where an application was deployed. This door is the one that cannot decide what a document is before it looks: the assets binding is the platform's and uf holds no index of what is behind it, so the answer is read off the response it gave. A content-type of text/html is a document; everything else is a chunk, a stylesheet or an image, and those are the same bytes in draft mode as out of it.
type
ScheduledEvent
export type ScheduledEvent = {
/** The expression that fired, exactly as `wrangler.json` spells it. */
readonly cron: string,
readonly scheduledTime?: number,
...
};
What Cloudflare hands a scheduled() export.
function
createWorkerScheduled
export function createWorkerScheduled(options: {|
readonly handle: (request: Request) => Promise<Response>,
readonly beginRequest: (request: Request) => RequestLifecycle,
readonly routes: { readonly [cron: string]: string },
|}): (event: ScheduledEvent, env: mixed, ctx?: ExecutionContext) => Promise<void> { ... }
Cloudflare's scheduled() export, over the same application fetch answers.
The counterpart of [createWorkerFetch], and it makes the same promises in the same order: beginRequest, the whole of the work inside run, one line in the log, and settle handed to ctx.waitUntil where there is a ctx. A schedule that skipped any of those would be work the application could not see itself doing — no request context, so no cookies(), no after(), and no request id in the line it leaves behind.
# A schedule is a request the platform makes
There is no second dispatcher here. routes maps the expression Cloudflare fires to the route path that answers it, and this synthesises a GET to that path through the application's own handler — so a scheduled run and a curl of the same path are the same code, and a route handler needs to know nothing about schedules to be one.
GET because a cron has no body to send. uf build refuses a module that declares a schedule and exports no GET, so a trigger that could not be answered is a build that did not happen rather than a 405 nobody reads.
# A trigger with no route
Logged and dropped, not thrown. wrangler.json is a file a person can edit after uf writes it, and a cron added there by hand is not a reason to fail an invocation — but it is a reason to say so, because the alternative is a schedule that fires into silence.
type
RequestLifecycle
export type RequestLifecycle = {|
/** The context `run` establishes, for a host that needs to read it. */
readonly context: RequestContext,
/** Run the whole request inside it. */
readonly run: <T>(body: () => Promise<T>) => Promise<T>,
/** The response has gone: run what `after()` deferred. */
readonly settle: () => Promise<void>,
|};
One request, from the moment a host has one to the moment its bytes are gone.
Two functions rather than one, because they are called from two places and that is the whole point rather than an inconvenience. run wraps everything that *decides* the response — the guard, the dispatcher, the render — and settle happens after the response has been *written*, which in every host uf has is a different line in a different module. A single handle(request, body) that drained when body returned would be the bug this exists to fix, spelled once instead of twice.
@uniflowed/server/events
type
ServerSentEvent
export type ServerSentEvent = {|
/** The event's name, which is what a client listens for. */
readonly event?: string,
/** The payload. A newline in it becomes a second `data:` line. */
readonly data: string,
/** The cursor a reconnecting client sends back as `Last-Event-ID`. */
readonly id?: string,
/** Milliseconds a client should wait before reconnecting. */
readonly retry?: number,
|};
One event, as the fields the format has.
data is required and everything else is not, because an event with no data is a message a reader cannot act on — the format allows it and no application means it.
type
EventSink
export type EventSink = {|
/** Send one event. A bare string is the data with no name and no id. */
readonly send: (event: ServerSentEvent | string) => void,
/** Send a comment, which no reader sees. The heartbeat is one of these. */
readonly comment: (text: string) => void,
/** End the stream from this end. */
readonly close: () => void,
/**
* Aborted once the stream is over, however it ended.
*
* One signal for the client hanging up, `close()`, and the reader falling
* too far behind, because a producer's cleanup is the same in all three and
* a producer that had to tell them apart would get one of them wrong.
*/
readonly signal: AbortSignal,
|};
What a source is handed.
type
EventStreamSource
export type EventStreamSource = (sink: EventSink) => mixed;
What produces the events.
A returned function is run when the stream ends — the shape useEffect taught everybody — and sink.signal is there for a producer that would rather pass a signal to something than write a cleanup.
type
EventStreamOptions
export type EventStreamOptions = {|
/** Milliseconds between heartbeat comments. `0` sends none. Default 15000. */
readonly heartbeat?: number,
/** A `retry:` sent once, before anything else, in milliseconds. */
readonly retry?: number,
/** Extra response headers; the four this module sets cannot be replaced. */
readonly headers?: { readonly [string]: string },
/**
* Events the stream may hold for a reader that is behind. Default 64.
*
* Not a tuning knob so much as a ceiling: past twice this many the
* connection is closed. See the module header.
*/
readonly buffer?: number,
|};
How the stream behaves.
function
eventStream
export function eventStream(source: EventStreamSource, options?: EventStreamOptions): Response { ... }
An event stream, as a Response a route handler returns.
source is called once, immediately, with the sink it writes to. It may return a cleanup function, and it may be async — a rejection ends the stream with that error rather than becoming an unhandled rejection, which is the whole reason it is awaited at all.
# The reader that cannot keep up
buffer is the stream's high-water mark, so desiredSize is that number minus what is queued: it reaches zero when a reader is buffer events behind and -buffer when it is twice that. The second is the ceiling, and past it the connection is closed with an error rather than held — the alternative is a queue whose producer is an application and whose consumer is a phone on a train, which is the unbounded thing docs/security.md rule 4 is about. A source that would rather shed events than lose the reader can watch sink.signal and stop sending.
function
encodeEvent
export function encodeEvent(event: ServerSentEvent): string { ... }
One event, as the bytes that go on the wire.
Exported because the encoding is the part with the bugs in it and the suite should be able to drive it without a socket — the same reason @uniflowed/server/host exports the pieces of beginRequest.
A payload's newlines become further data: lines, which is what the format says and what nobody writes by hand. \r\n and a bare \r are normalised first: a reader splits on any of the three, so leaving them in would make one message into two on some clients and not others.
type
ServerCapabilities
export type ServerCapabilities = {|
/** The adapter's own name for itself: `node`, `edge`, `serverless`, `dev`. */
readonly target: string,
/**
* Whether a response body reaches the client as it is produced.
*
* False on a target that reads the whole body before answering — which is
* every serverless invocation in this package, because a Lambda response in
* payload format 2.0 is a JSON value. A document survives that as a slower
* document; an event stream does not survive it at all.
*/
readonly stream: boolean,
/**
* Whether the process is still there once the response has been written.
*
* False for a serverless invocation, which is billed until it returns and
* frozen afterwards, and false for a worker isolate, which the platform may
* tear down the moment the response is out — the reason `after()` on that
* target goes through `ctx.waitUntil` rather than being awaited.
*/
readonly persistent: boolean,
/** The host's upgrade, or `null` where it has none. */
readonly websocket: WebSocketUpgrader | null,
/** Where `enqueue` puts work, or `null` where the deployment named none. */
readonly queue: QueueBackend | null,
/**
* Where scheduled work runs from, or `null` where the deployment named none.
*
* Read by the adapters rather than by `../schedule.js`, the same way `queue`
* is: what the scheduler needs from a target is somewhere to be at the right
* minute, and whether that is uf's tick or the platform's own call is the
* one bit `triggered` carries.
*/
readonly scheduler: SchedulerBackend | null,
|};
What the host answering this request can do.
@uniflowed/server/fetch
type
FetchHandlerOptions
export type FetchHandlerOptions = {|
/** The server bundle, as imported. */
readonly app: Application,
/** The script, stylesheet and preload URLs a rendered document references. */
readonly document: DocumentAssets,
/**
* The cache this host installed, from `rendering.cache` in `uf.config.js`.
*
* Absent is the default and means no cache at all — every request renders,
* exactly as before this option existed. A host that passes one is saying
* two separate things with it, `route` and `fetch`, because the two switches
* in the configuration are two switches.
*/
readonly cache?: CacheOptions,
/**
* What this host can do, from the adapter that built it.
*
* Absent means nothing was said, which is what every request looked like
* before this option existed and is treated as such: an event stream is
* allowed, because a `Response` streams by default everywhere except where
* somebody said otherwise, and an upgrade and a queue are refused, because
* both are objects and there is no such object. `./internal/capabilities.js`
* argues that asymmetry.
*/
readonly capabilities?: ServerCapabilities,
/**
* The pages this build regenerates, from what `uf build` recorded.
*
* Absent for a build that regenerates nothing, which is every build whose
* prerendered pages stated no lifetime and no tag. See [`Regeneration`].
*/
readonly regeneration?: Regeneration,
/**
* `/__uf/image`, from `@uniflowed/server/image`'s `createImageEndpoint`.
*
* Absent unless `app.builtins.images.remotePatterns` lists something, and
* absent is no endpoint: the path is an ordinary 404 like any other. Asked
* before the middleware, for the reason given where it is asked.
*/
readonly images?: (request: Request) => Promise<Response | null>,
/**
* The pages this build prerendered partially, from what `uf build` recorded.
*
* Absent for a build that wrote no static shell. See [`PartialPrerenders`].
*/
readonly partial?: PartialPrerenders,
|};
Everything the application half needs to answer a request.
type
PartialPrerenders
export type PartialPrerenders = {|
readonly pages: { readonly [pathname: string]: PrerenderedShell },
|};
Every page uf build prerendered as a static shell with holes, by the pathname it was prerendered for.
A page is one of these when app.rendering.modes allows ppr, the build left a server behind, and the page read cookies(), headers() or draftMode() inside a <Suspense> boundary. The build writes no document at the page's URL — a shell on its own is a page whose holes never fill — so every request for it reaches this handler, which sends the shell before it renders anything and then streams the holes. See @uniflowed/router's internal/stream.js for the shell and resumeDocument.
type
RegeneratedPage
export type RegeneratedPage = {|
/** Where the build's copy is: a URL path the host's static half answers. */
readonly document: string,
/** When the prerender rendered it, in milliseconds since the epoch. */
readonly renderedAt: number,
/** `cacheLife`'s `revalidate`, in seconds. */
readonly revalidate: number,
/** `cacheLife`'s `expire`, in seconds, or `null` when it stated none. */
readonly expire: number | null,
/** What `cacheTag` named during the prerender. */
readonly tags: $ReadOnlyArray<string>,
|};
One page uf build prerendered and a server regenerates.
A page is one of these when its prerender stated a lifetime with cacheLife, called noStore nowhere, answered 200, and the project allows isr with rendering.cache.route on. The build then writes its document where no static half answers the page's own URL, so every request for it reaches this handler — and this handler answers it from that document until the page's lifetime has passed.
A tag alone does not make a page one of these. The route cache keeps nothing without a lifetime, and the reason holds here with more force: a regenerated page with no end is one another process could keep serving from its memory long after revalidateTag took it out of the shared store.
type
Regeneration
export type Regeneration = {|
readonly pages: { readonly [pathname: string]: RegeneratedPage },
|};
Every page a build regenerates, by the pathname it was prerendered for.
function
createFetchHandler
export function createFetchHandler(
options: FetchHandlerOptions,
): (request: Request) => Promise<Response> { ... }
The application half: middleware, then route handlers, then rendering.
Returns null for nothing, ever — a request that matches no handler and no route is a rendered 404, because the renderer is what knows what the project's $not-found page says.
The order is the dev server's, and has to stay the dev server's: middleware first, then server actions, then handlers for every method, because a handler is the only thing that can answer a POST and it may also answer a GET for a path that has no page. A page cannot answer a POST, so a non-navigation that no handler claimed is a 404 rather than a rendered page with a 200.
app.callAction is between the two, and this is the function that puts it on all four deploy targets at once: handler.js is byte-for-byte the same file in the node, container, edge and serverless artefacts, so an action endpoint that works here works in each of them or in none. It declines every request that carries no action id and answers every request that carries one, refusals included — so a POST naming an action never reaches a route handler that happens to sit at the same path, and a request naming none pays one header lookup. Called rather than tested for, for the reason app.runMiddleware is: a server bundle without it is a TypeError on the first request rather than an application whose actions quietly answer 404.
Middleware above both, and not inside either: it guards a path, so it has to run for a page, for a route handler, and for a path under it that matches neither — /dashboard/typo is a 404 that the guard on /dashboard still answers. app.runMiddleware is called rather than tested for, so a server bundle without it is a TypeError on the first request instead of an application whose auth check quietly stopped running once it was built. That is the whole of ubugeeei-prod/uf#260, and every host that reaches this function is one more place it could have happened.
# It must be called inside a request, and does not begin one
after() says "once the response has been sent", and this function has a Response in hand rather than a response on the wire — for a streamed body those are a document apart. So the host begins the request with app.beginRequest, runs this inside run, and settles it after the bytes: ./node.js's nodeListener does that for uf start and for the server.js an adapter writes, and @uniflowed/vite's withRequest does it for uf dev and uf preview.
A worker-shaped host is the one uf does not write, and it has the same two halves to place. settle is what to hand ctx.waitUntil where there is one; without one, the honest moment is when the response body stream closes — and a runtime that tears the isolate down at that moment drops the callback, which is worth saying out loud rather than leaving to be discovered.
A caller that forgets is not left to discover *that*, at least: app.runMiddleware refuses outside a request and names what establishes one. See ubugeeei-prod/uf#389.
# And the cache, if the host installed one
cache is rendering.cache from uf.config.js, and it does two separate things here. It is put on the request before the guard runs, so that a route handler or a server action calling revalidateTag() reaches the store that is answering this request; and, when route is on, a GET goes through [cachedDocument] instead of the streaming path. Both halves are argued in the module header and in ./cache.js. With no cache at all this function is what it has always been, one AsyncLocalStorage.run aside.
type
DocumentAssets
export type DocumentAssets = {|
readonly scripts: $ReadOnlyArray<string>,
readonly styles: $ReadOnlyArray<string>,
readonly preloads: $ReadOnlyArray<string>,
/**
* The build these URLs belong to, as the document publishes it.
*
* Written by `uf build` and absent everywhere else — `uf dev`, and a build
* recorded before it existed — which means "no skew check". A document
* carries it as `<meta name="uf:deployment">`, the browser sends it back on
* every action call and payload request, and a front door answers one that
* names another build with a `409` rather than running anything. See
* `./deployment.js`.
*/
readonly deployment?: string,
|};
The script, stylesheet and preload URLs a rendered document references.
type
RenderedDocument
export type RenderedDocument = {|
readonly status: number,
readonly headers?: { readonly [string]: string },
readonly pipe: (destination: WritableLike) => mixed,
readonly stream: () => ReadableStream<Uint8Array>,
|};
A document that has begun.
status and headers are known once the shell is ready, which is why a streaming renderer can still answer with a status line. The body arrives afterwards through exactly one of pipe and stream — each is a single pass over the same chunks, so calling both would read a document twice.
type
PrerenderedShell
export type PrerenderedShell = {|
readonly html: string,
readonly close: string,
readonly rootDepth: number,
readonly postponed: mixed,
|};
A page's static shell, as uf build recorded it and @uniflowed/router resumes it.
Stated here rather than imported, for the reason [RenderedDocument] is: this package links no renderer, and what crosses between the two is data. @uniflowed/router's internal/stream.js is where each field is argued.
type
Application
export type Application = {|
/** Render `url`, resolving when the shell is ready. */
readonly render: (
url: string,
assets: DocumentAssets,
options?: {|
readonly onError?: (error: mixed) => void,
/**
* React's `formState` for a page rendered in answer to a form posted
* before hydration, so the `useActionState` that submitted starts from
* the action's result. Only `callAction`'s `postback` passes one.
*/
readonly formState?: FormState,
|},
) => Promise<RenderedDocument>,
/**
* A route's Flight payload, for a browser that is navigating.
*
* Present on a bundle React Server Components render and absent on one
* rendered from its modules (`app.rsc: false`), whose browser navigates by
* importing routes and never asks. `url` is the document's path and query,
* not the payload's. `stream` is `null` for a redirect, whose `location`
* already names the target's payload. See `./flight.js`.
*/
readonly flight?: (
url: string,
options?: {|
readonly onError?: (error: mixed) => void,
readonly interceptedFrom?: string,
|},
) => Promise<{|
readonly status: number,
readonly headers: { readonly [string]: string },
readonly stream: ReadableStream<Uint8Array> | null,
readonly error?: mixed,
|}>,
/**
* Answer a page `uf build` prerendered partially: its static shell first,
* then its holes as this request renders them.
*
* Present on a bundle React Server Components render, which is the only kind
* that writes a shell. `shell` is what the build recorded for the page; see
* `../fetch.js`'s `PartialPrerenders`.
*/
readonly resume?: (
url: string,
assets: DocumentAssets,
shell: PrerenderedShell,
options?: {| readonly onError?: (error: mixed) => void |},
) => Promise<RenderedDocument>,
/** The route handler for this request, or `null` when no handler claims it. */
readonly dispatch: (request: Request) => Promise<Response | null>,
/**
* The server action this request names, or `null` when it names none.
*
* Between the guard and the handlers in every host, and it answers every
* request carrying an action id — including every refusal — so a `POST`
* naming an action can never fall through to a route handler at the same
* path. See `packages/router/internal/action-endpoint.js` for what it
* refuses and why, and `docs/security.md` for the boundary those refusals
* are keeping.
*
* Called rather than tested for, for the reason `runMiddleware` is: a
* server bundle without it is a `TypeError` on the first request rather than
* an application whose actions quietly stopped being reachable once it was
* built.
*/
readonly callAction: (
request: Request,
settings?: {|
/**
* Render the page this request is for with `formState`: the answer to a
* `useActionState` form posted before its page hydrated. A host passes
* it; without one such a post is answered with a `303` back to the page.
*/
readonly postback?: (formState: FormState) => Promise<Response>,
|},
) => Promise<Response | null>,
/**
* The guard on the path, run before anything under it answers.
*
* Called rather than tested for: a server bundle without it is a `TypeError`
* on the first request, not an application whose auth check quietly stopped
* running once it was built. See ubugeeei-prod/uf#260.
*
* Three answers: a `Response` answers, `null` carries on, and a `Request` is
* a middleware's `rewrite()` — the same request at another path, which the
* host carries on with instead and which has already been past that path's
* own middleware. See `packages/router/middleware.js`.
*/
readonly runMiddleware: (request: Request) => Promise<Response | Request | null>,
/**
* `app.router.redirects`, `rewrites` and `headers` from `uf.config.js`, as the
* build read them.
*
* On the bundle rather than read from the config where a host starts, so a
* served build answers with the rules it was built with. Optional, because a
* bundle from before the rules existed has none and means none. `./routing.js`
* is what every host asks about them.
*/
readonly routing?: RoutingRules,
/**
* Begin the request everything above runs inside.
*
* A host calls this, runs the whole of answering the request inside `run`,
* and calls `settle` once the response has been written — which is what
* `after()` means by "sent" and is a different line in every host.
*
* It is on the bundle rather than importable beside this type, and that is
* the one thing about it that looks wrong and is not: the request store is
* shared by every copy of one release of this package (see
* `./process-state.js`), and the release the application reads is the one
* bundled into its own `server.js`. A host that began a request through a
* copy of another release would fail silently — the guard would run, the page
* would render, and every `cookies()` in it would throw as though no host had
* run at all. See ubugeeei-prod/uf#389.
*/
readonly beginRequest: (request: Request) => RequestLifecycle,
|};
What the project's server bundle exports; see virtual:uf/server.
variable
export const DEPLOYMENT_HEADER = "uf-deployment";
The header a browser names its build in, and a refusal names this one in.
type
WebSocketUpgrade
export type WebSocketUpgrade = {|
/** The handshake response to return from the handler. */
readonly response: Response,
/** This end of the connection, already accepted. */
readonly socket: WebSocketLike,
|};
What a host's upgrade answered with: the response to send, and the socket.
type
WebSocketUpgrader
export type WebSocketUpgrader = (request: Request) => WebSocketUpgrade;
A host's WebSocket upgrade.
Supplied by the deployment rather than implemented here, and ../socket.js argues that at length: the runtimes uf targets spell this four incompatible ways, and Node does not spell it at all.
type
JobRecord
export type JobRecord = {|
readonly id: string,
/** The name `defineJob` gave, which is how a worker finds the function. */
readonly job: string,
/**
* The payload as JSON text.
*
* Text rather than a value, and that is the load-bearing decision in this
* whole type. Every backend a real deployment uses puts the record through a
* process boundary — a Redis list, an SQS message, a row — so an in-process
* backend that passed the object straight through would be the one backend
* where a `Date` is still a `Date`, a shared array is still shared, and a
* job that mutates its payload is visible to the caller. It would work
* locally and be wrong in production, which is the only kind of difference
* worth designing against.
*/
readonly payload: string,
/** 1 for the first run, 2 for the first retry. */
readonly attempt: number,
/** Epoch milliseconds before which this must not run. */
readonly notBefore: number,
|};
One unit of deferred work as it is stored; see ../queue.js.
type
QueueBackend
export type QueueBackend = {
/**
* Whether the work survives the process that pushed it.
*
* Read by the adapters rather than by the queue. An in-process backend on a
* host that keeps running is a legitimate small deployment; the same backend
* on a target that ends with the response is work that is dropped without a
* word. One field, so the difference is a value the wiring can be refused
* over rather than a paragraph somebody has to have read.
*/
readonly durable: boolean,
/** A name for this backend, for the message when one is refused. */
readonly name: string,
/** Take one record. Resolves when the backend has it, not when it has run. */
readonly push: (record: JobRecord) => Promise<void>,
...
};
Where enqueued work goes.
The producer half only. A backend that can also *run* work says so by having somewhere to call ../queue.js's runner from; nothing here requires it, because the deployment that stores the work is not always the process that drains it — which is the whole difference between a queue and after().
Inexact, and that is the point rather than laziness: a backend is written by the deployment and is entitled to carry whatever else it needs — a poll loop, a connection, the drain that ../queue.js's own in-process one exposes. These three are what uf reads.
type
ServerCapabilities
export type ServerCapabilities = {|
/** The adapter's own name for itself: `node`, `edge`, `serverless`, `dev`. */
readonly target: string,
/**
* Whether a response body reaches the client as it is produced.
*
* False on a target that reads the whole body before answering — which is
* every serverless invocation in this package, because a Lambda response in
* payload format 2.0 is a JSON value. A document survives that as a slower
* document; an event stream does not survive it at all.
*/
readonly stream: boolean,
/**
* Whether the process is still there once the response has been written.
*
* False for a serverless invocation, which is billed until it returns and
* frozen afterwards, and false for a worker isolate, which the platform may
* tear down the moment the response is out — the reason `after()` on that
* target goes through `ctx.waitUntil` rather than being awaited.
*/
readonly persistent: boolean,
/** The host's upgrade, or `null` where it has none. */
readonly websocket: WebSocketUpgrader | null,
/** Where `enqueue` puts work, or `null` where the deployment named none. */
readonly queue: QueueBackend | null,
/**
* Where scheduled work runs from, or `null` where the deployment named none.
*
* Read by the adapters rather than by `../schedule.js`, the same way `queue`
* is: what the scheduler needs from a target is somewhere to be at the right
* minute, and whether that is uf's tick or the platform's own call is the
* one bit `triggered` carries.
*/
readonly scheduler: SchedulerBackend | null,
|};
What the host answering this request can do.
type
CapabilityOptions
export type CapabilityOptions = {|
readonly websocket?: WebSocketUpgrader | null,
readonly queue?: QueueBackend | null,
readonly scheduler?: SchedulerBackend | null,
|};
What a deployment may hand an adapter; everything else is the target's.
class
CapabilityRefusedError
export class CapabilityRefusedError extends Error { ... }
Raised when a deployment is wired with something its target cannot do.
class
CapabilityUnavailableError
export class CapabilityUnavailableError extends Error { ... }
Raised when a request asks for something the host answering it cannot do.
The other half of the pair, and the one that catches what a wiring-time refusal cannot: a deployment with no WebSocket handler today is wired exactly as it will be on the day somebody adds one.
type
CapabilityDefaults
export type CapabilityDefaults = {|
readonly stream: boolean,
readonly persistent: boolean,
|};
How an adapter describes itself before the deployment's half is added.
function
capabilitiesFor
export function capabilitiesFor(
target: string,
defaults: CapabilityDefaults,
options?: CapabilityOptions,
): ServerCapabilities { ... }
The capabilities of target, with whatever the deployment supplied.
websocket and queue default to null and there is no default that would be better: those are the deployment's to supply, and inventing one would be uf pretending to own an implementation it does not have.
function
assertCapable
export function assertCapable(capabilities: ServerCapabilities): ServerCapabilities { ... }
Refuse capabilities this target cannot honour, or hand them back.
What it checks is never "did the caller want a socket" but "can this target keep one", which is why the rules live beside the target's own flags rather than in each adapter: a fifth adapter then inherits the reasoning instead of re-deriving it, and a target that gets one of the two booleans wrong is wrong in one place.
@uniflowed/server/host
type
export type HeaderStore = {
readonly get: (name: string) => string | null,
readonly has: (name: string) => boolean,
};
A read-only view of one request's headers.
type
CookieStore
export type CookieStore = {
readonly get: (name: string) => string | null,
readonly has: (name: string) => boolean,
};
A read-only view of one request's cookies.
type
DraftMode
export type DraftMode = {
readonly isEnabled: boolean,
readonly enable: () => void,
readonly disable: () => void,
};
Whether this request is rendering draft content, and how to change that.
type
RequestContext
export type RequestContext = {
/** An application verified bearer credential, scoped to the exact Request. */
nativeAction: NativeActionAuthorization | null,
readonly headers: HeaderStore,
readonly cookies: CookieStore,
/**
* What to call this request in a log line, in a trace, and in an error page.
*
* On the context for the same reason everything else here is, and the reason
* is sharper for an id than for anything above it: an id whose whole purpose
* is to tell two requests apart, kept in a module-level variable, would name
* whichever request set it last. Every line the first request wrote after the
* second one arrived would carry the second one's id, and the log would be
* wrong in a way that reads as though it were right.
*
* uf generates it and never takes it from the request. An `X-Request-Id` a
* client sent is text that client chose: it can be the same on a million
* requests, which defeats the one thing an id is for; it can be a megabyte;
* and it lands in a log line, which is a place `./log.js` spends its header
* explaining that attacker-chosen text does not belong. A deployment behind
* a proxy that already assigns ids has a real need here and it is not this
* field — it is a second, clearly-named one that says whose id it is, and it
* is not in this change.
*/
readonly id: string,
/**
* The route pattern that claimed this request, or `null` if none has.
*
* `/orders/:id` rather than `/orders/8813`. Written by whichever part of
* `@uniflowed/router` matched — the handler dispatcher or the renderer —
* through [`noteRoute`], and read by the host when it writes the request's
* log line. Mutable because it is not known when the request begins: a host
* establishes the request before anything has looked at the path.
*/
route: string | null,
/**
* The Content-Security-Policy nonce this response carries, or `null`.
*
* `null` until something asks, and that is the whole design rather than an
* optimisation. A nonce is only worth anything to a response whose policy
* names it, and a document that carries one can never be stored in a shared
* route cache — a replayed nonce is a nonce reused across two responses,
* which is precisely the property `docs/security.md` promises against. So
* minting is the act that turns both of those on, and nothing mints by
* accident: `../index.js`'s `nonce()` does, on behalf of the application,
* and `app.router.headers` does when a rule's value names `{uf.nonce}`.
* A project that asks for neither gets the documents it has always had and a
* route cache that still works.
*
* uf generates it and never takes it from the request, for the reason `id`
* above is not taken from `X-Request-Id`, and the reason is sharper here: an
* inbound header is text the client chose, and a client that picks the nonce
* picks the one value that would make a strict policy admit a script it
* injected. A deployment whose proxy issues nonces has the value in hand
* where it sets the header, and that is where the two have to agree.
*
* Mutable because it is not known when the request begins — a host
* establishes the request long before anything has decided whether this
* response has a policy at all.
*/
nonce: string | null,
/**
* Whether this request is rendering draft content.
*
* Read from the request's signed `__Host-uf.draft` cookie by [`beginRequest`]
* before anything the host asked for runs, and settable afterwards by
* `../index.js`'s `draftMode()` — so a guard, a route handler and the page
* underneath them all agree, and a request that arrived with the cookie is in
* draft mode from its first line rather than from whenever something happened
* to call `enable()`. It was initialised `false` and never read from the
* request at all, which made draft mode a feature that could not be turned
* on: ubugeeei-prod/uf#282.
*/
draft: boolean,
/**
* What this request decided about draft mode, for a responder to write.
*
* `null` until `draftMode().enable()` or `.disable()` is called. It is
* separate from `draft` because the two are different facts: `draft` is what
* *this* request renders, and this is the instruction to change what the
* *next* one does — a `Set-Cookie` that only the thing producing the response
* can write. [`asResponder`] is what turns it into one, and clears it.
*/
draftChange: DraftChange | null,
/**
* What is producing this request's response, or `null`.
*
* A name, e.g. `a route handler`, set by [`asResponder`] around the call into
* whatever owns the response. `draftMode().enable()` refuses when it is
* `null`, and that refusal is the whole reason this field exists: a component
* that set a cookie would be setting it at a moment with no defined meaning,
* because the headers may already be on the wire by the time a component six
* levels down renders. `../index.js` gives the same argument for `headers()`
* being read-only, and draft mode is the exception that needs somewhere to
* put the response half.
*/
responder: string | null,
/** Work deferred until the response has been sent. */
readonly deferred: Array<() => mixed | Promise<mixed>>,
/** Keep a late streaming error observer alive on hosts with waitUntil. */
waitUntil?: (work: Promise<mixed>) => void,
/**
* How many times this request has read state that varies per request.
*
* A counter rather than a flag, and the difference is what makes a route
* cache possible at all. A middleware that reads a cookie to decide whether
* to let the request through has not made the *page* vary — it either
* answered or it did not — so a flag set by that read would refuse to cache
* every page in every application that has an auth guard. A counter can be
* read before the render and again after the last byte, and what moved in
* between is exactly what the document depended on.
*
* Incremented by `../index.js`'s three bindings and by nothing else.
* `after()` reads the context too and does not touch this: registering
* deferred work says nothing about what the response contains.
*/
requestStateReads: number,
/**
* The cache the host installed for this request, or `null`.
*
* On the context rather than in a module-level variable, which is the same
* decision `storage` above is and rests on the same fact: two requests are
* answered at once, and anything a server function reaches for by name has
* to be scoped to the request or it is scoped to whichever request set it
* last. It also means `revalidateTag()` in a server action reaches the store
* that answered the request the action is part of, rather than a copy some
* other module instance is holding — the hazard ubugeeei-prod/uf#389 is
* about, pointed at a cache.
*/
cache: CacheOptions | null,
/**
* What the host answering this request can do, or `null`.
*
* Beside the cache, and set the same way and at the same moment, because it
* is the same kind of fact: something the *host* knows that a route handler
* has no other way to ask about. `eventStream`, `upgradeWebSocket` and
* `enqueue` all read it, and each refuses by naming the target rather than
* failing as a dropped connection somewhere downstream.
*
* `null` means no host said, which is what every request looked like before
* this field existed. The three readers treat it as "cannot", because the
* alternative — assuming a host that says nothing can hold a socket open —
* is the failure this is here to prevent.
*/
capabilities: ServerCapabilities | null,
/**
* A file the build wrote, answered the way the host serving this request
* answers it, or `null` where it has nothing under `pathname`.
*
* Set by the front door, because the front door is the only thing that
* knows where the build's files are: a directory beside `server.js`, a
* Worker's `ASSETS` binding, the package a Lambda was deployed with.
* `pathname` is a URL path, and the answer is exactly what that host's
* static half gives for it.
*
* For incremental static regeneration: `../fetch.js` starts a regenerated
* page from the document the build wrote and reads it through this, rather
* than through a filesystem the host may not have. `null` means no front
* door offered one, and such a page is rendered on its first request
* instead.
*/
buildFile: ((pathname: string) => Promise<Response | null>) | null,
/**
* The platform's bindings for this request, which on a Worker is `env`, or
* `null`.
*
* On the request because a Worker is handed them per request and nowhere
* else, and a durable cache provider is constructed once, at module load: a
* KV namespace is something it can only reach from inside the request using
* it. See `../cache-kv.js`.
*/
bindings: { readonly [string]: mixed } | null,
};
Everything a server function may ask about the request it is inside.
Deliberately not the Request itself. A server function that could reach the whole request could read the body, which is already being consumed by the thing that called it, and could hold it past the response.
type
RequestLifecycle
export type RequestLifecycle = {|
/** The context `run` establishes, for a host that needs to read it. */
readonly context: RequestContext,
/** Run the whole request inside it. */
readonly run: <T>(body: () => Promise<T>) => Promise<T>,
/** The response has gone: run what `after()` deferred. */
readonly settle: () => Promise<void>,
|};
One request, from the moment a host has one to the moment its bytes are gone.
Two functions rather than one, because they are called from two places and that is the whole point rather than an inconvenience. run wraps everything that *decides* the response — the guard, the dispatcher, the render — and settle happens after the response has been *written*, which in every host uf has is a different line in a different module. A single handle(request, body) that drained when body returned would be the bug this exists to fix, spelled once instead of twice.
function
insideRequest
export function insideRequest(): boolean { ... }
Whether a request has been established around this call.
For a caller that is not a server function and has nothing to answer about the request — the router's dispatcher and its middleware runner, which need to know that a host established one *before* anything they call asks for cookies. They must not be handed the context itself: a module that can reach it can drain it, which is how the drain came to be in the wrong place.
function
runWithContext
export function runWithContext<T>(context: RequestContext, body: () => T): T { ... }
Run body with context as the current request.
Everything body awaits sees the same context, and nothing outside it does.
function
contextFor
export function contextFor(request: Request): RequestContext { ... }
Build a context from a Request.
The header and cookie views are built once and read many times: a render touches cookies().get(…) as often as it has components that care, and re-parsing the cookie header each time would be the kind of cost nobody looks for.
Parsed on the first read rather than here, and that changed when the host became the thing that begins a request: a host begins one before it knows whether the path is an embedded chunk or a page, so every asset a compiled binary serves now builds a context. Splitting a Cookie header for a request that never asks about cookies is exactly the cost the paragraph above refuses to pay per read, and there is no reason to pay it per request either.
function
currentNonce
export function currentNonce(): string | null { ... }
The nonce this request already has, or null.
Deliberately **not** minting, which is what separates it from [nonceFor] and what keeps this feature from costing anything to a project that has not asked for it. The renderer calls this for every document it writes: with a nonce in hand it stamps one on every script it emits, and with null it writes exactly the document it wrote before nonces existed. A renderer that minted instead would put a nonce in every document in every project, and with it would turn off the route cache everywhere — see the field's own paragraph for why a nonced document cannot be stored.
null outside a request too, which is a static prerender: uf build has no request, so there is no per-request value to write and the honest answer is to write none. docs/security.md says what a prerendered route has to do instead.
function
noteRoute
export function noteRoute(pattern: string): void { ... }
Record that pattern is the route this request turned out to be.
Silent outside a request, and that is what makes it callable from the router's own matching code: prerender resolves routes at build time, where there is no request and nothing to record, and a function that threw there would push the check into every caller.
Last writer wins, and the writers are chosen so that this means "the most specific thing that claimed the request". @uniflowed/router's dispatcher calls it for a route handler and its renderer calls it for a page; the middleware runner deliberately does not. A guard covers a directory — /dashboard covers /dashboard/typo, which is a 404 — so recording the guard's path would label a request with a route it never reached.
function
beginRequest
export function beginRequest(request: Request): RequestLifecycle { ... }
Begin a request, and hand back the two halves of owning it.
The one function a host calls. contextFor, runWithContext and drainDeferred are still here because they are what this is made of and because the suite drives them one at a time, but a *host* reaching for them separately is how uf got two contexts on one request and a drain that ran before the response: the middleware runner built one and drained it, and the dispatcher underneath it built another. See ubugeeei-prod/uf#389.
settle runs once. A host learns that a response is finished more than once — the body stream closed, and then the socket did — and draining twice would run whatever the first drain's callbacks registered, at a moment nothing asked for.
function
asResponder
export function asResponder(kind: string, body: () => Promise<Response>): Promise<Response> { ... }
Run body as the thing that owns this request's response.
Two things at once, and they are the same thing seen from both ends. draftMode().enable() refuses outside this scope — a render has no defined moment at which a response header takes effect — and inside it, whatever body decided about draft mode is written onto the Response it returned.
@uniflowed/router calls it in exactly two places: around a route handler and around a server action. Those are the two things Next allows enable() in and the two things uf can honestly allow it in, because they are the two that return a response of their own. A middleware is deliberately not one: it may decline, and a guard that turned draft mode on and then let the request through would have made a decision with nowhere to be written — silently, which is the failure ubugeeei-prod/uf#282 is about wearing a different hat.
Silent outside a request, and that matters for the same reason noteRoute is: dispatch refuses outside one already, and a second refusal here would only replace a good message with a worse one.
function
parseCookies
export function parseCookies(header: string | null): { [string]: string } { ... }
Parse a Cookie header into a plain object.
Object.create(null) rather than {}: a cookie called __proto__ is a thing an attacker can set, and on an ordinary object it would not be a key at all — it would be the prototype.
A duplicated name keeps the first value, which is what every server-side cookie parser does and what browsers send for a name set at two paths.
function
drainDeferred
export function drainDeferred(context: RequestContext): Promise<void> { ... }
Run everything after() deferred, in the order it was registered.
A failure is reported and does not stop the rest: deferred work is by definition not what the response depended on, and one broken analytics call should not take the others with it.
type
PartialPrerender
export type PartialPrerender = {|
/** The bindings that were left for the request: `cookies`, `headers`, `draftMode`. */
readonly reads: Array<string>,
|};
One partial prerender: what it read, in the order it read it.
function
newPartialPrerender
export function newPartialPrerender(): PartialPrerender { ... }
A scope that has read nothing yet.
function
runPartialPrerender
export function runPartialPrerender<T>(scope: PartialPrerender, body: () => T): T { ... }
Run body as a partial prerender, recording into scope.
function
isPostponedRead
export function isPostponedRead(error: mixed): boolean { ... }
Whether error is a [PostponedReadError], from any copy of this package.
Without a doc comment
@uniflowed/server/image
variable
IMAGE_ENDPOINT
export const IMAGE_ENDPOINT: string = "/__uf/image";
Where the endpoint answers, under the application's base path.
variable
DEFAULT_WIDTHS
export const DEFAULT_WIDTHS: $ReadOnlyArray<number> = [640, 750, 828, 1080, 1200, 1440, 1920];
app.builtins.images.widths when a project says nothing.
uf_assets::DEFAULT_WIDTHS, spelled again because this side reads the configuration as JavaScript and never sees the Rust default applied; tests/library/image-endpoint.test.js holds the two spellings, and @uniflowed/web's, to each other.
variable
DEFAULT_QUALITY
export const DEFAULT_QUALITY: number = 75;
app.builtins.images.quality when a project says nothing.
variable
MAX_SOURCE_BYTES
export const MAX_SOURCE_BYTES: number = 32 * 1024 * 1024;
Largest source the endpoint reads, in bytes.
uf_assets::MAX_VARIANT_SOURCE_BYTES, the same number on the other side of the transformer, so a body that got this far is never refused there.
variable
MAX_REDIRECTS
export const MAX_REDIRECTS: number = 3;
Most redirects followed to reach a source.
variable
SOURCE_TIMEOUT
export const SOURCE_TIMEOUT: number = 15_000;
How long a source may take to arrive, in milliseconds.
variable
DEFAULT_CONCURRENCY
export const DEFAULT_CONCURRENCY: number = 4;
Encodes in flight at once, per endpoint.
variable
MAX_QUEUED
export const MAX_QUEUED: number = 64;
Requests waiting for an encode, per endpoint, before one is a 503.
variable
MAX_MEMORY_VARIANTS
export const MAX_MEMORY_VARIANTS: number = 256;
Variants held in memory, when the host gave no store.
variable
DEFAULT_LIFETIME
export const DEFAULT_LIFETIME: number = 60 * 60;
Lifetime of a variant whose origin stated none, in seconds.
type
SourceType
export type SourceType = "image/png" | "image/jpeg" | "image/webp";
The source formats the endpoint decodes.
type
ImageFetch
export type ImageFetch = (url: URL, signal: AbortSignal) => Promise<Response>;
One request to a remote host, with redirects *not* followed.
What a host passes, because it is the part that differs: ./image-node.js resolves the name, judges the address and connects to it, and ./image-edge.js's is the platform's fetch with redirect: "manual". A redirect comes back as the 3xx it was; the endpoint decides whether to follow it.
type
export type TransformInput = {|
readonly bytes: Uint8Array,
readonly type: SourceType,
readonly width: number,
readonly quality: number,
/** Whether the browser accepts AVIF. */
readonly avif: boolean,
|};
What a transformer is handed.
type
export type TransformOutput = {|
readonly bytes: Uint8Array,
/** The encoded file's media type. */
readonly type: string,
|};
What a transformer answers with.
type
export type ImageTransform = (input: TransformInput) => Promise<TransformOutput>;
The encoder: one width of one source.
uf start and uf preview pass uf itself (uf_assets::variant, over the uf assets protocol); --adapter edge passes Cloudflare's image binding; a directory built for a runtime with neither is given the module app.builtins.images.transformer names. A transformer that answers wider than asked, or in a format the browser did not accept, is a bug in the transformer — the endpoint does not re-check its output.
type
ImageEndpointOptions
export type ImageEndpointOptions = {|
/** `app.builtins.images.remotePatterns`. */
readonly remotePatterns: $ReadOnlyArray<RemotePattern>,
/** `app.builtins.images.widths`: the only widths a request may name. */
readonly widths: $ReadOnlyArray<number>,
/** `app.builtins.images.quality`: the default, and always allowed. */
readonly quality: number,
/** `app.builtins.images.qualities`: the other qualities a request may name. */
readonly qualities?: $ReadOnlyArray<number>,
readonly fetch: ImageFetch,
readonly transform: ImageTransform,
/**
* Where variants are kept. A memory store of [`MAX_MEMORY_VARIANTS`] when
* absent; a host with a durable `rendering.cache.store` passes a store over
* the same provider.
*/
readonly store?: CacheStore,
/** Where the endpoint answers. [`IMAGE_ENDPOINT`] when absent. */
readonly path?: string,
/** Encodes at once. [`DEFAULT_CONCURRENCY`] when absent. */
readonly concurrency?: number,
|};
Everything a host decides about its endpoint.
class
ImageRefusal
export class ImageRefusal extends Error { ... }
A request the endpoint refuses, with the status it is answered with.
Thrown by a host's [ImageFetch] as well as here: a name that resolves to a private address is a 403 whichever runtime found out.
function
createImageEndpoint
export function createImageEndpoint(
options: ImageEndpointOptions,
): (request: Request) => Promise<Response | null> { ... }
The endpoint, as a function that answers its own path and declines the rest.
null for any request that is not for [IMAGE_ENDPOINT], so a host can ask it first and carry on; a Response for every request that is, refusals included.
Throws when it is built from a list it cannot read, rather than answering with an endpoint that admits nothing or more than it should.
function
acceptsAvif
export function acceptsAvif(accept: string | null): boolean { ... }
Whether an Accept header admits image/avif.
Read as a list of media ranges, so image/avif;q=0 is the refusal it says it is; a wildcard is *not* taken as a yes, because every browser that sends *\/* for an image and means it has shipped AVIF support in the same release that started naming it — and the one that does not is exactly the browser a wrong guess would hand an AVIF to.
function
sniff
export function sniff(bytes: Uint8Array): SourceType | null { ... }
The format the first bytes say, or null for anything else.
function
lifetimeOf
export function lifetimeOf(cacheControl: string | null): number | null { ... }
How long a variant may be kept, from the origin's Cache-Control, or null for an origin that said not to keep it.
type
RemotePattern
export type RemotePattern = {|
readonly protocol?: "https" | "http",
readonly hostname: string,
readonly port?: string,
readonly pathname?: string,
|};
One entry in the allow-list, as uf.config.js writes it.
@uniflowed/server/lambda
function
lambdaCapabilities
export function lambdaCapabilities(options?: CapabilityOptions): ServerCapabilities { ... }
What a Lambda can do, which is neither of the two things this is asked.
Both flags are false, and both are facts about the platform rather than about this module. The response is a JSON value, so [toResult] reads the whole body before the invocation returns — an event stream would be held in memory until it closed, which for a stream that stays open is a timeout. And the invocation is frozen the moment it answers, so work pushed into its own memory is dropped rather than run.
So an upgrader handed here is refused where the host is wired, before a single connection is accepted and dropped, and so is a queue that does not survive the process. That is the whole point of the pair being values: the alternative is a deployment that accepts WebSocket handshakes all day and a customer wondering why nothing arrives.
A durable queue is not refused. Pushing to SQS from a Lambda is ordinary and correct; what cannot be here is the *consumer*, which is a second function or a container. ./queue.js says which half is whose.
type
LambdaHttpEvent
export type LambdaHttpEvent = {
readonly version?: string,
readonly rawPath?: string,
readonly rawQueryString?: string,
readonly cookies?: $ReadOnlyArray<string>,
readonly headers?: { readonly [string]: string | void },
readonly body?: string,
readonly isBase64Encoded?: boolean,
readonly requestContext?: {
readonly domainName?: string,
readonly http?: {
readonly method?: string,
readonly path?: string,
...
},
...
},
...
};
An HTTP API payload format 2.0 event, as much of it as this module reads.
Inexact and almost entirely optional, because it arrives from the platform rather than from a caller: the fields uf needs are checked in [toRequest], where a missing one can be reported as the wrong event shape.
type
LambdaHttpResult
export type LambdaHttpResult = {|
readonly statusCode: number,
readonly headers: { [string]: string },
readonly cookies: $ReadOnlyArray<string>,
readonly body: string,
readonly isBase64Encoded: boolean,
|};
What Lambda expects back for payload format 2.0.
type
LambdaHandlerOptions
export type LambdaHandlerOptions = {|
/** The application, from the generated `handler.js`. */
readonly handle: (request: Request) => Promise<Response>,
/** That same module's `beginRequest`; see [`./node.js`]'s header for why. */
readonly beginRequest: (request: Request) => RequestLifecycle,
/**
* The directory holding the build's own files, if the package carries them.
*
* Omitted where a CDN answers for them; see the header.
*/
readonly staticDir?: string,
/**
* That same module's `routing`: its redirects answer before the package's
* files, and its headers go on whatever answers. See `./internal/routing.js`.
*/
readonly routing?: RoutingRules,
|};
Everything the serverless half needs to answer an invocation.
function
toRequest
export function toRequest(event: LambdaHttpEvent): Request { ... }
The event as a Request.
The URL is rebuilt rather than taken from a field, because no field holds one: the path is rawPath, the query is rawQueryString, and the authority is the host header the client sent — falling back to the API's own domain, which is what a health check with no Host arrives with. https, always: both a Function URL and an HTTP API terminate TLS, and there is no spelling of either that a browser reaches over http.
Cookies come from event.cookies and not from a header, because that is where format 2.0 puts them — an application reading cookies() would otherwise see none of them, which is the kind of difference between a deployment and uf start this whole seam exists to prevent.
function
toResult
export function toResult(response: Response): Promise<LambdaHttpResult> { ... }
A Response as the JSON value Lambda returns to the client.
function
createLambdaHandler
export function createLambdaHandler(
options: LambdaHandlerOptions,
): (event: LambdaHttpEvent) => Promise<LambdaHttpResult> { ... }
A built uf application as a Lambda handler.
Static files first, then the application — the order uf preview cannot deviate from and therefore the order every other front door matches. The request is begun here and settled once the body has been read into the result, which on this target is genuinely "the response has been produced": an invocation that returned before its after() callbacks ran would have them killed with the sandbox, so settle is awaited rather than deferred.
type
RequestLifecycle
export type RequestLifecycle = {|
/** The context `run` establishes, for a host that needs to read it. */
readonly context: RequestContext,
/** Run the whole request inside it. */
readonly run: <T>(body: () => Promise<T>) => Promise<T>,
/** The response has gone: run what `after()` deferred. */
readonly settle: () => Promise<void>,
|};
One request, from the moment a host has one to the moment its bytes are gone.
Two functions rather than one, because they are called from two places and that is the whole point rather than an inconvenience. run wraps everything that *decides* the response — the guard, the dispatcher, the render — and settle happens after the response has been *written*, which in every host uf has is a different line in a different module. A single handle(request, body) that drained when body returned would be the bug this exists to fix, spelled once instead of twice.
@uniflowed/server/log
function
processLogger
export function processLogger(): Logger { ... }
The process logger, building the default one if nobody installed any.
Every uf module that logs goes through this rather than holding a logger of its own, so [installLogger] reaches all of them — including modules that were imported before it was called.
function
installLogger
export function installLogger(logger: Logger | null): void { ... }
Send everything uf logs to logger instead.
Called by a host at startup, before it takes a socket. Calling it later is allowed and does what it says — the lines after it go to the new logger — but the lines before it have already gone somewhere, which is worth knowing rather than discovering.
Passing null puts the default back, which is what a test needs at the end of a case that installed one: a logger left installed by one test is a logger the next test writes through, and a shared sink between two tests is the sort of coupling that fails only when the order changes.
function
installLoggerUnlessChosen
export function installLoggerUnlessChosen(create: () => Logger): void { ... }
Install the logger create builds, unless something already chose one.
For a front door that knows a better default than [createLogger]'s and must not overrule a decision made elsewhere: @uniflowed/server/edge's [installWorkerLogger], which runs after the application's modules have been evaluated, so an application that called [installLogger] while it loaded keeps its logger. A default built lazily by [processLogger] is not a choice and is replaced; installLogger(null) takes a choice back.
function
recordingLogger
export function recordingLogger(options?: {| readonly level?: LogLevel |}): {|
readonly logger: Logger,
readonly records: Array<LogRecord>,
|} { ... }
A logger that records what it was told instead of writing it.
Here rather than in the test suite because two suites and any application asserting on its own logging need the same thing, and because a hand-rolled one tends to capture the arguments rather than the record — which is the half where redaction and truncation happen, and so the half worth asserting on.
function
silenceLogging
export function silenceLogging(): void { ... }
A logger that writes nothing, as the process logger.
The shape a host reaches for when it has its own front-of-house output and does not want uf's underneath it — uf dev owns its terminal and renders it from an event channel rather than from lines. Spelled as one call so a caller does not have to know that silence is silentLogger() rather than a level.
type
RequestLogFields
export type RequestLogFields = {|
readonly requestId: string,
readonly method: string,
/** The path, and never the query string; see [`logRequest`]. */
readonly path: string,
/** The route pattern that matched, or `null` when nothing did. */
readonly route: string | null,
readonly status: number,
readonly durationMs: number,
|};
Everything an access line carries, so the hosts cannot disagree about it.
function
logRequest
export function logRequest(logger: Logger, fields: RequestLogFields): void { ... }
Write the one line a finished request leaves behind.
Here rather than in each host because there is more than one of them — ./node.js for uf start, uf preview and the server.js an adapter writes, ./edge.js for a worker, ./lambda.js for a serverless invocation — and three copies of a log shape is three field names that agree until somebody fixes one of them. The message is the constant request; a caller filtering a log asks for msg:request status:>=500, which is only a question because none of this is interpolated into a sentence.
./standalone.js is the one front door that does not write this line yet. Its handler answers an embedded asset and a prerendered document before it begins a request at all, so there is no single place with both the status and the context, and restructuring the streaming path to make one is a change that deserves its own review rather than a paragraph in this one.
# The level comes from the status
A 500 is the operator's problem, a 404 is usually the client's, and a 200 is neither. Deciding that here rather than at each call site is what makes UF_LOG_LEVEL=warn a useful setting: it leaves exactly the requests that went wrong.
# The query string is not in it
path is url.pathname, and the search is dropped rather than trimmed for length. A query string is where a redirect target, a search term, a signed download URL and — in this very package — an OAuth code and state all live. ./oauth.js keeps a code away from a logger at its own end as well, but a host writing this line has no idea which route it is logging, so the only rule it can apply is the one that is right for every route.
# And the route is
A log of paths says /orders/8813 was slow. A log of routes says /orders/:id is slow, which is the question an operator actually has — one fact instead of a million. The route is what matched in @uniflowed/router, put on the request context by whichever of the dispatcher and the renderer claimed it, and null for a request that matched neither: a static asset, a 404, a request a guard refused above any route.
type
LogLevel
export type LogLevel = "debug" | "info" | "warn" | "error";
How much a record has to matter before it is written.
type
LogFields
export type LogFields = { +[string]: mixed };
The varying half of a record: everything that is not the constant message.
type
LogRecord
export type LogRecord = {|
readonly level: LogLevel,
/** When it happened, from uf's clock rather than the host's; see the header. */
readonly time: Instant,
/** A constant the author wrote. Never interpolated; see the module header. */
readonly message: string,
readonly fields: LogFields,
|};
One thing worth saying, before anybody has decided how to spell it.
type
LogSink
export type LogSink = (record: LogRecord) => void;
Where a record goes once it has been decided it is worth writing.
type
export type LogFormat = "json" | "text";
How a record is spelled for whoever reads it.
type
Logger
export type Logger = {|
readonly debug: (message: string, fields?: LogFields) => void,
readonly info: (message: string, fields?: LogFields) => void,
readonly warn: (message: string, fields?: LogFields) => void,
readonly error: (message: string, fields?: LogFields) => void,
/** A logger that adds `fields` to everything written through it. */
readonly child: (fields: LogFields) => Logger,
/** The threshold this logger writes at, for a caller that wants to skip work. */
readonly level: LogLevel,
|};
Somewhere to say something.
child is the reason this is an object rather than four functions: a request has an id and a route that belong on every line it produces, and the alternative to binding them once is passing them at every call, which is the same as not having them.
type
LoggerOptions
export type LoggerOptions = {|
/** The lowest level written. Below it, nothing is formatted at all. */
readonly level?: LogLevel,
/** How a record is spelled. Ignored when `sink` is given, which spells it itself. */
readonly format?: LogFormat,
/** Where records go. The default writes to `console`; see the module header. */
readonly sink?: LogSink,
|};
What createLogger may be told.
function
createLogger
export function createLogger(options?: LoggerOptions): Logger { ... }
A logger.
The threshold is checked before anything is built, so a debug call on an info logger costs an object lookup and a comparison rather than a walk over whatever was passed to it. That matters because the cheapest way to end up with no debug logging at all is for debug logging to be expensive.
function
silentLogger
export function silentLogger(): Logger { ... }
A logger that writes nothing.
For a caller that must have a logger and has been told not to log — a test, and a host whose operator set UF_LOG_LEVEL=silent. It is a real logger rather than null so that no call site has to test for one; a null logger is a ?. on every line, and a ?. that is forgotten once is a crash in the path that was already going wrong.
function
consoleSink
export function consoleSink(format: LogFormat): LogSink { ... }
The sink that writes to console.error, spelled as format says.
Every level, one stream. See the module header: the process that runs uf start has a protocol on stdout, and console.info writes there.
function
export function formatJson(record: LogRecord): string { ... }
One record as a line of JSON.
time is an ISO string rather than the instant it is held as, because that is what every log aggregator sorts on without being told; msg rather than message for the same reason. The fields are spread at the top level, so a query is status:500 rather than fields.status:500 — and a field named level, time or msg cannot displace the record's own, because the record's are written after it.
function
formatText
export function formatText(record: LogRecord): string { ... }
One record as a line for a person.
The time is the wall clock without the date: this format is for a terminal that has been open for a few minutes, and the date in front of every line is a column nobody reads. key=value for the fields rather than JSON, because the thing a reader does with this line is scan it.
function
elapsedMs
export function elapsedMs(started: Instant): number { ... }
Milliseconds from started until now, for a request line's durationMs.
until and total rather than subtracting two numbers, because a number is not what a host holds any more: uf reads its clock through @uniflowed/core/temporal, so the value a request started with is an Instant and the time it took is the Duration between two of them. The answer is milliseconds because that is what the field is named and what a dashboard buckets — a Duration on the record would be an object every sink had to learn to spell.
Both ends read the same clock, so a test that froze it gets 0 rather than a number that moves. That is the seam working: a suite asserting on a log line should not have to match a duration it cannot predict.
function
isRedacted
export function isRedacted(name: string): boolean { ... }
Whether a field of this name is a credential.
Normalised before the lookup so one entry covers every spelling a caller might reach for; see [REDACTED_FIELDS].
@uniflowed/server/node
function
nodeCapabilities
export function nodeCapabilities(options?: CapabilityOptions): ServerCapabilities { ... }
What a Node host can do, plus whatever the deployment supplied.
Both flags are true and neither is a formality. A Node response is a socket, so a body reaches the client as it is written — which is what an event stream needs. And the process is still there once the response has gone, which is what makes an in-process queue a legitimate small deployment here and nowhere else in this package.
websocket is still the deployment's to pass. Node has no server-side WebSocket: taking one means framing it over the raw socket from server.on("upgrade"), and which library does that is a choice uf must not make on a project's behalf — see ./socket.js and docs/red-lines.md rule 3.
type
NodeRequest
export type NodeRequest = {
readonly method?: string,
readonly url?: string,
readonly originalUrl?: string,
readonly headers: { readonly [string]: string | Array<string> | void },
...
};
The pieces of a Node request this module touches.
Declared structurally rather than imported from a node:http libdef, for the same reason ./standalone.js does it: the set is small, and naming it here is what lets the file be read without knowing which host's types are in scope. originalUrl is Connect's, and it is here because uf preview runs this behind Vite's middleware stack, which sets it.
type
NodeResponse
export type NodeResponse = {
statusCode: number,
statusMessage: string,
headersSent: boolean,
setHeader(name: string, value: string): mixed,
write(chunk: Uint8Array | string): boolean,
end(chunk?: Uint8Array | string): mixed,
destroy(error?: mixed): mixed,
// `send` paces itself against the socket and stops when the client hangs
// up, so it needs the events as well as the writes. `write` returns a
// `boolean` for the same reason — `mixed` would have made the back-pressure
// check unwritable, which is one way this was lost.
on(event: string, listener: () => mixed): mixed,
once(event: string, listener: () => mixed): mixed,
off(event: string, listener: () => mixed): mixed,
...
};
The pieces of a Node response this module writes.
function
toRequest
export function toRequest(
incoming: NodeRequest,
options?: {| readonly secure?: boolean |},
): Request { ... }
A Node request as a Request.
The body is passed as a stream where the host allows it, so a handler that accepts an upload does not need the whole thing buffered before it starts. duplex is required by the specification whenever a body is a stream, and Node throws without it.
function
send
export function send(outgoing: NodeResponse, result: Response): Promise<void> { ... }
Write a Response to a Node response.
function
createStaticHandler
export function createStaticHandler(options: {|
readonly root: string,
|}): (request: Request) => Promise<Response | null> { ... }
The static half: a file under root, or null for the caller to carry on.
Which file, and with what headers, is [locateStatic](./internal/static.js) — shared with every other host, because none of it is about Node. What is Node's is the last line: the body.
function
nodeListener
export function nodeListener(
handle: (request: Request) => Promise<Response>,
options: {|
readonly beginRequest: (request: Request) => RequestLifecycle,
readonly secure?: boolean,
/**
* Where this listener's lines go. The process logger by default.
*
* Passed rather than only installed globally because a host that runs two
* servers in one process — `uf preview` beside a test harness — has a
* reason to tell them apart, and because a test that wants silence should
* not have to reach for a global to get it.
*/
readonly log?: Logger,
|},
): (incoming: NodeRequest, outgoing: NodeResponse) => Promise<void> { ... }
A Request/Response handler as a Node request listener.
The handler contract is the platform's, so this adapter belongs here rather than in every host that wants to run one.
beginRequest is required, and it comes from the application bundle for the reason in "Who owns the request" above. A listener built without one fails on its first request, which is the same trade createFetchHandler makes about app.runMiddleware: an optional lifecycle is a lifecycle somebody forgets, and what is lost when they do is every after() in the application.
A handler that throws is answered with a bare 500 and reported to the logger: the body must not carry the stack, because the body goes to whoever asked, and the log is where the operator is already looking. The drain is in a finally below the catch, so a middleware that logged the request sees its callback run once that 500 is on the wire rather than once the handler gave up — and a request that failed is still a request that happened, which is why it is drained at all.
# The access line
One line per request, written after the response and never before it, because the two things worth knowing — what it answered and how long it took — are only true then. It carries the route that matched rather than only the path, which is the point of ubugeeei-prod/uf#506 and the reason this reads lifecycle.context at the end: the route is not known when the request begins, and by here whichever of the dispatcher and the renderer claimed it has said so. @uniflowed/server/log's logRequest decides the level and the field names, so every host says it the same way.
A request whose bytes were not a request Node could parse never reaches this function at all; that one is clientError in [serve], which is the half of ubugeeei-prod/uf#405 that had nowhere to be reported.
function
createServeHandler
export function createServeHandler(options: {|
readonly staticDir: string,
readonly handle: (request: Request) => Promise<Response>,
readonly routing?: RoutingRules,
|}): (request: Request) => Promise<Response> { ... }
Static files, then the application: the whole of what a built uf app serves.
staticDir is the directory uf build wrote — dist/ in a checkout, and the static/ copied beside server.js in an adapter's output.
routing is the bundle's routing: a redirect in it answers before the files do, and its headers go on whatever answers — a file included. See ./internal/routing.js for why both sit in front of the static half and a rewrite does not.
function
export function pinHeaders(outgoing: NodeResponse, pairs: $ReadOnlyArray<[string, string]>): void { ... }
Hold pairs on a Node response, whatever writes it afterwards.
For the front doors that do not answer with a Response of their own: uf dev and uf preview, where Vite's file middleware writes the file with a writeHead of its own headers, and a compiled binary, which writes a file and a document straight to the socket. Setting the headers first is not enough in any of them, because a later setHeader or writeHead of the same name would win — and the project's app.router.headers rule is the one that has to, as it does everywhere [withHeaders] is the answer.
function
serve
export function serve(options: {|
readonly staticDir: string,
readonly handle: (request: Request) => Promise<Response>,
/**
* The application bundle's own `beginRequest`.
*
* The generated `handler.js` re-exports it beside `fetch` so that
* `server.js` has one to pass; see "Who owns the request" above for why it
* cannot be imported here instead.
*/
readonly beginRequest: (request: Request) => RequestLifecycle,
readonly host?: string,
readonly port?: number,
/** Where this server's lines go. The process logger by default. */
readonly log?: Logger,
/**
* Schedules to run in this process, from `@uniflowed/server/schedule`.
*
* A target that keeps a process is the one that can hold a scheduler, which
* is what `processScheduler`'s `triggered: false` says and what
* `assertCapable` refuses elsewhere. Ticking stops when `close` resolves,
* so a test that takes a server down does not leave one running behind it.
* See ubugeeei-prod/uf#531.
*/
readonly schedules?: $ReadOnlyArray<Schedule>,
/** The bundle's `routing`, which the generated `handler.js` re-exports. */
readonly routing?: RoutingRules,
|}): Promise<{|
readonly host: string,
readonly port: number,
readonly close: () => Promise<void>,
|}> { ... }
Serve the application until the process is stopped.
What uf build --adapter node writes calls this and nothing else. It resolves once the socket is listening, with the address it took, because a caller that asked for port 0 has no other way to learn which port it got — and because a test that has to drive a deployed directory needs exactly that.
PORT and HOST are read from the environment because that is how every process manager and container platform says which socket to take, and a production server that could only be told on the command line would need a wrapper script everywhere it ran. The command line wins over both, and the default address is every interface: a container that bound loopback would be a container nothing outside it can reach.
# The request that never became one
[reportMalformedRequests] is the other half of ubugeeei-prod/uf#405, and it is a separate function because this one cannot be called without a socket and that one can: a stream.Duplex handed to an http.Server as a connection drives the real parser with no listen at all, which is what makes the behaviour testable on a machine where bind is refused. ./standalone.js calls it too, so a compiled binary is not the one front door that stays silent.
type
ClientErrorServer
export type ClientErrorServer = {
on(event: string, listener: (error: mixed, socket: ClientErrorSocket) => mixed): mixed,
...
};
The pieces of a node:http server this module attaches to.
type
ClientErrorSocket
export type ClientErrorSocket = {
readonly writableEnded: boolean,
end(chunk?: string): mixed,
...
};
The pieces of a socket a refused request is answered on.
function
export function reportMalformedRequests(server: ClientErrorServer, log: Logger): void { ... }
Say what the runtime's own parser refused, on a server that says nothing.
The whole of ubugeeei-prod/uf#405. Bytes Node's parser rejects never reach a request listener: http.Server answers 400 Bad Request, closes the socket, and — with no clientError handler attached — says so to nobody. An operator whose client is sending a header Node will not accept sees a failing request and an empty terminal, which is the worst combination a server can offer, and a day was spent on exactly that (assert_served built a request target with a space in it, and the space was invisible from every side).
Node's own 400 is still what goes on the wire. This handler writes the same bytes the default one does, because replacing the default means taking over its job as well as adding to it, and answering differently would make the change a behaviour change rather than a diagnostic one.
# The log-volume question, answered
This is the reason somebody might not want it, so it is decided here rather than left to be discovered on a public address. A malformed request is two dozen bytes to send and, unbounded, one line to write — which makes a line per rejection an amplifier: a client that can saturate a link can fill a disk and a log bill with it, and can push whatever an operator actually needed off the end of the retention window.
So the budget is **twenty lines a minute per server, and a count of what that hid**. The first twenty in a window are written; the rest are counted, and the count is written once, when the next window opens, as its own record. A flood therefore costs a fixed number of lines per minute and still says how big it was, which is the number an operator wants from a flood — the individual lines of one are all the same line.
Sampling was the alternative and it is worse for this: one in a hundred of a flood is still unbounded, and one in a hundred of *three* malformed requests is nothing at all, which is the case where the line matters most.
The budget is per call rather than per process, so two servers in one process — uf preview beside a test harness — cannot spend each other's.
# And what is in the line
error.code and nothing else. Node puts the offending bytes on error.rawPacket, and those are whatever the client sent: the one thing @uniflowed/server/log's header spends its length explaining does not belong in a log line. The code is llhttp's own enumeration — HPE_INVALID_METHOD, HPE_HEADER_OVERFLOW — which is a closed set uf did not have to invent, and it is the half that says what to fix.
warn rather than error, for the reason logRequest picks a level from a status: a malformed request is somebody else's mistake far more often than it is this server's.
type
RequestLifecycle
export type RequestLifecycle = {|
/** The context `run` establishes, for a host that needs to read it. */
readonly context: RequestContext,
/** Run the whole request inside it. */
readonly run: <T>(body: () => Promise<T>) => Promise<T>,
/** The response has gone: run what `after()` deferred. */
readonly settle: () => Promise<void>,
|};
One request, from the moment a host has one to the moment its bytes are gone.
Two functions rather than one, because they are called from two places and that is the whole point rather than an inconvenience. run wraps everything that *decides* the response — the guard, the dispatcher, the render — and settle happens after the response has been *written*, which in every host uf has is a different line in a different module. A single handle(request, body) that drained when body returned would be the bug this exists to fix, spelled once instead of twice.
type
RoutingRules
export type RoutingRules = {|
readonly redirects?: $ReadOnlyArray<RedirectRule>,
readonly rewrites?: $ReadOnlyArray<RewriteRule>,
readonly headers?: $ReadOnlyArray<HeaderRule>,
readonly basePath?: string,
readonly trailingSlash?: TrailingSlash,
|};
What the server bundle exports as routing.
function
admit
export function admit(rules: ?RoutingRules, request: Request): Admission { ... }
What a front door does with a request before anything else answers it.
In order: a request outside basePath is a 404; a GET or HEAD for the spelling of a path trailingSlash does not use is a 308 to the one it does; a matching redirect rule is answered. Anything else continues as the same request at its application path, and is remembered as admitted.
The 404 is plain rather than the project's not-found page, because a page is rendered for an address inside the application and this one is not.
function
rewriteFor
export function rewriteFor(rules: ?RoutingRules, request: Request): Request | null { ... }
The request app.router.rewrites hands the application instead, or null.
Asked of an admitted request, whose path is already the application's. Same method, headers and body; another path. The address the visitor asked for is untouched, because a rewrite happens on the server and a redirect is the one that tells the browser.
function
export function headersFor(
rules: ?RoutingRules,
request: Request,
): $ReadOnlyArray<[string, string]> { ... }
The response headers app.router.headers puts on this request's answer.
Asked of the request as it arrived: the base is taken off here, and a request outside it gets none. Every matching rule, in order, lowercased — so a later rule setting the same name wins, which is what applying them one after another with set does.
function
prerenderedMayAnswer
export function prerenderedMayAnswer(cookieHeader: string | null): boolean { ... }
Whether a prerendered document may answer this request.
**This is the reason, and it is written here once.** Four front doors answer a request from bytes that were written before it arrived — uf start's static handler in ../node.js, the compiled binary's embedded index in ../standalone.js, the worker's assets binding in ../edge.js, and uf preview, where the file server is Vite's own and runs in front of everything uf mounts. Each of them asks this, and none of them argues it again. ../lambda.js is not a fifth: its staticDir is served through ../node.js's createStaticHandler, so it is the first door under another name.
A file in dist/ is what the site said *before* the draft existed. Handing one to an editor who came to look at the draft answers a different question from the one they asked, and it would make draft mode a feature that works everywhere except on the pages a build was able to prerender — which are exactly the pages a CMS produces. So a request that carries the draft cookie is rendered, on every door, or draft mode is a property of which command somebody happened to run.
**Documents only.** A stylesheet and a chunk are the same bytes in draft mode as out of it, and skipping those would leave the page unstyled and unhydrated for no gain. Deciding *which* bytes are a document is each door's own — a file extension here, an embedded key there, a content-type at the edge — because that is the one part of the question that depends on where the bytes are kept.
The cookie's **name** decides it and not its signature, for the reason in [carriesDraftCookie]: these doors run before any application code and cannot reach the request context where the verified answer lives. What a forged cookie buys is a live render of a page that is public anyway.
See ubugeeei-prod/uf#282, #615 and #620.
@uniflowed/server/oauth
type
TokenSet
export type TokenSet = {|
readonly accessToken: string,
readonly tokenType: string,
/** Absent for a provider that does not issue one, or a flow that did not ask. */
readonly refreshToken: string | null,
/** The OpenID Connect identity token, unverified; see the module header. */
readonly idToken: string | null,
readonly scope: string | null,
/**
* When the access token stops working, or `null` when the provider did not
* say.
*
* A `Temporal.Instant` rather than a number, because an expiry is a point in
* time and this is the value an application compares against another one.
* `@uniflowed/core/temporal` supplies it on every host uf runs on, so this
* type means the same thing in a worker as it does under `uf start`.
*/
readonly expiresAt: Instant | null,
|};
What a token endpoint answered with, in uf's spelling rather than OAuth's.
type
OAuthIdentity
export type OAuthIdentity = {|
/**
* The provider's own stable id for this person.
*
* Not an email address. An email address is a thing people change and a thing
* some providers let anybody claim without proving it, and an application
* that keyed its accounts on one has an account takeover waiting for it. Put
* the email in `claims` and key on this.
*/
readonly subject: string,
/** Anything else worth keeping about them. Readable by the application. */
readonly claims?: { +[string]: mixed },
|};
Who the tokens turned out to be about.
type
OAuthProvider
export type OAuthProvider = {|
/** Where the browser is sent to ask the person. Must be `https`. */
readonly authorizationEndpoint: string,
/** Where uf exchanges a code for tokens. Must be `https`. */
readonly tokenEndpoint: string,
readonly clientId: string,
/**
* The client secret, for a provider that issues one.
*
* Optional, because a public client with PKCE does not have one and should
* not pretend to. When it is present uf sends it with HTTP Basic, which is
* the method RFC 6749 requires a server to support and the one that keeps it
* out of the request body.
*/
readonly clientSecret?: string,
/** The scopes to ask for, space-separated, exactly as the provider spells them. */
readonly scope?: string,
/**
* Who these tokens are about.
*
* The only provider-specific code in the whole flow. Called once, on the
* callback, with the tokens the exchange produced; whatever it returns is
* what the application reads from `currentSession()` afterwards. Throwing is
* how it says the tokens are not good enough — uf answers `502` and stores
* nothing.
*/
readonly identify: (tokens: TokenSet) => Promise<OAuthIdentity>,
|};
The one thing uf cannot write for you.
Four strings and a function. Everything else about signing in is the same whoever the provider is, which is why this type is the entire seam and why it has no name, no icon, no button and no type: "oauth2" | "oidc" — those are an application's business, and a framework that collected them would be collecting them forever.
type
Session
export type Session = {|
readonly subject: string,
readonly claims: { +[string]: mixed },
/**
* The session is gone after this.
*
* A `Temporal.Instant`, so "is this about to run out" is
* `Temporal.Instant.compare` and not arithmetic on two numbers whose units a
* reader has to take on trust. `JSON.stringify` spells it as the same ISO
* string the `session` handler answers with, so the value a server component
* holds and the value the browser is told are one thing written twice.
*/
readonly expiresAt: Instant,
|};
What an application sees of somebody who is signed in.
type
AuthOptions
export type AuthOptions = {|
readonly provider: OAuthProvider,
readonly store: SessionStore,
/**
* Where `callback` is mounted, as a path on this site.
*
* uf builds the `redirect_uri` from it and the request's origin, and the
* provider must have the same string registered. It is required rather than
* guessed: a framework that assumed `/auth/callback` would be a framework
* whose convention has to match a value in somebody else's dashboard.
*/
readonly callbackPath: string,
/** Where a finished sign-in goes when the request did not say. `/` by default. */
readonly defaultReturnPath?: string,
/**
* The absolute origin to build the `redirect_uri` from.
*
* Optional, and worth setting. Without it uf uses the request's own origin,
* which comes from the `Host` header — a value the client chose. That is
* survivable because the provider only accepts a `redirect_uri` that has been
* registered with it, so a forged `Host` produces a refused authorization
* rather than a code delivered somewhere else; but "survivable because
* somebody else checks" is not where a security-relevant absolute URL should
* come from, and a deployment that knows its own name should say it.
*
* It is not used for the CSRF check. That one compares `Origin` against
* `Host` and nothing else; see `sameOrigin`.
*/
readonly origin?: string,
/** The session cookie's name, before any `__Host-` prefix. */
readonly cookieName?: string,
/**
* Whether uf's cookies are `Secure` and carry the `__Host-` prefix. On by
* default.
*
* One deployment-wide boolean rather than something derived per request from
* the scheme, and that is the point. A cookie whose *name* depended on how a
* request arrived would have two names, and a reader looking for the session
* would have to try both — at which point a subdomain can set the unprefixed
* one and be preferred whenever the prefixed one happens to be absent, which
* is the exact fixation `__Host-` exists to stop. One name, decided once.
*
* On by default because the wrong default here is somebody's session. It
* costs nothing under `uf dev`: browsers treat `http://localhost` as a secure
* context and accept `Secure` and `__Host-` cookies from it. Turning it off
* is for the one case that genuinely cannot be secure — a shared development
* host reached by name over plain HTTP — and it is spelled out so that it is
* a decision somebody made rather than a default they inherited.
*/
readonly secureCookies?: boolean,
readonly sessionSeconds?: number,
readonly authorizationSeconds?: number,
|};
What createAuth may be told.
type
Auth
export type Auth = {|
/** `GET`: begin. Redirects to the provider. `?return=/somewhere` comes back. */
readonly authorize: (request: Request) => Promise<Response>,
/** `GET`: the provider sends the browser here. Establishes the session. */
readonly callback: (request: Request) => Promise<Response>,
/** `POST`: exchange the stored refresh token for a new access token. */
readonly refresh: (request: Request) => Promise<Response>,
/** `GET`: who is signed in. `DELETE`: sign out. */
readonly session: (request: Request) => Promise<Response>,
/** The session behind this request's cookie, for a loader or a component. */
readonly currentSession: () => Promise<Session | null>,
/**
* This request's provider tokens, for calling the provider's own API.
*
* Named apart from `currentSession` so that reaching for a credential is a
* thing somebody chose to do rather than a field they happened to destructure.
* What comes back must not be rendered, returned from a loader, or put in a
* response: a loader's value is embedded in the document uf sends to the
* browser, so a token that reaches one has been published.
*/
readonly tokens: () => Promise<TokenSet | null>,
|};
The four handlers, and the two ways to read what they established.
Each handler is a plain Request → Response, which is what a $route.js exports and what runs unchanged on Node, Bun, Deno and a worker. They are values rather than methods so that export const GET = auth.authorize is the whole of mounting one.
function
createAuth
export function createAuth(options: AuthOptions): Auth { ... }
Wire a provider, a store and a mount point into the four handlers.
The endpoints are checked here rather than on the first request, so a provider configured with an http:// token endpoint fails when the application is built rather than the first time somebody signs in.
type
StoredValue
export type StoredValue = { +[string]: mixed };
The pieces of a session or a half-finished authorization, as stored.
type
SessionStore
export type SessionStore = {|
/** The value under `key`, or `null` when there is none or it has expired. */
readonly read: (key: string) => Promise<StoredValue | null>,
/**
* Put `value` under `key` until `expiresAt`, replacing whatever was there.
*
* `expiresAt` is milliseconds since the epoch — the one number in a package
* that otherwise holds instants; see the module header for why it stops here.
*/
readonly write: (key: string, value: StoredValue, expiresAt: number) => Promise<void>,
/** The value under `key`, removed in the same step; see above. */
readonly take: (key: string) => Promise<StoredValue | null>,
/** Remove `key`, whether or not it was there. */
readonly destroy: (key: string) => Promise<void>,
|};
Where uf keeps what it may not put in a cookie.
Four methods, one value type, and uf owns what goes in — so implementing this against Redis, a table, or a worker's KV is a few lines that know nothing about OAuth. expiresAt is milliseconds since the epoch and is passed separately rather than left inside the value, because a store that can expire entries itself should be told when to, and one that cannot still has to know when to answer null.
# take is not read then destroy
It is the one method that could not be composed from the others, and it is the whole reason a state parameter is single-use. Two callbacks carrying the same state arriving at once must not both find the record: whichever store this is has to make the read and the removal one step, so the second one gets null and is refused. Written as two calls there is a window between them, and a window is all a replay needs.
A Map closes it by being single-threaded, which is what [memorySessionStore] relies on and says so. A durable store closes it with whatever it has — GETDEL, a delete that returns the row, a transaction — and an implementation that cannot is an implementation that must not be used for this.
# Everything is async
Including on the in-memory store, where nothing needs to be. A contract whose default implementation is synchronous is a contract every caller quietly starts assuming is synchronous, and the first durable store to be dropped in behind it finds a dozen places that never awaited.
function
memorySessionStore
export function memorySessionStore(options?: {| readonly capacity?: number |}): SessionStore { ... }
A store in one process's memory.
The implementation uf ships, and the honest description of it is that it is for one process: four instances behind a load balancer hold four different sets of sessions, a restart signs everybody out, and a half-finished sign-in that lands on a different instance is refused as a replay. That is not a defect to be configured around — it is what "in memory" means — and it is spelled out because a default that quietly does not work in production is worse than no default at all.
# What it does when it is full
Expired entries first, and then the oldest surviving one. Both choices are uncomfortable and the second is the lesser: refusing the write instead would mean an attacker who fills the map can stop everybody else signing in, which is a denial of service reachable by anyone, whereas evicting the oldest costs a session that has to be established again. Neither is a reason to run this in production, which is the actual answer.
function
sameOrigin
export function sameOrigin(request: Request): boolean { ... }
Whether this request was made from the site it is addressed to.
The rule docs/security.md states and this is the only implementation of it: **Origin is compared against Host, and never against X-Forwarded-Host.** new URL(request.url).host is the Host header in every host uf ships — ../node.js's toRequest builds the URL from it, and a worker's Request carries the real one — and no forwarded header is read here or anywhere near here. A forwarded header is a header: something a client can send, and therefore something that cannot decide whether a client is allowed to do what it is asking to do. Trusting one is how a Next.js deployment behind a proxy came to be steerable by whoever could set it.
A missing Origin is refused rather than allowed. Every browser sends it on a POST or a DELETE, so a request without one is not a browser — and a non-browser client authenticating with a cookie is a request that should not be answered anyway, because a cookie is exactly the credential the browser attaches whether or not the caller meant it to.
A deployment whose proxy rewrites Host will see these refused, and the fix is the proxy: preserving Host is something every proxy can do, and the alternative is uf believing a header instead.
type
CookieAttributes
export type CookieAttributes = {|
/** Seconds. `0` expires it now, which is how uf clears one. */
readonly maxAge: number,
readonly secure: boolean,
|};
What a cookie uf sets says about itself.
@uniflowed/server/queue
type
RetryPolicy
export type RetryPolicy = {|
/** Runs in total, including the first. `1` never retries. */
readonly attempts: number,
/** Milliseconds before the second attempt; doubles each time after. */
readonly backoff: number,
/** The ceiling that doubling stops at. */
readonly maxBackoff: number,
|};
How often a failing job is tried again, and how long it waits.
type
Job
export type Job<P> = {|
/** How a worker in another process finds this function again. */
readonly name: string,
readonly retry: RetryPolicy,
readonly run: (payload: P) => mixed,
|};
A named unit of work.
type
PartialRetryPolicy
export type PartialRetryPolicy = {|
readonly attempts?: number,
readonly backoff?: number,
readonly maxBackoff?: number,
|};
A retry policy with the parts a definition did not state left out.
type
JobDefinition
export type JobDefinition<P> = {|
readonly name: string,
readonly run: (payload: P) => mixed,
/** Defaults to three attempts, a second apart, doubling to a minute. */
readonly retry?: PartialRetryPolicy,
|};
What defineJob is given.
class
QueueUnavailableError
export class QueueUnavailableError extends CapabilityUnavailableError { ... }
Raised when work is queued with nowhere to put it.
class
UnknownJobError
export class UnknownJobError extends Error { ... }
Raised when a record names a job the runner was not given.
function
defineJob
export function defineJob<P>(definition: JobDefinition<P>): Job<P> { ... }
Declare a job.
A value with a name rather than a bare function, because the two halves of a queue are usually two processes: what enqueue writes is a name and some JSON, and the worker has to find the function from that. A closure cannot be written down.
function
enqueue
export function enqueue<P>(
job: Job<P>,
payload: P,
options?: {| readonly delay?: number |},
): Promise<string> { ... }
Queue payload for job, and answer with the record's id.
Resolves when the backend has the work, which is not when the work is done — that is the entire point, and the reason this returns an id rather than a result. A caller that needs the answer wanted a request.
Inside a request, because the backend is the host's and the host installs it on the request. A worker re-queuing a retry does not come through here; it pushes through the backend it is already holding.
type
JobRunner
export type JobRunner = (record: JobRecord) => Promise<void>;
What a backend calls for one record; see [createRunner].
function
createRunner
export function createRunner(options: {|
readonly jobs: $ReadOnlyArray<AnyJob>,
readonly backend: QueueBackend,
/** The clock, so a suite can drive the backoff without waiting for it. */
readonly now?: () => number,
|}): JobRunner { ... }
The function a consumer calls for each record it takes.
This is uf's half of a backend written by somebody else: the loop that reads from SQS belongs to the deployment, and what it does with each message is this. The retry policy is applied here rather than in the backend so that every backend retries the same way — a policy implemented once per backend is a policy that differs per deployment.
A failed attempt with tries left is pushed back with a later notBefore and this resolves; the record is *handled*, and what is left is scheduled. A failed attempt with none left rejects, so a backend with a dead-letter queue has something to catch. The two are different outcomes and a caller that treated them the same would either retry forever or not at all.
function
backoffFor
export function backoffFor(retry: RetryPolicy, attempt: number): number { ... }
How long to wait before attempt attempt + 1.
Doubling from backoff, capped at maxBackoff. No jitter, and that is a decision rather than an omission: jitter matters when many clients retry the same failure at once, and the callers of this are workers draining a queue they already take from one at a time. Adding it would make every test of a backoff a test of a random number.
type
MemoryQueue
export type MemoryQueue = {|
readonly durable: boolean,
readonly name: string,
readonly push: (record: JobRecord) => Promise<void>,
/** Records waiting, whether or not they are due yet. */
readonly size: () => number,
/**
* Run everything that is due, once.
*
* Records that fail and have attempts left come back with a later
* `notBefore`, so they are not picked up again by this same call — a drain
* that retried in place would turn a one-second backoff into a tight loop.
*/
readonly drain: () => Promise<void>,
/** Stop the timer. A queue that is not stopped holds nothing else open. */
readonly stop: () => void,
|};
[memoryQueue]'s backend, plus the two things only an in-process one has.
function
memoryQueue
export function memoryQueue(options: {|
readonly jobs: $ReadOnlyArray<AnyJob>,
/** Milliseconds between drains. `0` runs no timer, for a suite. */
readonly tick?: number,
/**
* The clock, so a suite can move time rather than wait for it.
*
* It decides when a record is due and how a backoff is measured, and it does
* not reach `enqueue`, which stamps `notBefore` from `Date.now()` because it
* has no backend-specific clock to ask. A suite that injects one should push
* its own records; one that enqueues should leave this alone.
*/
readonly now?: () => number,
/** Where a job that ran out of attempts is reported. */
readonly onFailure?: (record: JobRecord, error: mixed) => void,
|}): MemoryQueue { ... }
A queue that runs in this process.
The one implementation, and it is honest about being the small one: durable is false, which is a value an adapter refuses on rather than a warning somebody reads. Use it for a single long-lived process whose work can be lost — a container, uf start, a development machine — and for the tests of every job in an application, which is the use that makes it worth shipping even to deployments that will never run it in production.
The timer is the whole of its scheduling: every tick it drains what is due. It is unreferenced where the host allows, so a pending job cannot be the reason a process will not exit — a queue that kept uf test alive would be a queue nobody could use in a test.
Jobs run one at a time. A pool is a decision about how much load somebody else's database should take, and this module is in no position to make it.
type
JobRecord
export type JobRecord = {|
readonly id: string,
/** The name `defineJob` gave, which is how a worker finds the function. */
readonly job: string,
/**
* The payload as JSON text.
*
* Text rather than a value, and that is the load-bearing decision in this
* whole type. Every backend a real deployment uses puts the record through a
* process boundary — a Redis list, an SQS message, a row — so an in-process
* backend that passed the object straight through would be the one backend
* where a `Date` is still a `Date`, a shared array is still shared, and a
* job that mutates its payload is visible to the caller. It would work
* locally and be wrong in production, which is the only kind of difference
* worth designing against.
*/
readonly payload: string,
/** 1 for the first run, 2 for the first retry. */
readonly attempt: number,
/** Epoch milliseconds before which this must not run. */
readonly notBefore: number,
|};
One unit of deferred work as it is stored; see ../queue.js.
type
QueueBackend
export type QueueBackend = {
/**
* Whether the work survives the process that pushed it.
*
* Read by the adapters rather than by the queue. An in-process backend on a
* host that keeps running is a legitimate small deployment; the same backend
* on a target that ends with the response is work that is dropped without a
* word. One field, so the difference is a value the wiring can be refused
* over rather than a paragraph somebody has to have read.
*/
readonly durable: boolean,
/** A name for this backend, for the message when one is refused. */
readonly name: string,
/** Take one record. Resolves when the backend has it, not when it has run. */
readonly push: (record: JobRecord) => Promise<void>,
...
};
Where enqueued work goes.
The producer half only. A backend that can also *run* work says so by having somewhere to call ../queue.js's runner from; nothing here requires it, because the deployment that stores the work is not always the process that drains it — which is the whole difference between a queue and after().
Inexact, and that is the point rather than laziness: a backend is written by the deployment and is entitled to carry whatever else it needs — a poll loop, a connection, the drain that ../queue.js's own in-process one exposes. These three are what uf reads.
Without a doc comment
@uniflowed/server/schedule
type
Schedule
export type Schedule = {|
readonly name: string,
readonly cron: Cron,
readonly run: () => mixed,
|};
A named schedule: when it runs, and what runs.
variable
processScheduler
export const processScheduler: SchedulerBackend = Object.freeze({
name: "process",
triggered: false,
});
uf's own scheduler: a tick in this process.
triggered: false, which is what makes a target that does not keep a process refuse it.
function
defineSchedule
export function defineSchedule(options: {|
readonly name: string,
readonly cron: string,
readonly run: () => mixed,
|}): Schedule { ... }
Declare a schedule.
The expression is parsed here rather than on the first tick, so a typo is a module that fails to load instead of a schedule that quietly never matches.
type
Tick
export type Tick = {|
readonly ran: $ReadOnlyArray<string>,
readonly failed: $ReadOnlyArray<string>,
|};
What a scheduler did with one minute.
function
createScheduler
export function createScheduler(options: {|
readonly schedules: $ReadOnlyArray<Schedule>,
readonly log?: Logger,
|}): {|
readonly tick: (instant: Instant) => Promise<Tick>,
readonly start: () => () => void,
|} { ... }
Decide and run whatever is due, once per minute at most.
tick is the whole interface, and start is a convenience over it. A host that has its own loop — a platform scheduler calling in, or a test — drives tick with the instant it means, and gets the same decisions.
function
startSchedules
export function startSchedules(
schedules: $ReadOnlyArray<Schedule> | void,
log: Logger,
): () => void { ... }
Start schedules ticking, and hand back the way to stop.
The two lines every host that keeps a process needs, in one place: both ./node.js and ./bun.js take an optional list on serve and neither should own the decision about how often a tick happens.
A host given no schedules pays nothing — no timer is armed, which is the same bargain installInterception makes for module mocks.
variable
SCHEDULED_ORIGIN
export const SCHEDULED_ORIGIN: string = "https://cron.invalid";
The origin a scheduled invocation carries.
A schedule has no request behind it and therefore no host, and a handler that reads new URL(request.url).host has to read *something*. A reserved-invalid name rather than the deployment's own: .invalid can never resolve, so a handler that echoes the origin into a link produces something obviously wrong rather than something that looks right and points at the wrong place.
variable
export const SCHEDULED_HEADER: string = "uf-scheduled";
The header naming the expression that fired, on a scheduled invocation.
function
runScheduled
export function runScheduled(options: {|
readonly handle: (request: Request) => Promise<Response>,
readonly beginRequest: (request: Request) => RequestLifecycle,
readonly path: string,
readonly cron: string,
readonly log?: Logger,
|}): Promise<RequestLifecycle> { ... }
Run one route the way a schedule runs it: a request the platform made.
**The one implementation of "a schedule is a request"**, used by every host that has one. ./edge.js's createWorkerScheduled calls it for Cloudflare's cron, and the entry uf build --adapter node writes calls it through defineSchedule. Two copies of this would be two answers to "does a scheduled run have a request context", and the answer has to be yes on every target or cookies() means something different depending on where the deployment went.
GET because a cron has no body to send, through the application's own handler so that a scheduled run and a curl of the same path are the same code, and inside the lifecycle so the run has a context, an id, and an after() that settles.
A route that throws is logged and swallowed. There is no caller above a scheduled run to catch it, and on a host that ticks, a rejection would take down the timer for every other schedule with it.
function
routeSchedule
export function routeSchedule(options: {|
readonly handle: (request: Request) => Promise<Response>,
readonly beginRequest: (request: Request) => RequestLifecycle,
readonly path: string,
readonly cron: string,
|}): Schedule { ... }
A schedule that runs one of this application's own routes.
What the entry uf build --adapter node writes calls, once per declared schedule. The name is the route's path, because that is what a platform's scheduler would call it on a target that had one — so the same declaration reads the same in a log wherever it ran.
type
SchedulerBackend
export type SchedulerBackend = {|
readonly name: string,
readonly triggered: boolean,
|};
Where scheduled work runs from; see ../schedule.js.
@uniflowed/server/socket
class
UpgradeUnavailableError
export class UpgradeUnavailableError extends CapabilityUnavailableError { ... }
Raised when a handler asks for a socket the host cannot give it.
Its own class rather than a CapabilityUnavailableError with a longer message, because this is the one a person will search for — and because a handler that would rather answer 426 Upgrade Required than let a 500 out needs something specific to catch.
function
canUpgrade
export function canUpgrade(): boolean { ... }
Whether this request could be upgraded.
For a handler with something else to answer — a page that falls back to polling, or a health check that reports what the deployment can do. A handler with no fallback should call [upgradeWebSocket] and let it throw: a false that is caught and ignored is how a missing capability becomes a feature that quietly does not work.
function
isUpgradeRequest
export function isUpgradeRequest(request: Request): boolean { ... }
Whether request is asking to be upgraded to a WebSocket.
Both values are compared case-insensitively, and Connection is searched rather than matched because it is a comma-separated list a proxy is entitled to have added to: Connection: keep-alive, Upgrade is what several of them send, and an equality test rejects exactly the requests that arrived through infrastructure.
function
upgradeWebSocket
export function upgradeWebSocket(request: Request): WebSocketUpgrade { ... }
Take this request's connection, or name the host that would not.
The response has to be returned from the handler: on every runtime it is what completes the handshake, and on Cloudflare it is the only way the platform learns which end of the pair the worker keeps. Listeners may be attached before or after returning it — the socket is accepted by the time this returns.
function
currentCapabilities
export function currentCapabilities(): ServerCapabilities | null { ... }
What the host answering this request can do, or null where none said.
Exported for a handler that wants to report the deployment's shape rather than discover it — a health check, or the uf-shaped answer to "why does this work locally". Everything that *decides* on a capability in this package reads it through a binding of its own, so that the refusal is one message written once rather than an if in every handler.
type
SocketEvent
export type SocketEvent = { readonly data?: mixed, ... };
An event a socket delivers; inexact, because close carries no data.
type
WebSocketLike
export type WebSocketLike = {
readonly send: (data: string) => mixed,
readonly close: (code?: number, reason?: string) => mixed,
readonly addEventListener: (
type: "message" | "close" | "error",
listener: (event: SocketEvent) => mixed,
) => mixed,
...
};
A socket the host handed back from an upgrade.
The three members a handler actually uses, declared structurally so this package holds no copy of another project's WebSocket type: what arrives is Cloudflare's, Deno's, Bun's or ws's, and all four are this much.
type
WebSocketUpgrade
export type WebSocketUpgrade = {|
/** The handshake response to return from the handler. */
readonly response: Response,
/** This end of the connection, already accepted. */
readonly socket: WebSocketLike,
|};
What a host's upgrade answered with: the response to send, and the socket.
type
WebSocketUpgrader
export type WebSocketUpgrader = (request: Request) => WebSocketUpgrade;
A host's WebSocket upgrade.
Supplied by the deployment rather than implemented here, and ../socket.js argues that at length: the runtimes uf targets spell this four incompatible ways, and Node does not spell it at all.
type
ServerCapabilities
export type ServerCapabilities = {|
/** The adapter's own name for itself: `node`, `edge`, `serverless`, `dev`. */
readonly target: string,
/**
* Whether a response body reaches the client as it is produced.
*
* False on a target that reads the whole body before answering — which is
* every serverless invocation in this package, because a Lambda response in
* payload format 2.0 is a JSON value. A document survives that as a slower
* document; an event stream does not survive it at all.
*/
readonly stream: boolean,
/**
* Whether the process is still there once the response has been written.
*
* False for a serverless invocation, which is billed until it returns and
* frozen afterwards, and false for a worker isolate, which the platform may
* tear down the moment the response is out — the reason `after()` on that
* target goes through `ctx.waitUntil` rather than being awaited.
*/
readonly persistent: boolean,
/** The host's upgrade, or `null` where it has none. */
readonly websocket: WebSocketUpgrader | null,
/** Where `enqueue` puts work, or `null` where the deployment named none. */
readonly queue: QueueBackend | null,
/**
* Where scheduled work runs from, or `null` where the deployment named none.
*
* Read by the adapters rather than by `../schedule.js`, the same way `queue`
* is: what the scheduler needs from a target is somewhere to be at the right
* minute, and whether that is uf's tick or the platform's own call is the
* one bit `triggered` carries.
*/
readonly scheduler: SchedulerBackend | null,
|};
What the host answering this request can do.
class
CapabilityUnavailableError
export class CapabilityUnavailableError extends Error { ... }
Raised when a request asks for something the host answering it cannot do.
The other half of the pair, and the one that catches what a wiring-time refusal cannot: a deployment with no WebSocket handler today is wired exactly as it will be on the day somebody adds one.
@uniflowed/server/standalone
function
standaloneCapabilities
export function standaloneCapabilities(options?: CapabilityOptions): ServerCapabilities { ... }
What a compiled binary can do, plus whatever the deployment supplied.
The same two answers nodeCapabilities gives, because it is the same kind of host: a socket this process is holding, and a process that is still there once a response has gone. It is a separate function only so that the target a refusal names is the one somebody actually ran — "the standalone host has no WebSocket upgrader" points at a binary, and "the node host" points at a directory of JavaScript.
type
EmbeddedAsset
export type EmbeddedAsset = {|
/** The `content-type` to serve it with, decided at build time. */
readonly type: string,
/** The file's bytes, base64. */
readonly body: string,
|};
One file from dist/, as uf build --compile embedded it.
type
EmbeddedAssets
export type EmbeddedAssets = { readonly [path: string]: EmbeddedAsset };
Every embedded file, keyed by its path relative to the output directory.
type
DocumentAssets
export type DocumentAssets = {|
readonly scripts: $ReadOnlyArray<string>,
readonly styles: $ReadOnlyArray<string>,
readonly preloads: $ReadOnlyArray<string>,
/** The build these URLs belong to; see `./internal/application.js`. */
readonly deployment?: string,
|};
The script, stylesheet and preload URLs a rendered document references.
type
StandaloneApp
export type StandaloneApp = {|
/**
* Render `url`, resolving when the *shell* is ready.
*
* The same `{ status, headers?, pipe }` the router hands `uf start` and
* every adapter — not a finished string. A binary that collected the whole
* document before answering would be the one deployment target that does not
* stream, and the reason `renderToString` was replaced is that the wait is
* the slowest thing on the page.
*/
readonly render: (
url: string,
assets: DocumentAssets,
options?: {|
readonly onError?: (error: mixed) => void,
/** See `./internal/application.js`. */
readonly formState?: FormState,
|},
) => Promise<{|
readonly status: number,
readonly headers?: { readonly [string]: string },
// A promise, and not `void`: `DocumentBody.pipe` resolves on the last byte
// and rejects when the render fails after the shell. Typing it away was
// how the rejection below came to be dropped.
readonly pipe: (destination: NodeResponse) => Promise<void>,
readonly stream: () => ReadableStream<Uint8Array>,
|}>,
readonly dispatch: (request: Request) => Promise<Response | null>,
/**
* The server action this request names, or `null` when it names none.
*
* Between the guard and the handlers here as in every other host, and called
* rather than tested for on the same reasoning as `runMiddleware` below: a
* binary whose bundle predates this is a `TypeError` on the first request
* rather than one whose actions quietly answer 404.
*/
readonly callAction: (
request: Request,
settings?: {| readonly postback?: (formState: FormState) => Promise<Response> |},
) => Promise<Response | null>,
/**
* The guard on the path, run before anything under it answers.
*
* Called rather than tested for: a server bundle without it is a `TypeError`
* on the first request, not an application whose auth check quietly stopped
* running once it was compiled. See ubugeeei-prod/uf#260, and
* `@uniflowed/vite`'s `createApplicationHandler`, which says the same thing
* about `uf preview` and `uf start`.
*
* A `Request` back is a middleware's `rewrite()`; see `./internal/application.js`.
*/
readonly runMiddleware: (request: Request) => Promise<Response | Request | null>,
/** `app.router`'s redirects, rewrites and headers; see `./internal/routing.js`. */
readonly routing?: RoutingRules,
/**
* Begin the request everything above runs inside.
*
* From the application bundle rather than from this module's own import of
* `@uniflowed/server/host`, and that is not a stylistic choice: the request
* store is shared by every copy of one *release* of `@uniflowed/server`, and
* the release that matters is the one the bundled router, middleware and
* pages resolved to. Beginning a request in another release's store would
* leave every `cookies()` in the application outside one, silently.
*
* `run` wraps everything that decides the response; `settle` is called after
* the last byte, which here is after `send`, after `sendBytes`, and after
* `pipe` resolves. See ubugeeei-prod/uf#389.
*/
readonly beginRequest: (request: Request) => {|
/**
* The request itself, so this module can say what the host can do.
*
* Named here rather than left off because a compiled binary reaches a
* route handler without going through `./fetch.js`, which is where the
* other three front doors install their capabilities — and a handler that
* streams events has to get the same answer from all four.
*/
readonly context: { capabilities: ServerCapabilities | null, ... },
readonly run: <T>(body: () => Promise<T>) => Promise<T>,
readonly settle: () => Promise<void>,
|},
|};
What the project's server bundle exports; see virtual:uf/server.
type
HandlerOptions
export type HandlerOptions = {|
readonly app: StandaloneApp,
readonly assets: EmbeddedAssets,
readonly document: DocumentAssets,
|};
Everything an application needs to answer a request, all of it built in.
type
ServeOptions
export type ServeOptions = {|
readonly app: StandaloneApp,
readonly assets: EmbeddedAssets,
readonly document: DocumentAssets,
/** Overridden by `--port` and then by `PORT`; defaults to 3000. */
readonly port?: number,
/** Overridden by `--host` and then by `HOST`; defaults to loopback. */
readonly host?: string,
|};
What [serve] needs: the above, and where to listen.
function
serve
export function serve(options: ServeOptions): Promise<{|
readonly host: string,
readonly port: number,
readonly close: () => Promise<void>,
|}> { ... }
Serve the application until the process is stopped.
Resolves once the socket is listening, with the address it took — a caller that asked for port 0 has no other way to learn which port it got, and the test that drives a compiled binary needs exactly that.
function
createHandler
export function createHandler(
options: HandlerOptions,
): (NodeRequest, NodeResponse) => Promise<void> { ... }
One request, answered — exported so an application can be mounted rather than only run.
serve is the whole of a compiled binary, and it is not the whole of what anybody wants: a uf application behind an existing Node server, or beside other routes in one process, needs the request handling without the socket. That is this. It is also what the tests drive, which is not a coincidence — a request handler that can only be reached through a listening socket is one that can only be tested on a machine allowed to bind one.
The order is internal/serve.js's, which is Vite's:
1. a file uf build already wrote, for GET and HEAD only — an embedded path that matches exactly, such as /assets/index-a1b2c3.js or anything copied out of public/, and then the prerendered document for this URL, because /guide was written as guide/index.html; 2. a route handler, for any method, because a handler is the only thing that answers a POST and may also answer a GET for a path with no page; 3. and otherwise the renderer, which also produces the 404.
The prerendered document is looked up *before* the dispatcher, and that is the one place this used to disagree with uf preview and uf start. The router allows a handler to sit beside a page in the same directory, so a path can have both — and Vite's preview server serves the file first with no say in the matter, so a binary that let the handler win would answer differently from the command a build is checked with. Answering the same wrong-looking way as the other two is worth more than answering a better way alone.
A page never answers a POST: letting one try turns a missing handler into a rendered page with a 200 where the caller expected a 405.