@uniflowed/server 0.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/host.js +23 -0
- package/index.js +101 -0
- package/internal/context.js +179 -0
- package/package.json +22 -0
package/host.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/server/host`: how a host establishes a request.
|
|
4
|
+
//
|
|
5
|
+
// The other half of this package. `@uniflowed/server` is what an application
|
|
6
|
+
// calls *inside* a request; this is what a renderer, a route dispatcher or a
|
|
7
|
+
// server-action bridge calls to say that a request has begun and, later, that
|
|
8
|
+
// the response has gone.
|
|
9
|
+
//
|
|
10
|
+
// Two subpaths rather than one module, because they have opposite audiences and
|
|
11
|
+
// opposite rules. Everything in the root is safe to call from a component and
|
|
12
|
+
// meaningless outside a request; everything here is meaningless *inside* one
|
|
13
|
+
// and must be called exactly once around it. Mixing them would put
|
|
14
|
+
// `runWithContext` in the same import a page reaches for, which is an invitation
|
|
15
|
+
// to nest one request inside another.
|
|
16
|
+
//
|
|
17
|
+
// It is a subpath rather than `internal/` because a sibling package cannot
|
|
18
|
+
// reach another's internals: `@uniflowed/router` is where a request begins, and
|
|
19
|
+
// it is a different npm package.
|
|
20
|
+
|
|
21
|
+
export type { CookieStore, DraftMode, HeaderStore, RequestContext } from "./internal/context.js";
|
|
22
|
+
|
|
23
|
+
export { contextFor, drainDeferred, parseCookies, runWithContext } from "./internal/context.js";
|
package/index.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/server`: what a server function may ask about its request.
|
|
4
|
+
//
|
|
5
|
+
// Every binding here takes no arguments and answers about the request being
|
|
6
|
+
// handled, which is only possible because the renderer establishes a context
|
|
7
|
+
// around each one ([`./internal/context.js`]). Outside a request they throw,
|
|
8
|
+
// and each says what it was that had nowhere to look — a component that calls
|
|
9
|
+
// `cookies()` during a static prerender has made a mistake worth naming, not a
|
|
10
|
+
// mistake worth returning `null` for.
|
|
11
|
+
//
|
|
12
|
+
// This module is server-only. Nothing in it is reachable from a client
|
|
13
|
+
// component, `uf:rsc` classifies it that way, and importing it from one is the
|
|
14
|
+
// error that classification exists to produce.
|
|
15
|
+
|
|
16
|
+
import type { CookieStore, DraftMode, HeaderStore } from "./internal/context.js";
|
|
17
|
+
import { currentContext } from "./internal/context.js";
|
|
18
|
+
|
|
19
|
+
export type { CookieStore, DraftMode, HeaderStore } from "./internal/context.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Raised when a server function is called with no request to answer about.
|
|
23
|
+
*
|
|
24
|
+
* Names the binding, because "no request context" on its own leaves a reader
|
|
25
|
+
* hunting for which of the six things they called was the one out of place.
|
|
26
|
+
*/
|
|
27
|
+
export class OutsideRequestError extends Error {
|
|
28
|
+
/** The binding that was called, e.g. `cookies`. */
|
|
29
|
+
binding: string;
|
|
30
|
+
|
|
31
|
+
constructor(binding: string) {
|
|
32
|
+
super(
|
|
33
|
+
`@uniflowed/server: ${binding}() was called outside a request. ` +
|
|
34
|
+
"It answers about the request being handled, and there is not one here — " +
|
|
35
|
+
"a static prerender, a module's top level, or a client component.",
|
|
36
|
+
);
|
|
37
|
+
this.name = "OutsideRequestError";
|
|
38
|
+
this.binding = binding;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The current request's context, or a named failure. */
|
|
43
|
+
function require$Context(binding: string) {
|
|
44
|
+
const context = currentContext();
|
|
45
|
+
if (context == null) {
|
|
46
|
+
throw new OutsideRequestError(binding);
|
|
47
|
+
}
|
|
48
|
+
return context;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The request's headers, read-only.
|
|
53
|
+
*
|
|
54
|
+
* Read-only because a response header set from inside a render has no defined
|
|
55
|
+
* moment to take effect: the headers may already be on the wire by the time a
|
|
56
|
+
* component deep in the tree renders.
|
|
57
|
+
*/
|
|
58
|
+
export function headers(): HeaderStore {
|
|
59
|
+
return require$Context("headers").headers;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The request's cookies, read-only.
|
|
64
|
+
*
|
|
65
|
+
* Setting a cookie belongs to a route handler or a server action, which run
|
|
66
|
+
* before a response exists and can say so in it.
|
|
67
|
+
*/
|
|
68
|
+
export function cookies(): CookieStore {
|
|
69
|
+
return require$Context("cookies").cookies;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether this request is rendering draft content.
|
|
74
|
+
*
|
|
75
|
+
* The flag lives on the request rather than in a module, so two requests being
|
|
76
|
+
* handled at once cannot see each other's answer.
|
|
77
|
+
*/
|
|
78
|
+
export function draftMode(): DraftMode {
|
|
79
|
+
const context = require$Context("draftMode");
|
|
80
|
+
return {
|
|
81
|
+
isEnabled: context.draft,
|
|
82
|
+
enable: () => {
|
|
83
|
+
context.draft = true;
|
|
84
|
+
},
|
|
85
|
+
disable: () => {
|
|
86
|
+
context.draft = false;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run `callback` once the response has been sent.
|
|
93
|
+
*
|
|
94
|
+
* For the work a request causes but a response does not wait on: recording a
|
|
95
|
+
* view, flushing a metric, warming a cache. Registered work runs in the order
|
|
96
|
+
* it was registered, and one task failing does not stop the others — deferred
|
|
97
|
+
* work is by definition not what the response depended on.
|
|
98
|
+
*/
|
|
99
|
+
export function after(callback: () => mixed | Promise<mixed>): void {
|
|
100
|
+
require$Context("after").deferred.push(callback);
|
|
101
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/server`: the request a server function is inside.
|
|
4
|
+
//
|
|
5
|
+
// `headers()` and `cookies()` take no arguments, which is the whole point —
|
|
6
|
+
// a component nested six levels down should not have to be handed a request
|
|
7
|
+
// that every layer between it and the server has to thread through. That
|
|
8
|
+
// convenience needs somewhere to keep the request, and "somewhere" has exactly
|
|
9
|
+
// one safe answer on a server: storage scoped to the asynchronous call tree of
|
|
10
|
+
// the request being handled.
|
|
11
|
+
//
|
|
12
|
+
// A module-level variable would be wrong in a way that only shows up under
|
|
13
|
+
// load. `renderToString` is synchronous, so a variable set around it reads
|
|
14
|
+
// correctly — right up until a route awaits something, another request arrives
|
|
15
|
+
// while it is suspended, and the second request's headers are what the first
|
|
16
|
+
// one sees. `AsyncLocalStorage` is the primitive that does not have that bug,
|
|
17
|
+
// and Node, Deno and Bun all provide it under the `node:` specifier.
|
|
18
|
+
//
|
|
19
|
+
// This module is server-only by construction: nothing in `@uniflowed/server`
|
|
20
|
+
// is reachable from a client component, and `uf:rsc` classifies it that way.
|
|
21
|
+
|
|
22
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
23
|
+
|
|
24
|
+
/** A read-only view of one request's headers. */
|
|
25
|
+
export type HeaderStore = {
|
|
26
|
+
readonly get: (name: string) => string | null,
|
|
27
|
+
readonly has: (name: string) => boolean,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A read-only view of one request's cookies. */
|
|
31
|
+
export type CookieStore = {
|
|
32
|
+
readonly get: (name: string) => string | null,
|
|
33
|
+
readonly has: (name: string) => boolean,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Whether this request is rendering draft content, and how to change that. */
|
|
37
|
+
export type DraftMode = {
|
|
38
|
+
readonly isEnabled: boolean,
|
|
39
|
+
readonly enable: () => void,
|
|
40
|
+
readonly disable: () => void,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Everything a server function may ask about the request it is inside.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately not the `Request` itself. A server function that could reach the
|
|
47
|
+
* whole request could read the body, which is already being consumed by the
|
|
48
|
+
* thing that called it, and could hold it past the response.
|
|
49
|
+
*/
|
|
50
|
+
export type RequestContext = {
|
|
51
|
+
readonly headers: HeaderStore,
|
|
52
|
+
readonly cookies: CookieStore,
|
|
53
|
+
draft: boolean,
|
|
54
|
+
/** Work deferred until the response has been sent. */
|
|
55
|
+
readonly deferred: Array<() => mixed | Promise<mixed>>,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const storage: AsyncLocalStorage<RequestContext> = new AsyncLocalStorage();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The context of the request being handled, or `null` outside one.
|
|
62
|
+
*
|
|
63
|
+
* `null` rather than throwing, so each caller can say what *it* needed the
|
|
64
|
+
* request for — "cookies() was called outside a request" is a better error than
|
|
65
|
+
* one generic message from here.
|
|
66
|
+
*/
|
|
67
|
+
export function currentContext(): RequestContext | null {
|
|
68
|
+
return storage.getStore() ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run `body` with `context` as the current request.
|
|
73
|
+
*
|
|
74
|
+
* Everything `body` awaits sees the same context, and nothing outside it does.
|
|
75
|
+
*/
|
|
76
|
+
export function runWithContext<T>(context: RequestContext, body: () => T): T {
|
|
77
|
+
return storage.run(context, body);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a context from a `Request`.
|
|
82
|
+
*
|
|
83
|
+
* The header and cookie views are built once and read many times: a render
|
|
84
|
+
* touches `cookies().get(…)` as often as it has components that care, and
|
|
85
|
+
* re-parsing the cookie header each time would be the kind of cost nobody
|
|
86
|
+
* looks for.
|
|
87
|
+
*/
|
|
88
|
+
export function contextFor(request: Request): RequestContext {
|
|
89
|
+
const headers = request.headers;
|
|
90
|
+
const cookies = parseCookies(headers.get("cookie"));
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
headers: {
|
|
94
|
+
get: (name) => headers.get(name),
|
|
95
|
+
has: (name) => headers.has(name),
|
|
96
|
+
},
|
|
97
|
+
cookies: {
|
|
98
|
+
get: (name) => (Object.hasOwn(cookies, name) ? cookies[name] : null),
|
|
99
|
+
has: (name) => Object.hasOwn(cookies, name),
|
|
100
|
+
},
|
|
101
|
+
draft: false,
|
|
102
|
+
deferred: [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse a `Cookie` header into a plain object.
|
|
108
|
+
*
|
|
109
|
+
* `Object.create(null)` rather than `{}`: a cookie called `__proto__` is a
|
|
110
|
+
* thing an attacker can set, and on an ordinary object it would not be a key
|
|
111
|
+
* at all — it would be the prototype.
|
|
112
|
+
*
|
|
113
|
+
* A duplicated name keeps the first value, which is what every server-side
|
|
114
|
+
* cookie parser does and what browsers send for a name set at two paths.
|
|
115
|
+
*/
|
|
116
|
+
export function parseCookies(header: string | null): { [string]: string } {
|
|
117
|
+
const out: { [string]: string } = Object.create(null);
|
|
118
|
+
if (header == null || header === "") {
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const pair of header.split(";")) {
|
|
123
|
+
const at = pair.indexOf("=");
|
|
124
|
+
if (at < 0) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const name = pair.slice(0, at).trim();
|
|
128
|
+
if (name === "" || Object.hasOwn(out, name)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
out[name] = decodeValue(pair.slice(at + 1).trim());
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Decode one cookie value, leaving it alone if it is not valid encoding.
|
|
138
|
+
*
|
|
139
|
+
* `decodeURIComponent` throws on a stray `%`, and a malformed cookie is not a
|
|
140
|
+
* reason to fail a request — the value is simply not what the sender meant.
|
|
141
|
+
*/
|
|
142
|
+
function decodeValue(value: string): string {
|
|
143
|
+
const unquoted =
|
|
144
|
+
value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
|
145
|
+
try {
|
|
146
|
+
return decodeURIComponent(unquoted);
|
|
147
|
+
} catch {
|
|
148
|
+
return unquoted;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Run everything `after()` deferred, in the order it was registered.
|
|
154
|
+
*
|
|
155
|
+
* A failure is reported and does not stop the rest: deferred work is by
|
|
156
|
+
* definition not what the response depended on, and one broken analytics call
|
|
157
|
+
* should not take the others with it.
|
|
158
|
+
*/
|
|
159
|
+
export async function drainDeferred(context: RequestContext): Promise<void> {
|
|
160
|
+
const pending = context.deferred.splice(0, context.deferred.length);
|
|
161
|
+
for (const task of pending) {
|
|
162
|
+
try {
|
|
163
|
+
await task();
|
|
164
|
+
} catch (error) {
|
|
165
|
+
reportDeferredFailure(error);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Report a deferred task that threw.
|
|
172
|
+
*
|
|
173
|
+
* Isolated so a host can be given somewhere to put this; today it is the
|
|
174
|
+
* console, which is where an unhandled rejection would have gone anyway.
|
|
175
|
+
*/
|
|
176
|
+
function reportDeferredFailure(error: mixed): void {
|
|
177
|
+
// eslint-disable-next-line no-console
|
|
178
|
+
console.error("uf: a task registered with after() failed", error);
|
|
179
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniflowed/server",
|
|
3
|
+
"version": "0.0.0-alpha.2",
|
|
4
|
+
"description": "Request-scoped server functions for the Unified Toolchain for Flow (React).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ubugeeei-prod/uf.git",
|
|
11
|
+
"directory": "packages/server"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./host": "./host.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"index.js",
|
|
19
|
+
"host.js",
|
|
20
|
+
"internal/*.js"
|
|
21
|
+
]
|
|
22
|
+
}
|