@uniflowed/server 0.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cache.js +341 -0
- package/edge.js +158 -0
- package/fetch.js +310 -0
- package/host.js +56 -0
- package/index.js +133 -0
- package/internal/application.js +90 -0
- package/internal/cache-key.js +79 -0
- package/internal/cache-store.js +524 -0
- package/internal/context.js +285 -0
- package/lambda.js +266 -0
- package/node.js +460 -0
- package/package.json +34 -0
- package/standalone.js +593 -0
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
import type { CacheOptions } from "./cache-store.js";
|
|
25
|
+
|
|
26
|
+
/** A read-only view of one request's headers. */
|
|
27
|
+
export type HeaderStore = {
|
|
28
|
+
readonly get: (name: string) => string | null,
|
|
29
|
+
readonly has: (name: string) => boolean,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** A read-only view of one request's cookies. */
|
|
33
|
+
export type CookieStore = {
|
|
34
|
+
readonly get: (name: string) => string | null,
|
|
35
|
+
readonly has: (name: string) => boolean,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Whether this request is rendering draft content, and how to change that. */
|
|
39
|
+
export type DraftMode = {
|
|
40
|
+
readonly isEnabled: boolean,
|
|
41
|
+
readonly enable: () => void,
|
|
42
|
+
readonly disable: () => void,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Everything a server function may ask about the request it is inside.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately not the `Request` itself. A server function that could reach the
|
|
49
|
+
* whole request could read the body, which is already being consumed by the
|
|
50
|
+
* thing that called it, and could hold it past the response.
|
|
51
|
+
*/
|
|
52
|
+
export type RequestContext = {
|
|
53
|
+
readonly headers: HeaderStore,
|
|
54
|
+
readonly cookies: CookieStore,
|
|
55
|
+
draft: boolean,
|
|
56
|
+
/** Work deferred until the response has been sent. */
|
|
57
|
+
readonly deferred: Array<() => mixed | Promise<mixed>>,
|
|
58
|
+
/**
|
|
59
|
+
* How many times this request has read state that varies per request.
|
|
60
|
+
*
|
|
61
|
+
* A counter rather than a flag, and the difference is what makes a route
|
|
62
|
+
* cache possible at all. A middleware that reads a cookie to decide whether
|
|
63
|
+
* to let the request through has not made the *page* vary — it either
|
|
64
|
+
* answered or it did not — so a flag set by that read would refuse to cache
|
|
65
|
+
* every page in every application that has an auth guard. A counter can be
|
|
66
|
+
* read before the render and again after the last byte, and what moved in
|
|
67
|
+
* between is exactly what the document depended on.
|
|
68
|
+
*
|
|
69
|
+
* Incremented by `../index.js`'s three bindings and by nothing else.
|
|
70
|
+
* `after()` reads the context too and does not touch this: registering
|
|
71
|
+
* deferred work says nothing about what the response contains.
|
|
72
|
+
*/
|
|
73
|
+
requestStateReads: number,
|
|
74
|
+
/**
|
|
75
|
+
* The cache the host installed for this request, or `null`.
|
|
76
|
+
*
|
|
77
|
+
* On the context rather than in a module-level variable, which is the same
|
|
78
|
+
* decision `storage` above is and rests on the same fact: two requests are
|
|
79
|
+
* answered at once, and anything a server function reaches for by name has
|
|
80
|
+
* to be scoped to the request or it is scoped to whichever request set it
|
|
81
|
+
* last. It also means `revalidateTag()` in a server action reaches the store
|
|
82
|
+
* that answered the request the action is part of, rather than a copy some
|
|
83
|
+
* other module instance is holding — the hazard ubugeeei-prod/uf#389 is
|
|
84
|
+
* about, pointed at a cache.
|
|
85
|
+
*/
|
|
86
|
+
cache: CacheOptions | null,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One request, from the moment a host has one to the moment its bytes are gone.
|
|
91
|
+
*
|
|
92
|
+
* Two functions rather than one, because they are called from two places and
|
|
93
|
+
* that is the whole point rather than an inconvenience. `run` wraps everything
|
|
94
|
+
* that *decides* the response — the guard, the dispatcher, the render — and
|
|
95
|
+
* `settle` happens after the response has been *written*, which in every host
|
|
96
|
+
* uf has is a different line in a different module. A single
|
|
97
|
+
* `handle(request, body)` that drained when `body` returned would be the bug
|
|
98
|
+
* this exists to fix, spelled once instead of twice.
|
|
99
|
+
*/
|
|
100
|
+
export type RequestLifecycle = {|
|
|
101
|
+
/** The context `run` establishes, for a host that needs to read it. */
|
|
102
|
+
readonly context: RequestContext,
|
|
103
|
+
/** Run the whole request inside it. */
|
|
104
|
+
readonly run: <T>(body: () => Promise<T>) => Promise<T>,
|
|
105
|
+
/** The response has gone: run what `after()` deferred. */
|
|
106
|
+
readonly settle: () => Promise<void>,
|
|
107
|
+
|};
|
|
108
|
+
|
|
109
|
+
const storage: AsyncLocalStorage<RequestContext> = new AsyncLocalStorage();
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The context of the request being handled, or `null` outside one.
|
|
113
|
+
*
|
|
114
|
+
* `null` rather than throwing, so each caller can say what *it* needed the
|
|
115
|
+
* request for — "cookies() was called outside a request" is a better error than
|
|
116
|
+
* one generic message from here.
|
|
117
|
+
*/
|
|
118
|
+
export function currentContext(): RequestContext | null {
|
|
119
|
+
return storage.getStore() ?? null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether a request has been established around this call.
|
|
124
|
+
*
|
|
125
|
+
* For a caller that is not a server function and has nothing to answer about
|
|
126
|
+
* the request — the router's dispatcher and its middleware runner, which need
|
|
127
|
+
* to know that a host established one *before* anything they call asks for
|
|
128
|
+
* cookies. They must not be handed the context itself: a module that can reach
|
|
129
|
+
* it can drain it, which is how the drain came to be in the wrong place.
|
|
130
|
+
*/
|
|
131
|
+
export function insideRequest(): boolean {
|
|
132
|
+
return storage.getStore() != null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Run `body` with `context` as the current request.
|
|
137
|
+
*
|
|
138
|
+
* Everything `body` awaits sees the same context, and nothing outside it does.
|
|
139
|
+
*/
|
|
140
|
+
export function runWithContext<T>(context: RequestContext, body: () => T): T {
|
|
141
|
+
return storage.run(context, body);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build a context from a `Request`.
|
|
146
|
+
*
|
|
147
|
+
* The header and cookie views are built once and read many times: a render
|
|
148
|
+
* touches `cookies().get(…)` as often as it has components that care, and
|
|
149
|
+
* re-parsing the cookie header each time would be the kind of cost nobody
|
|
150
|
+
* looks for.
|
|
151
|
+
*
|
|
152
|
+
* Parsed on the first read rather than here, and that changed when the host
|
|
153
|
+
* became the thing that begins a request: a host begins one before it knows
|
|
154
|
+
* whether the path is an embedded chunk or a page, so every asset a compiled
|
|
155
|
+
* binary serves now builds a context. Splitting a `Cookie` header for a
|
|
156
|
+
* request that never asks about cookies is exactly the cost the paragraph
|
|
157
|
+
* above refuses to pay per read, and there is no reason to pay it per request
|
|
158
|
+
* either.
|
|
159
|
+
*/
|
|
160
|
+
export function contextFor(request: Request): RequestContext {
|
|
161
|
+
const headers = request.headers;
|
|
162
|
+
let cookies: { [string]: string } | null = null;
|
|
163
|
+
const parsed = () => (cookies ??= parseCookies(headers.get("cookie")));
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
headers: {
|
|
167
|
+
get: (name) => headers.get(name),
|
|
168
|
+
has: (name) => headers.has(name),
|
|
169
|
+
},
|
|
170
|
+
cookies: {
|
|
171
|
+
get: (name) => (Object.hasOwn(parsed(), name) ? parsed()[name] : null),
|
|
172
|
+
has: (name) => Object.hasOwn(parsed(), name),
|
|
173
|
+
},
|
|
174
|
+
draft: false,
|
|
175
|
+
deferred: [],
|
|
176
|
+
requestStateReads: 0,
|
|
177
|
+
cache: null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Begin a request, and hand back the two halves of owning it.
|
|
183
|
+
*
|
|
184
|
+
* The one function a host calls. `contextFor`, `runWithContext` and
|
|
185
|
+
* `drainDeferred` are still here because they are what this is made of and
|
|
186
|
+
* because the suite drives them one at a time, but a *host* reaching for them
|
|
187
|
+
* separately is how uf got two contexts on one request and a drain that ran
|
|
188
|
+
* before the response: the middleware runner built one and drained it, and the
|
|
189
|
+
* dispatcher underneath it built another. See ubugeeei-prod/uf#389.
|
|
190
|
+
*
|
|
191
|
+
* `settle` runs once. A host learns that a response is finished more than once
|
|
192
|
+
* — the body stream closed, and then the socket did — and draining twice would
|
|
193
|
+
* run whatever the first drain's callbacks registered, at a moment nothing
|
|
194
|
+
* asked for.
|
|
195
|
+
*/
|
|
196
|
+
export function beginRequest(request: Request): RequestLifecycle {
|
|
197
|
+
const context = contextFor(request);
|
|
198
|
+
let settling: Promise<void> | null = null;
|
|
199
|
+
|
|
200
|
+
function run<T>(body: () => Promise<T>): Promise<T> {
|
|
201
|
+
return runWithContext(context, body);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function settle(): Promise<void> {
|
|
205
|
+
settling ??= drainDeferred(context);
|
|
206
|
+
return settling;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { context, run, settle };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Parse a `Cookie` header into a plain object.
|
|
214
|
+
*
|
|
215
|
+
* `Object.create(null)` rather than `{}`: a cookie called `__proto__` is a
|
|
216
|
+
* thing an attacker can set, and on an ordinary object it would not be a key
|
|
217
|
+
* at all — it would be the prototype.
|
|
218
|
+
*
|
|
219
|
+
* A duplicated name keeps the first value, which is what every server-side
|
|
220
|
+
* cookie parser does and what browsers send for a name set at two paths.
|
|
221
|
+
*/
|
|
222
|
+
export function parseCookies(header: string | null): { [string]: string } {
|
|
223
|
+
const out: { [string]: string } = Object.create(null);
|
|
224
|
+
if (header == null || header === "") {
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const pair of header.split(";")) {
|
|
229
|
+
const at = pair.indexOf("=");
|
|
230
|
+
if (at < 0) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const name = pair.slice(0, at).trim();
|
|
234
|
+
if (name === "" || Object.hasOwn(out, name)) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
out[name] = decodeValue(pair.slice(at + 1).trim());
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Decode one cookie value, leaving it alone if it is not valid encoding.
|
|
244
|
+
*
|
|
245
|
+
* `decodeURIComponent` throws on a stray `%`, and a malformed cookie is not a
|
|
246
|
+
* reason to fail a request — the value is simply not what the sender meant.
|
|
247
|
+
*/
|
|
248
|
+
function decodeValue(value: string): string {
|
|
249
|
+
const unquoted =
|
|
250
|
+
value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
|
251
|
+
try {
|
|
252
|
+
return decodeURIComponent(unquoted);
|
|
253
|
+
} catch {
|
|
254
|
+
return unquoted;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Run everything `after()` deferred, in the order it was registered.
|
|
260
|
+
*
|
|
261
|
+
* A failure is reported and does not stop the rest: deferred work is by
|
|
262
|
+
* definition not what the response depended on, and one broken analytics call
|
|
263
|
+
* should not take the others with it.
|
|
264
|
+
*/
|
|
265
|
+
export async function drainDeferred(context: RequestContext): Promise<void> {
|
|
266
|
+
const pending = context.deferred.splice(0, context.deferred.length);
|
|
267
|
+
for (const task of pending) {
|
|
268
|
+
try {
|
|
269
|
+
await task();
|
|
270
|
+
} catch (error) {
|
|
271
|
+
reportDeferredFailure(error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Report a deferred task that threw.
|
|
278
|
+
*
|
|
279
|
+
* Isolated so a host can be given somewhere to put this; today it is the
|
|
280
|
+
* console, which is where an unhandled rejection would have gone anyway.
|
|
281
|
+
*/
|
|
282
|
+
function reportDeferredFailure(error: mixed): void {
|
|
283
|
+
// eslint-disable-next-line no-console
|
|
284
|
+
console.error("uf: a task registered with after() failed", error);
|
|
285
|
+
}
|
package/lambda.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/server/lambda`: the AWS Lambda half of serving a build.
|
|
4
|
+
//
|
|
5
|
+
// A serverless target has no server. What it has is one function per
|
|
6
|
+
// invocation, whose signature belongs to the platform rather than to the Web —
|
|
7
|
+
// so this module is a translation, and the thing being translated is the same
|
|
8
|
+
// [`./fetch.js`] handler `uf start` and every other front door answer through.
|
|
9
|
+
//
|
|
10
|
+
// # One event shape, named
|
|
11
|
+
//
|
|
12
|
+
// **Payload format version 2.0**, which is what a Lambda Function URL always
|
|
13
|
+
// sends and what an API Gateway *HTTP* API sends by default. It is not what an
|
|
14
|
+
// API Gateway *REST* API sends (that is format 1.0, with `httpMethod`,
|
|
15
|
+
// `path` and `multiValueHeaders`), and it is not an ALB target-group event.
|
|
16
|
+
// Supporting all three by sniffing the event would be three untested code
|
|
17
|
+
// paths where the platform's own documentation says which one you get; this
|
|
18
|
+
// one is named in `docs/app/reference/cli/_uf.page.mdx`, and an event that is
|
|
19
|
+
// not it is refused by [`toRequest`] with a message saying so rather than
|
|
20
|
+
// answered from fields that happen to be undefined.
|
|
21
|
+
//
|
|
22
|
+
// # Buffered, and why that is a limitation rather than a choice
|
|
23
|
+
//
|
|
24
|
+
// `createFetchHandler` answers a document as a stream, so a `<Suspense>`
|
|
25
|
+
// fallback reaches the browser while the page behind it is still resolving.
|
|
26
|
+
// A Lambda response in this format is a JSON value, so the whole body is read
|
|
27
|
+
// before the invocation returns and none of that streaming survives. Response
|
|
28
|
+
// streaming exists — `awslambda.streamifyResponse`, on a Function URL whose
|
|
29
|
+
// invoke mode is `RESPONSE_STREAM` — and this module does not implement it:
|
|
30
|
+
// the wrapper is a global the managed runtime injects, so nothing outside a
|
|
31
|
+
// real invocation can drive it, and an untested streaming path is worse than a
|
|
32
|
+
// buffered one that says it is buffered. The 6 MB response payload limit is
|
|
33
|
+
// the platform's and applies to what this returns.
|
|
34
|
+
//
|
|
35
|
+
// # The static half is here rather than on a CDN, unless you put one there
|
|
36
|
+
//
|
|
37
|
+
// `staticDir` is served through `./node.js`'s `createStaticHandler`, from the
|
|
38
|
+
// deployment package itself, so the function answers a prerendered document
|
|
39
|
+
// and a hashed asset without any other infrastructure existing. That is the
|
|
40
|
+
// shape that works the moment it is uploaded; it is not the shape anybody
|
|
41
|
+
// should keep. Every byte of `static/` is then billed as invocation time and
|
|
42
|
+
// counted against the package limit, and a CloudFront distribution or an S3
|
|
43
|
+
// origin in front of the function is what the platform expects. Leaving
|
|
44
|
+
// `staticDir` out is how you say you have one — the application half then
|
|
45
|
+
// answers alone, exactly as it does on a worker.
|
|
46
|
+
|
|
47
|
+
import { Buffer } from "node:buffer";
|
|
48
|
+
|
|
49
|
+
import { createStaticHandler } from "./node.js";
|
|
50
|
+
|
|
51
|
+
import type { RequestLifecycle } from "./internal/context.js";
|
|
52
|
+
|
|
53
|
+
export type { RequestLifecycle } from "./internal/context.js";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* An HTTP API payload format 2.0 event, as much of it as this module reads.
|
|
57
|
+
*
|
|
58
|
+
* Inexact and almost entirely optional, because it arrives from the platform
|
|
59
|
+
* rather than from a caller: the fields uf needs are checked in [`toRequest`],
|
|
60
|
+
* where a missing one can be reported as the wrong event shape.
|
|
61
|
+
*/
|
|
62
|
+
export type LambdaHttpEvent = {
|
|
63
|
+
readonly version?: string,
|
|
64
|
+
readonly rawPath?: string,
|
|
65
|
+
readonly rawQueryString?: string,
|
|
66
|
+
readonly cookies?: $ReadOnlyArray<string>,
|
|
67
|
+
readonly headers?: { readonly [string]: string | void },
|
|
68
|
+
readonly body?: string,
|
|
69
|
+
readonly isBase64Encoded?: boolean,
|
|
70
|
+
readonly requestContext?: {
|
|
71
|
+
readonly domainName?: string,
|
|
72
|
+
readonly http?: {
|
|
73
|
+
readonly method?: string,
|
|
74
|
+
readonly path?: string,
|
|
75
|
+
...
|
|
76
|
+
},
|
|
77
|
+
...
|
|
78
|
+
},
|
|
79
|
+
...
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** What Lambda expects back for payload format 2.0. */
|
|
83
|
+
export type LambdaHttpResult = {|
|
|
84
|
+
readonly statusCode: number,
|
|
85
|
+
readonly headers: { [string]: string },
|
|
86
|
+
readonly cookies: $ReadOnlyArray<string>,
|
|
87
|
+
readonly body: string,
|
|
88
|
+
readonly isBase64Encoded: boolean,
|
|
89
|
+
|};
|
|
90
|
+
|
|
91
|
+
/** Everything the serverless half needs to answer an invocation. */
|
|
92
|
+
export type LambdaHandlerOptions = {|
|
|
93
|
+
/** The application, from the generated `handler.js`. */
|
|
94
|
+
readonly handle: (request: Request) => Promise<Response>,
|
|
95
|
+
/** That same module's `beginRequest`; see [`./node.js`]'s header for why. */
|
|
96
|
+
readonly beginRequest: (request: Request) => RequestLifecycle,
|
|
97
|
+
/**
|
|
98
|
+
* The directory holding the build's own files, if the package carries them.
|
|
99
|
+
*
|
|
100
|
+
* Omitted where a CDN answers for them; see the header.
|
|
101
|
+
*/
|
|
102
|
+
readonly staticDir?: string,
|
|
103
|
+
|};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The `getSetCookie` half of `Headers`.
|
|
107
|
+
*
|
|
108
|
+
* Declared rather than called straight off the value because iterating
|
|
109
|
+
* `Headers` joins repeated fields with a comma, and `Set-Cookie` is the one
|
|
110
|
+
* field where that is a corruption rather than a spelling: two cookies become
|
|
111
|
+
* one header nothing can parse back apart. `getSetCookie` is the standard
|
|
112
|
+
* answer and every runtime that can run this has it — but a runtime that does
|
|
113
|
+
* not would throw here rather than at the point the cookies were set, so the
|
|
114
|
+
* absence is checked.
|
|
115
|
+
*/
|
|
116
|
+
type SetCookieReader = {
|
|
117
|
+
readonly getSetCookie?: () => $ReadOnlyArray<string>,
|
|
118
|
+
...
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Media types whose bodies are text, and therefore not base64.
|
|
123
|
+
*
|
|
124
|
+
* The same shape of decision `./node.js` makes with `CONTENT_TYPES`, and the
|
|
125
|
+
* same default: what is not known to be text is returned base64-encoded, which
|
|
126
|
+
* is lossless for every byte sequence. Getting it the other way round would
|
|
127
|
+
* corrupt an image silently.
|
|
128
|
+
*/
|
|
129
|
+
const TEXT_TYPES: $ReadOnlyArray<string> = Object.freeze([
|
|
130
|
+
"application/javascript",
|
|
131
|
+
"application/json",
|
|
132
|
+
"application/manifest+json",
|
|
133
|
+
"application/xml",
|
|
134
|
+
"image/svg+xml",
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
/** Whether a response with this `content-type` can be returned as a string. */
|
|
138
|
+
function isText(contentType: string | null): boolean {
|
|
139
|
+
if (contentType == null) return false;
|
|
140
|
+
const media = contentType.split(";")[0].trim().toLowerCase();
|
|
141
|
+
if (media.startsWith("text/")) return true;
|
|
142
|
+
if (media.endsWith("+json") || media.endsWith("+xml")) return true;
|
|
143
|
+
return TEXT_TYPES.includes(media);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The event as a `Request`.
|
|
148
|
+
*
|
|
149
|
+
* The URL is rebuilt rather than taken from a field, because no field holds
|
|
150
|
+
* one: the path is `rawPath`, the query is `rawQueryString`, and the authority
|
|
151
|
+
* is the `host` header the client sent — falling back to the API's own domain,
|
|
152
|
+
* which is what a health check with no `Host` arrives with. `https`, always:
|
|
153
|
+
* both a Function URL and an HTTP API terminate TLS, and there is no spelling
|
|
154
|
+
* of either that a browser reaches over `http`.
|
|
155
|
+
*
|
|
156
|
+
* Cookies come from `event.cookies` and not from a header, because that is
|
|
157
|
+
* where format 2.0 puts them — an application reading `cookies()` would
|
|
158
|
+
* otherwise see none of them, which is the kind of difference between a
|
|
159
|
+
* deployment and `uf start` this whole seam exists to prevent.
|
|
160
|
+
*/
|
|
161
|
+
export function toRequest(event: LambdaHttpEvent): Request {
|
|
162
|
+
const method = event.requestContext?.http?.method;
|
|
163
|
+
const rawPath = event.rawPath;
|
|
164
|
+
if (typeof method !== "string" || typeof rawPath !== "string") {
|
|
165
|
+
throw new Error(
|
|
166
|
+
"uf: this handler reads AWS Lambda payload format 2.0 — a Lambda Function URL, " +
|
|
167
|
+
"or an API Gateway HTTP API — and the event it was given has no " +
|
|
168
|
+
"`requestContext.http.method` and `rawPath`. An API Gateway REST API sends " +
|
|
169
|
+
"format 1.0 and an ALB sends its own shape; neither is supported.",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const headers = new Headers();
|
|
174
|
+
const source = event.headers ?? {};
|
|
175
|
+
for (const name of Object.keys(source)) {
|
|
176
|
+
const value = source[name];
|
|
177
|
+
if (typeof value === "string") headers.set(name, value);
|
|
178
|
+
}
|
|
179
|
+
const cookies = event.cookies ?? [];
|
|
180
|
+
if (cookies.length > 0) headers.set("cookie", cookies.join("; "));
|
|
181
|
+
|
|
182
|
+
const authority =
|
|
183
|
+
headers.get("host") ?? event.requestContext?.domainName ?? "lambda.amazonaws.com";
|
|
184
|
+
const query = event.rawQueryString ?? "";
|
|
185
|
+
const url = new URL(`https://${authority}${rawPath}${query === "" ? "" : `?${query}`}`);
|
|
186
|
+
|
|
187
|
+
const init: { [string]: mixed } = { method: method.toUpperCase(), headers };
|
|
188
|
+
// `Request` refuses a body on a `GET` or a `HEAD`, and API Gateway is under
|
|
189
|
+
// no obligation not to send one: a client can, and the invocation would then
|
|
190
|
+
// fail with a `TypeError` from the constructor rather than answer.
|
|
191
|
+
if (typeof event.body === "string" && !["GET", "HEAD"].includes(method.toUpperCase())) {
|
|
192
|
+
init.body =
|
|
193
|
+
event.isBase64Encoded === true ? Buffer.from(event.body, "base64") : Buffer.from(event.body);
|
|
194
|
+
}
|
|
195
|
+
return new Request(url, init);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A `Response` as the JSON value Lambda returns to the client. */
|
|
199
|
+
export async function toResult(response: Response): Promise<LambdaHttpResult> {
|
|
200
|
+
const headers: { [string]: string } = {};
|
|
201
|
+
for (const [name, value] of response.headers) {
|
|
202
|
+
// Skipped here and carried in `cookies` below: see [`SetCookieReader`].
|
|
203
|
+
if (name.toLowerCase() === "set-cookie") continue;
|
|
204
|
+
headers[name] = value;
|
|
205
|
+
}
|
|
206
|
+
const reader: SetCookieReader = response.headers;
|
|
207
|
+
const cookies = typeof reader.getSetCookie === "function" ? [...reader.getSetCookie()] : [];
|
|
208
|
+
|
|
209
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
210
|
+
const text = isText(response.headers.get("content-type"));
|
|
211
|
+
return {
|
|
212
|
+
statusCode: response.status,
|
|
213
|
+
headers,
|
|
214
|
+
cookies,
|
|
215
|
+
body: text ? body.toString("utf8") : body.toString("base64"),
|
|
216
|
+
isBase64Encoded: !text,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A built uf application as a Lambda handler.
|
|
222
|
+
*
|
|
223
|
+
* Static files first, then the application — the order `uf preview` cannot
|
|
224
|
+
* deviate from and therefore the order every other front door matches. The
|
|
225
|
+
* request is begun here and settled once the body has been read into the
|
|
226
|
+
* result, which on this target is genuinely "the response has been produced":
|
|
227
|
+
* an invocation that returned before its `after()` callbacks ran would have
|
|
228
|
+
* them killed with the sandbox, so `settle` is awaited rather than deferred.
|
|
229
|
+
*/
|
|
230
|
+
export function createLambdaHandler(
|
|
231
|
+
options: LambdaHandlerOptions,
|
|
232
|
+
): (event: LambdaHttpEvent) => Promise<LambdaHttpResult> {
|
|
233
|
+
const { handle, beginRequest, staticDir } = options;
|
|
234
|
+
const serveStatic = staticDir == null ? null : createStaticHandler({ root: staticDir });
|
|
235
|
+
|
|
236
|
+
return async function lambdaHandler(event: LambdaHttpEvent): Promise<LambdaHttpResult> {
|
|
237
|
+
const request = toRequest(event);
|
|
238
|
+
const lifecycle = beginRequest(request);
|
|
239
|
+
try {
|
|
240
|
+
return await lifecycle.run(async () => {
|
|
241
|
+
const asset = serveStatic == null ? null : await serveStatic(request);
|
|
242
|
+
return await toResult(asset ?? (await handle(request)));
|
|
243
|
+
});
|
|
244
|
+
} catch (error) {
|
|
245
|
+
// The same 500 `./node.js`'s `nodeListener` writes, and for the same
|
|
246
|
+
// reasons: the body must not carry the stack, and the console — which on
|
|
247
|
+
// Lambda is CloudWatch — is where the operator is already looking. A
|
|
248
|
+
// rejected invocation would be a 502 from API Gateway instead, which is
|
|
249
|
+
// a different answer from `uf start`'s for the same failure.
|
|
250
|
+
//
|
|
251
|
+
// `toRequest` above is deliberately outside this: an event in the wrong
|
|
252
|
+
// format is a misconfigured function rather than a failed request, and
|
|
253
|
+
// answering it 500 forever would hide that.
|
|
254
|
+
console.error(error);
|
|
255
|
+
return {
|
|
256
|
+
statusCode: 500,
|
|
257
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
258
|
+
cookies: [],
|
|
259
|
+
body: "500 Internal Server Error\n",
|
|
260
|
+
isBase64Encoded: false,
|
|
261
|
+
};
|
|
262
|
+
} finally {
|
|
263
|
+
await lifecycle.settle();
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
}
|