lambder 2.0.18 → 3.1.1
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/Readme.md +184 -41
- package/dist/Lambder.d.ts +154 -46
- package/dist/Lambder.js +312 -166
- package/dist/LambderCaller.js +6 -3
- package/dist/LambderContext.d.ts +20 -9
- package/dist/LambderContext.js +57 -17
- package/dist/LambderCors.d.ts +12 -0
- package/dist/LambderCors.js +30 -0
- package/dist/LambderDdbCache.d.ts +65 -0
- package/dist/LambderDdbCache.js +480 -0
- package/dist/LambderHtml.d.ts +33 -0
- package/dist/LambderHtml.js +62 -0
- package/dist/LambderMSW.d.ts +16 -1
- package/dist/LambderMSW.js +5 -9
- package/dist/LambderPublicFiles.d.ts +47 -0
- package/dist/LambderPublicFiles.js +108 -0
- package/dist/LambderResolver.d.ts +30 -31
- package/dist/LambderResolver.js +29 -43
- package/dist/LambderResponse.d.ts +71 -0
- package/dist/LambderResponse.js +196 -0
- package/dist/LambderResponseBuilder.d.ts +58 -33
- package/dist/LambderResponseBuilder.js +114 -167
- package/dist/LambderRouting.d.ts +23 -0
- package/dist/LambderRouting.js +67 -0
- package/dist/LambderSessionController.d.ts +13 -1
- package/dist/LambderSessionController.js +33 -10
- package/dist/LambderSessionManager.d.ts +3 -1
- package/dist/LambderSessionManager.js +15 -6
- package/dist/LambderTemplatingEngine.d.ts +87 -0
- package/dist/LambderTemplatingEngine.js +156 -0
- package/dist/index.d.ts +16 -2
- package/dist/index.js +12 -1
- package/dist/node-polyfills.d.ts +4 -2
- package/dist/node-polyfills.js +28 -0
- package/package.json +8 -5
- package/.eslintrc.cjs +0 -26
- package/.vscode/settings.json +0 -26
- package/deploy +0 -22
- package/dist/LambderUtils.d.ts +0 -10
- package/dist/LambderUtils.js +0 -70
- package/docs/DYNAMODB_SETUP.md +0 -96
- package/docs/LAMBDER_MSW.md +0 -409
- package/docs/TYPE_SAFE_QUICK_START.md +0 -77
- package/examples/msw-testing-example.ts +0 -280
- package/examples/secure-session-example.ts +0 -207
- package/examples/zod-chained-api-example.ts +0 -63
- package/src/Lambder.ts +0 -430
- package/src/LambderApiContract.ts +0 -20
- package/src/LambderCaller.ts +0 -238
- package/src/LambderContext.ts +0 -78
- package/src/LambderMSW.ts +0 -180
- package/src/LambderResolver.ts +0 -101
- package/src/LambderResponseBuilder.ts +0 -332
- package/src/LambderSessionController.ts +0 -114
- package/src/LambderSessionManager.ts +0 -217
- package/src/LambderUtils.ts +0 -75
- package/src/index.ts +0 -17
- package/src/node-polyfills.ts +0 -27
- package/tests/error-handling.test.ts +0 -585
- package/tests/file-serving.test.ts +0 -194
- package/tests/fixtures/public/index.html +0 -1
- package/tests/fixtures/public/main.css +0 -1
- package/tests/hooks.test.ts +0 -561
- package/tests/output-type-runtime.test.ts +0 -381
- package/tests/redirect.test.ts +0 -88
- package/tests/routes.test.ts +0 -543
- package/tests/session.test.ts +0 -1083
- package/tests/use-plugin.test.ts +0 -460
- package/tsconfig.json +0 -24
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import mimeTypeResolver from "mime-types";
|
|
2
|
+
import { getFS, getPath } from "./node-polyfills.js";
|
|
3
|
+
import { LambderResponse } from "./LambderResponse.js";
|
|
4
|
+
// Content-hashed build outputs (Vite/webpack/Rollup): a [-.] separated run of
|
|
5
|
+
// 8+ hash chars containing at least one digit, before the extension.
|
|
6
|
+
const DEFAULT_IMMUTABLE_PATTERN = /[-.](?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/;
|
|
7
|
+
const DEFAULT_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
8
|
+
const DEFAULT_CACHE_CONTROL = "public, max-age=3600";
|
|
9
|
+
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
10
|
+
const DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
/**
|
|
12
|
+
* Terminal public-file handler registered via lambder.servePublicFiles().
|
|
13
|
+
* Runs only when no route matched, so it can never shadow routes registered
|
|
14
|
+
* after it. Serves real files under publicPath (traversal-safe, mime-typed,
|
|
15
|
+
* memory-cached, immutable-cache heuristic for content-hashed assets) and
|
|
16
|
+
* falls through to the route fallback when the file does not exist.
|
|
17
|
+
*/
|
|
18
|
+
export class LambderPublicFilesHandler {
|
|
19
|
+
publicPath;
|
|
20
|
+
options;
|
|
21
|
+
fileCache = new Map();
|
|
22
|
+
fileCacheBytes = 0;
|
|
23
|
+
constructor(publicPath, options) {
|
|
24
|
+
this.publicPath = publicPath;
|
|
25
|
+
this.options = options;
|
|
26
|
+
}
|
|
27
|
+
/** Serve the mapped file, or return null to fall through. */
|
|
28
|
+
async handle(ctx) {
|
|
29
|
+
const fs = await getFS();
|
|
30
|
+
const path = await getPath();
|
|
31
|
+
if (!fs || !path)
|
|
32
|
+
throw new Error("servePublicFiles requires a Node.js environment.");
|
|
33
|
+
const mappedPath = this.options.path ? this.options.path(ctx) : ctx.path;
|
|
34
|
+
if (!mappedPath)
|
|
35
|
+
return null;
|
|
36
|
+
const publicRoot = path.resolve(this.publicPath);
|
|
37
|
+
const filePath = this.resolveSafe(path, publicRoot, mappedPath);
|
|
38
|
+
if (!filePath)
|
|
39
|
+
return null;
|
|
40
|
+
const file = await this.readFileCached(fs, filePath);
|
|
41
|
+
if (!file)
|
|
42
|
+
return null;
|
|
43
|
+
const compressOption = this.options.compress;
|
|
44
|
+
const compress = typeof compressOption === "function" ? compressOption(ctx) : (compressOption ?? "auto");
|
|
45
|
+
return new LambderResponse({
|
|
46
|
+
statusCode: 200,
|
|
47
|
+
headers: {
|
|
48
|
+
"Content-Type": file.mimeType,
|
|
49
|
+
"Cache-Control": this.cacheControlFor(ctx, filePath),
|
|
50
|
+
},
|
|
51
|
+
body: file.body,
|
|
52
|
+
compress,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Join base+target and require the result to stay under base. */
|
|
56
|
+
resolveSafe(path, base, target) {
|
|
57
|
+
if (target.split("/").some((segment) => segment === ".."))
|
|
58
|
+
return null;
|
|
59
|
+
const normalizedTarget = target.startsWith("/") ? target.slice(1) : target;
|
|
60
|
+
const absolute = path.resolve(base, normalizedTarget);
|
|
61
|
+
if (absolute !== base && !absolute.startsWith(base + path.sep))
|
|
62
|
+
return null;
|
|
63
|
+
return absolute;
|
|
64
|
+
}
|
|
65
|
+
/** Read a file, caching small files in memory for warm invocations. */
|
|
66
|
+
async readFileCached(fs, filePath) {
|
|
67
|
+
const cached = this.fileCache.get(filePath);
|
|
68
|
+
if (cached)
|
|
69
|
+
return cached;
|
|
70
|
+
const stat = await fs.promises.stat(filePath).catch(() => null);
|
|
71
|
+
if (!stat?.isFile())
|
|
72
|
+
return null;
|
|
73
|
+
const body = await fs.promises.readFile(filePath);
|
|
74
|
+
const mimeType = mimeTypeResolver.lookup(filePath) || "application/octet-stream";
|
|
75
|
+
const entry = { body, mimeType };
|
|
76
|
+
const cacheConfig = this.options.memoryCache;
|
|
77
|
+
if (cacheConfig !== false) {
|
|
78
|
+
const maxBytes = cacheConfig?.maxBytes ?? DEFAULT_MEMORY_CACHE_MAX_BYTES;
|
|
79
|
+
const maxFileBytes = cacheConfig?.maxFileBytes ?? DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES;
|
|
80
|
+
if (body.length <= maxFileBytes) {
|
|
81
|
+
// Evict oldest entries until the new file fits the budget.
|
|
82
|
+
for (const [key, value] of this.fileCache) {
|
|
83
|
+
if (this.fileCacheBytes + body.length <= maxBytes)
|
|
84
|
+
break;
|
|
85
|
+
this.fileCache.delete(key);
|
|
86
|
+
this.fileCacheBytes -= value.body.length;
|
|
87
|
+
}
|
|
88
|
+
if (this.fileCacheBytes + body.length <= maxBytes) {
|
|
89
|
+
this.fileCache.set(filePath, entry);
|
|
90
|
+
this.fileCacheBytes += body.length;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return entry;
|
|
95
|
+
}
|
|
96
|
+
cacheControlFor(ctx, filePath) {
|
|
97
|
+
const cacheOption = this.options.cacheControl;
|
|
98
|
+
if (typeof cacheOption === "function")
|
|
99
|
+
return cacheOption(ctx, filePath);
|
|
100
|
+
const immutablePattern = this.options.immutablePattern === false
|
|
101
|
+
? null
|
|
102
|
+
: (this.options.immutablePattern ?? DEFAULT_IMMUTABLE_PATTERN);
|
|
103
|
+
if (immutablePattern && immutablePattern.test(filePath)) {
|
|
104
|
+
return this.options.immutableCacheControl ?? DEFAULT_IMMUTABLE_CACHE_CONTROL;
|
|
105
|
+
}
|
|
106
|
+
return cacheOption ?? DEFAULT_CACHE_CONTROL;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -1,36 +1,35 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
type
|
|
5
|
-
interface DieResolverMethods<TOutput> {
|
|
6
|
-
raw:
|
|
7
|
-
json:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
import LambderResponseBuilder, { type LambderApiResponseConfig, type LambderResponseOptions } from "./LambderResponseBuilder.js";
|
|
2
|
+
import type { LambderResponse } from "./LambderResponse.js";
|
|
3
|
+
type SyncDie<T extends (...args: any[]) => LambderResponse> = (...args: Parameters<T>) => never;
|
|
4
|
+
type AsyncDie<T extends (...args: any[]) => Promise<LambderResponse>> = (...args: Parameters<T>) => Promise<never>;
|
|
5
|
+
export interface DieResolverMethods<TOutput> {
|
|
6
|
+
raw: SyncDie<LambderResponseBuilder["raw"]>;
|
|
7
|
+
json: SyncDie<LambderResponseBuilder["json"]>;
|
|
8
|
+
text: SyncDie<LambderResponseBuilder["text"]>;
|
|
9
|
+
xml: SyncDie<LambderResponseBuilder["xml"]>;
|
|
10
|
+
html: SyncDie<LambderResponseBuilder["html"]>;
|
|
11
|
+
status: SyncDie<LambderResponseBuilder["status"]>;
|
|
12
|
+
status404: SyncDie<LambderResponseBuilder["status404"]>;
|
|
13
|
+
redirect: SyncDie<LambderResponseBuilder["redirect"]>;
|
|
14
|
+
versionExpired: SyncDie<LambderResponseBuilder["versionExpired"]>;
|
|
15
|
+
fileBase64: SyncDie<LambderResponseBuilder["fileBase64"]>;
|
|
16
|
+
api: (payload: TOutput | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions) => never;
|
|
17
|
+
apiBinary: (payload: TOutput | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions) => never;
|
|
18
|
+
file: AsyncDie<LambderResponseBuilder["file"]>;
|
|
19
|
+
templateFile: AsyncDie<LambderResponseBuilder["templateFile"]>;
|
|
18
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Response builder passed to route/api handlers and hooks.
|
|
23
|
+
*
|
|
24
|
+
* `res.die.*` builds the response and THROWS it, immediately halting the
|
|
25
|
+
* request at any call depth (handlers, hooks, nested service functions).
|
|
26
|
+
* Lambder's render pipeline catches thrown LambderResponse instances and uses
|
|
27
|
+
* them as the response. Plain `throw res.html(...)` works the same way.
|
|
28
|
+
*/
|
|
19
29
|
export default class LambderResolver<TOutput = any> extends LambderResponseBuilder<TOutput> {
|
|
20
|
-
resolve: (response: LambderResolverResponse) => void;
|
|
21
|
-
reject: (err: Error) => void;
|
|
22
30
|
die: DieResolverMethods<TOutput>;
|
|
23
|
-
constructor(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
apiVersion?: string | null;
|
|
27
|
-
lambderUtils: LambderUtils;
|
|
28
|
-
ctx: LambderRenderContext<any>;
|
|
29
|
-
resolve: (response: LambderResolverResponse) => void;
|
|
30
|
-
reject: (err: Error) => void;
|
|
31
|
-
});
|
|
32
|
-
api(payload: TOutput | null, config?: Parameters<LambderResponseBuilder['api']>[1], headers?: Parameters<LambderResponseBuilder['api']>[2]): LambderResolverResponse;
|
|
33
|
-
private autoResolve;
|
|
34
|
-
private autoResolvePromise;
|
|
31
|
+
constructor(...args: ConstructorParameters<typeof LambderResponseBuilder>);
|
|
32
|
+
api(payload: TOutput | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
|
|
33
|
+
apiBinary(payload: TOutput | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
|
|
35
34
|
}
|
|
36
35
|
export {};
|
package/dist/LambderResolver.js
CHANGED
|
@@ -1,52 +1,38 @@
|
|
|
1
1
|
import LambderResponseBuilder from "./LambderResponseBuilder.js";
|
|
2
|
+
/**
|
|
3
|
+
* Response builder passed to route/api handlers and hooks.
|
|
4
|
+
*
|
|
5
|
+
* `res.die.*` builds the response and THROWS it, immediately halting the
|
|
6
|
+
* request at any call depth (handlers, hooks, nested service functions).
|
|
7
|
+
* Lambder's render pipeline catches thrown LambderResponse instances and uses
|
|
8
|
+
* them as the response. Plain `throw res.html(...)` works the same way.
|
|
9
|
+
*/
|
|
2
10
|
export default class LambderResolver extends LambderResponseBuilder {
|
|
3
|
-
resolve;
|
|
4
|
-
reject;
|
|
5
11
|
die;
|
|
6
|
-
constructor(
|
|
7
|
-
super(
|
|
8
|
-
this.resolve = resolve;
|
|
9
|
-
this.reject = reject;
|
|
12
|
+
constructor(...args) {
|
|
13
|
+
super(...args);
|
|
10
14
|
this.die = {
|
|
11
|
-
raw:
|
|
12
|
-
json:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
raw: (...a) => { throw this.raw(...a); },
|
|
16
|
+
json: (...a) => { throw this.json(...a); },
|
|
17
|
+
text: (...a) => { throw this.text(...a); },
|
|
18
|
+
xml: (...a) => { throw this.xml(...a); },
|
|
19
|
+
html: (...a) => { throw this.html(...a); },
|
|
20
|
+
status: (...a) => { throw this.status(...a); },
|
|
21
|
+
status404: (...a) => { throw this.status404(...a); },
|
|
22
|
+
redirect: (...a) => { throw this.redirect(...a); },
|
|
23
|
+
versionExpired: (...a) => { throw this.versionExpired(...a); },
|
|
24
|
+
fileBase64: (...a) => { throw this.fileBase64(...a); },
|
|
25
|
+
api: (...a) => { throw this.api(...a); },
|
|
26
|
+
apiBinary: (...a) => { throw this.apiBinary(...a); },
|
|
27
|
+
file: async (...a) => { throw await this.file(...a); },
|
|
28
|
+
templateFile: async (...a) => { throw await this.templateFile(...a); },
|
|
23
29
|
};
|
|
24
30
|
}
|
|
25
|
-
// Override api method with proper typing
|
|
26
|
-
api(payload, config,
|
|
27
|
-
return super.api(payload, config,
|
|
31
|
+
// Override api method with proper output typing
|
|
32
|
+
api(payload, config, options) {
|
|
33
|
+
return super.api(payload, config, options);
|
|
28
34
|
}
|
|
29
|
-
|
|
30
|
-
return (
|
|
31
|
-
const result = method.apply(this, args);
|
|
32
|
-
this.resolve(result);
|
|
33
|
-
return result;
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
autoResolvePromise(method) {
|
|
37
|
-
return (...args) => {
|
|
38
|
-
return new Promise((resolve, reject) => {
|
|
39
|
-
method.apply(this, args)
|
|
40
|
-
.then(result => {
|
|
41
|
-
this.resolve(result);
|
|
42
|
-
resolve(result);
|
|
43
|
-
})
|
|
44
|
-
.catch(err => {
|
|
45
|
-
this.reject(err);
|
|
46
|
-
reject(err);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
};
|
|
35
|
+
apiBinary(payload, config, options) {
|
|
36
|
+
return super.apiBinary(payload, config, options);
|
|
50
37
|
}
|
|
51
38
|
}
|
|
52
|
-
;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { LambderRenderContext } from "./LambderContext.js";
|
|
2
|
+
export type HttpStatusCode = 100 | 101 | 200 | 201 | 202 | 203 | 204 | 206 | 300 | 301 | 302 | 303 | 304 | 307 | 308 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 408 | 409 | 410 | 412 | 413 | 415 | 416 | 418 | 422 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504;
|
|
3
|
+
export type LambderHeadersInput = Record<string, string | string[]>;
|
|
4
|
+
/**
|
|
5
|
+
* Final Lambda response: v1 (REST API) uses multiValueHeaders, v2 (HTTP API /
|
|
6
|
+
* Function URLs) uses headers + cookies. Assignable to both official aws-lambda
|
|
7
|
+
* result types (compile-time asserted below), so exporting getHandler() as an
|
|
8
|
+
* APIGatewayProxyHandler / APIGatewayProxyHandlerV2 is type-safe.
|
|
9
|
+
*/
|
|
10
|
+
export type LambderHttpResponse = {
|
|
11
|
+
statusCode: number;
|
|
12
|
+
body: string;
|
|
13
|
+
isBase64Encoded: boolean;
|
|
14
|
+
/** API Gateway REST API (payload v1). */
|
|
15
|
+
multiValueHeaders?: Record<string, string[]>;
|
|
16
|
+
/** API Gateway HTTP API / Lambda Function URLs (payload v2). */
|
|
17
|
+
headers?: Record<string, string>;
|
|
18
|
+
cookies?: string[];
|
|
19
|
+
};
|
|
20
|
+
export type LambderHttpEventFormat = "v1" | "v2";
|
|
21
|
+
export declare const normalizeHeaders: (headers?: LambderHeadersInput) => Record<string, string[]>;
|
|
22
|
+
export type LambderResponseInit = {
|
|
23
|
+
statusCode: HttpStatusCode;
|
|
24
|
+
headers?: LambderHeadersInput;
|
|
25
|
+
body?: string | Buffer | null;
|
|
26
|
+
/** True when body is already a base64-encoded string (pre-encoded binary content). */
|
|
27
|
+
isBodyBase64?: boolean;
|
|
28
|
+
/** "auto": gzip when enabled + compressible + large enough. true: force (if client accepts). false: never. */
|
|
29
|
+
compress?: boolean | "auto";
|
|
30
|
+
/** "auto": ETag on GET/HEAD 200 when globally enabled. true: force. false: never. */
|
|
31
|
+
etag?: boolean | "auto";
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Intermediate response object returned by all response builder methods and by
|
|
35
|
+
* route/api handlers. Bodies stay uncompressed and un-encoded so hooks can
|
|
36
|
+
* still transform them; a single finalize step at the end of render() applies
|
|
37
|
+
* compression, ETag/304 handling and base64 encoding.
|
|
38
|
+
*
|
|
39
|
+
* Throwing a LambderResponse anywhere inside a handler or hook short-circuits
|
|
40
|
+
* the request: the thrown response becomes the response.
|
|
41
|
+
*/
|
|
42
|
+
export declare class LambderResponse {
|
|
43
|
+
statusCode: HttpStatusCode;
|
|
44
|
+
headers: Record<string, string[]>;
|
|
45
|
+
body: string | Buffer | null;
|
|
46
|
+
isBodyBase64: boolean;
|
|
47
|
+
compress: boolean | "auto";
|
|
48
|
+
etag: boolean | "auto";
|
|
49
|
+
constructor(init: LambderResponseInit);
|
|
50
|
+
getHeader(key: string): string[] | undefined;
|
|
51
|
+
setHeader(key: string, value: string | string[]): this;
|
|
52
|
+
addHeader(key: string, value: string): this;
|
|
53
|
+
}
|
|
54
|
+
export declare const isCompressibleContentType: (contentType: string | undefined) => boolean;
|
|
55
|
+
export declare const acceptsEncoding: (acceptEncoding: string | undefined | null, encoding: string) => boolean;
|
|
56
|
+
export type LambderFinalizeOptions = {
|
|
57
|
+
compression: false | {
|
|
58
|
+
minBytes: number;
|
|
59
|
+
};
|
|
60
|
+
etag: boolean;
|
|
61
|
+
/** Guard against Lambda's ~6MB response cap with a clear error. */
|
|
62
|
+
maxResponseBytes: number;
|
|
63
|
+
};
|
|
64
|
+
export declare const DEFAULT_FINALIZE_OPTIONS: LambderFinalizeOptions;
|
|
65
|
+
/**
|
|
66
|
+
* Convert an intermediate LambderResponse into the final Lambda response:
|
|
67
|
+
* gzip negotiation (Accept-Encoding), ETag + If-None-Match 304, base64
|
|
68
|
+
* encoding, HEAD body stripping, and Lambda payload size guard. Emits the v1
|
|
69
|
+
* (REST API) or v2 (HTTP API / Function URL) response shape.
|
|
70
|
+
*/
|
|
71
|
+
export declare const finalizeResponse: (ctx: Pick<LambderRenderContext, "method" | "headers"> | null, response: LambderResponse, options: LambderFinalizeOptions, format?: LambderHttpEventFormat) => Promise<LambderHttpResponse>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { getZlib, getCrypto } from "./node-polyfills.js";
|
|
2
|
+
export const normalizeHeaders = (headers) => Object.fromEntries(Object.entries(headers ?? {}).map(([k, v]) => [k, Array.isArray(v) ? [...v] : [v]]));
|
|
3
|
+
/**
|
|
4
|
+
* Intermediate response object returned by all response builder methods and by
|
|
5
|
+
* route/api handlers. Bodies stay uncompressed and un-encoded so hooks can
|
|
6
|
+
* still transform them; a single finalize step at the end of render() applies
|
|
7
|
+
* compression, ETag/304 handling and base64 encoding.
|
|
8
|
+
*
|
|
9
|
+
* Throwing a LambderResponse anywhere inside a handler or hook short-circuits
|
|
10
|
+
* the request: the thrown response becomes the response.
|
|
11
|
+
*/
|
|
12
|
+
export class LambderResponse {
|
|
13
|
+
statusCode;
|
|
14
|
+
headers;
|
|
15
|
+
body;
|
|
16
|
+
isBodyBase64;
|
|
17
|
+
compress;
|
|
18
|
+
etag;
|
|
19
|
+
constructor(init) {
|
|
20
|
+
this.statusCode = init.statusCode;
|
|
21
|
+
this.headers = normalizeHeaders(init.headers);
|
|
22
|
+
this.body = init.body ?? null;
|
|
23
|
+
this.isBodyBase64 = init.isBodyBase64 ?? false;
|
|
24
|
+
this.compress = init.compress ?? "auto";
|
|
25
|
+
this.etag = init.etag ?? "auto";
|
|
26
|
+
}
|
|
27
|
+
getHeader(key) {
|
|
28
|
+
const lower = key.toLowerCase();
|
|
29
|
+
for (const [k, v] of Object.entries(this.headers)) {
|
|
30
|
+
if (k.toLowerCase() === lower)
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
setHeader(key, value) {
|
|
36
|
+
const lower = key.toLowerCase();
|
|
37
|
+
for (const k of Object.keys(this.headers)) {
|
|
38
|
+
if (k.toLowerCase() === lower)
|
|
39
|
+
delete this.headers[k];
|
|
40
|
+
}
|
|
41
|
+
this.headers[key] = Array.isArray(value) ? [...value] : [value];
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
addHeader(key, value) {
|
|
45
|
+
const lower = key.toLowerCase();
|
|
46
|
+
const existingKey = Object.keys(this.headers).find((k) => k.toLowerCase() === lower);
|
|
47
|
+
if (existingKey) {
|
|
48
|
+
this.headers[existingKey].push(value);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
this.headers[key] = [value];
|
|
52
|
+
}
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export const isCompressibleContentType = (contentType) => {
|
|
57
|
+
if (!contentType)
|
|
58
|
+
return false;
|
|
59
|
+
const mime = (contentType.split(";")[0] ?? "").trim().toLowerCase();
|
|
60
|
+
if (mime.startsWith("text/"))
|
|
61
|
+
return true;
|
|
62
|
+
if (mime.endsWith("+json") || mime.endsWith("+xml"))
|
|
63
|
+
return true;
|
|
64
|
+
return [
|
|
65
|
+
"application/json",
|
|
66
|
+
"application/javascript",
|
|
67
|
+
"application/x-javascript",
|
|
68
|
+
"application/xml",
|
|
69
|
+
"application/wasm",
|
|
70
|
+
"image/svg+xml",
|
|
71
|
+
"application/lambder-json-stream",
|
|
72
|
+
].includes(mime);
|
|
73
|
+
};
|
|
74
|
+
export const acceptsEncoding = (acceptEncoding, encoding) => {
|
|
75
|
+
if (!acceptEncoding)
|
|
76
|
+
return false;
|
|
77
|
+
return acceptEncoding.split(",").some((part) => {
|
|
78
|
+
const [token, ...params] = part.trim().split(";");
|
|
79
|
+
const name = (token ?? "").trim().toLowerCase();
|
|
80
|
+
if (name !== encoding && name !== "*")
|
|
81
|
+
return false;
|
|
82
|
+
const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
|
|
83
|
+
return !q || Number(q.slice(2)) > 0;
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
export const DEFAULT_FINALIZE_OPTIONS = {
|
|
87
|
+
compression: { minBytes: 860 },
|
|
88
|
+
etag: true,
|
|
89
|
+
maxResponseBytes: 5_500_000,
|
|
90
|
+
};
|
|
91
|
+
const getRequestHeader = (ctx, name) => {
|
|
92
|
+
if (!ctx?.headers)
|
|
93
|
+
return undefined;
|
|
94
|
+
const lower = name.toLowerCase();
|
|
95
|
+
for (const [k, v] of Object.entries(ctx.headers)) {
|
|
96
|
+
if (k.toLowerCase() === lower)
|
|
97
|
+
return v ?? undefined;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
};
|
|
101
|
+
/** Emit the format-specific Lambda response shape. */
|
|
102
|
+
const emitResponse = (format, statusCode, headers, body, isBase64Encoded) => {
|
|
103
|
+
if (format === "v2") {
|
|
104
|
+
// Payload v2 has no multiValueHeaders: multi-values are comma-joined,
|
|
105
|
+
// except Set-Cookie which uses the dedicated cookies array.
|
|
106
|
+
const singleHeaders = {};
|
|
107
|
+
const cookies = [];
|
|
108
|
+
for (const [key, values] of Object.entries(headers)) {
|
|
109
|
+
if (key.toLowerCase() === "set-cookie")
|
|
110
|
+
cookies.push(...values);
|
|
111
|
+
else
|
|
112
|
+
singleHeaders[key] = values.join(", ");
|
|
113
|
+
}
|
|
114
|
+
return { statusCode, headers: singleHeaders, cookies, body, isBase64Encoded };
|
|
115
|
+
}
|
|
116
|
+
return { statusCode, multiValueHeaders: headers, body, isBase64Encoded };
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Convert an intermediate LambderResponse into the final Lambda response:
|
|
120
|
+
* gzip negotiation (Accept-Encoding), ETag + If-None-Match 304, base64
|
|
121
|
+
* encoding, HEAD body stripping, and Lambda payload size guard. Emits the v1
|
|
122
|
+
* (REST API) or v2 (HTTP API / Function URL) response shape.
|
|
123
|
+
*/
|
|
124
|
+
export const finalizeResponse = async (ctx, response, options, format = "v1") => {
|
|
125
|
+
const method = (ctx?.method ?? "GET").toUpperCase();
|
|
126
|
+
if (response.body === null) {
|
|
127
|
+
return emitResponse(format, response.statusCode, response.headers, "", false);
|
|
128
|
+
}
|
|
129
|
+
let outBody;
|
|
130
|
+
let isBase64 = false;
|
|
131
|
+
if (response.isBodyBase64) {
|
|
132
|
+
// Pre-encoded binary content: passes through untouched (no compression).
|
|
133
|
+
outBody = String(response.body);
|
|
134
|
+
isBase64 = true;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
let bodyBuffer = Buffer.isBuffer(response.body)
|
|
138
|
+
? response.body
|
|
139
|
+
: Buffer.from(String(response.body), "utf8");
|
|
140
|
+
const contentType = response.getHeader("Content-Type")?.[0];
|
|
141
|
+
const alreadyEncoded = !!response.getHeader("Content-Encoding");
|
|
142
|
+
const eligibleForCompression = !alreadyEncoded && (response.compress === true ||
|
|
143
|
+
(response.compress === "auto" &&
|
|
144
|
+
options.compression !== false &&
|
|
145
|
+
bodyBuffer.length >= options.compression.minBytes &&
|
|
146
|
+
isCompressibleContentType(contentType)));
|
|
147
|
+
if (eligibleForCompression) {
|
|
148
|
+
// Vary even when this client didn't accept an encoding, to keep caches correct.
|
|
149
|
+
response.addHeader("Vary", "Accept-Encoding");
|
|
150
|
+
if (acceptsEncoding(getRequestHeader(ctx, "accept-encoding"), "gzip")) {
|
|
151
|
+
const zlib = await getZlib();
|
|
152
|
+
if (zlib) {
|
|
153
|
+
bodyBuffer = zlib.gzipSync(bodyBuffer);
|
|
154
|
+
response.setHeader("Content-Encoding", "gzip");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (Buffer.isBuffer(response.body) || response.getHeader("Content-Encoding")) {
|
|
159
|
+
outBody = bodyBuffer.toString("base64");
|
|
160
|
+
isBase64 = true;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
outBody = bodyBuffer.toString("utf8");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const etagEnabled = response.etag === true || (response.etag === "auto" &&
|
|
167
|
+
options.etag &&
|
|
168
|
+
response.statusCode === 200 &&
|
|
169
|
+
(method === "GET" || method === "HEAD"));
|
|
170
|
+
if (etagEnabled) {
|
|
171
|
+
const crypto = await getCrypto();
|
|
172
|
+
if (crypto) {
|
|
173
|
+
const etagValue = `"${crypto.createHash("sha256").update(outBody).digest("hex").slice(0, 32)}"`;
|
|
174
|
+
response.setHeader("ETag", etagValue);
|
|
175
|
+
const ifNoneMatch = getRequestHeader(ctx, "if-none-match");
|
|
176
|
+
if (ifNoneMatch && ifNoneMatch.split(",").map((s) => s.trim()).includes(etagValue)) {
|
|
177
|
+
const preservedHeaders = {};
|
|
178
|
+
for (const key of ["ETag", "Cache-Control", "Vary", "Expires", "Last-Modified"]) {
|
|
179
|
+
const value = response.getHeader(key);
|
|
180
|
+
if (value)
|
|
181
|
+
preservedHeaders[key] = value;
|
|
182
|
+
}
|
|
183
|
+
return emitResponse(format, 304, preservedHeaders, "", false);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (method === "HEAD") {
|
|
188
|
+
return emitResponse(format, response.statusCode, response.headers, "", false);
|
|
189
|
+
}
|
|
190
|
+
if (outBody.length > options.maxResponseBytes) {
|
|
191
|
+
throw new Error(`Lambder: final response body is ${outBody.length} bytes which exceeds the configured ` +
|
|
192
|
+
`maxResponseBytes (${options.maxResponseBytes}). Lambda caps proxy responses at ~6MB. ` +
|
|
193
|
+
`Consider pagination or enabling compression.`);
|
|
194
|
+
}
|
|
195
|
+
return emitResponse(format, response.statusCode, response.headers, outBody, isBase64);
|
|
196
|
+
};
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import type { LambderRenderContext } from "./LambderContext.js";
|
|
2
|
+
import { LambderResponse, type HttpStatusCode, type LambderHeadersInput } from "./LambderResponse.js";
|
|
3
|
+
import { LambderSafeHtml } from "./LambderHtml.js";
|
|
4
|
+
import { type LambderTemplateData } from "./LambderTemplatingEngine.js";
|
|
5
|
+
export type LambderResponseOptions = {
|
|
6
|
+
statusCode?: HttpStatusCode;
|
|
7
|
+
headers?: LambderHeadersInput;
|
|
8
|
+
/** Shorthand for the Cache-Control header. */
|
|
9
|
+
cacheControl?: string;
|
|
10
|
+
/** "auto" (default): gzip when compressible/large enough. true: force. false: never. */
|
|
11
|
+
compress?: boolean | "auto";
|
|
12
|
+
/** "auto" (default): ETag on GET/HEAD 200 when globally enabled. true: force. false: never. */
|
|
13
|
+
etag?: boolean | "auto";
|
|
9
14
|
};
|
|
10
15
|
export type LambderApiResponseConfig = {
|
|
11
16
|
versionExpired?: boolean;
|
|
@@ -16,38 +21,58 @@ export type LambderApiResponseConfig = {
|
|
|
16
21
|
logList?: any[];
|
|
17
22
|
};
|
|
18
23
|
export type LambderApiResponse<T> = LambderApiResponseConfig & {
|
|
24
|
+
apiVersion?: string | null;
|
|
19
25
|
payload?: T | null;
|
|
20
26
|
};
|
|
27
|
+
export type LambderRawResponseInit = {
|
|
28
|
+
statusCode: HttpStatusCode;
|
|
29
|
+
headers?: LambderHeadersInput;
|
|
30
|
+
/** Legacy alias for headers (API Gateway naming). */
|
|
31
|
+
multiValueHeaders?: Record<string, string[]>;
|
|
32
|
+
body: string | Buffer | null;
|
|
33
|
+
/** True when body is already a base64-encoded string. */
|
|
34
|
+
isBase64Encoded?: boolean;
|
|
35
|
+
compress?: boolean | "auto";
|
|
36
|
+
etag?: boolean | "auto";
|
|
37
|
+
};
|
|
21
38
|
export default class LambderResponseBuilder<TResponse = any> {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
private ctx?;
|
|
27
|
-
constructor({ isCorsEnabled, publicPath, apiVersion, lambderUtils, ctx }: {
|
|
28
|
-
isCorsEnabled: boolean;
|
|
39
|
+
protected publicPath: string;
|
|
40
|
+
protected apiVersion: string | null;
|
|
41
|
+
protected ctx?: LambderRenderContext;
|
|
42
|
+
constructor({ publicPath, apiVersion, ctx }: {
|
|
29
43
|
publicPath: string;
|
|
30
44
|
apiVersion?: string | null;
|
|
31
|
-
|
|
32
|
-
ctx?: LambderRenderContext<any>;
|
|
45
|
+
ctx?: LambderRenderContext;
|
|
33
46
|
});
|
|
34
|
-
private
|
|
35
|
-
private
|
|
47
|
+
private buildResponse;
|
|
48
|
+
private resolvePublicFilePath;
|
|
36
49
|
addHeader(key: string, value: string): void;
|
|
37
50
|
setHeader(key: string, value: string | string[]): void;
|
|
38
51
|
logToApiResponse(input: any): void;
|
|
39
|
-
raw(
|
|
40
|
-
json(data: Record<string, any>,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
raw(init: LambderRawResponseInit): LambderResponse;
|
|
53
|
+
json(data: Record<string, any>, options?: LambderResponseOptions): LambderResponse;
|
|
54
|
+
text(data: string, options?: LambderResponseOptions): LambderResponse;
|
|
55
|
+
xml(data: string | LambderSafeHtml, options?: LambderResponseOptions): LambderResponse;
|
|
56
|
+
html(data: string | LambderSafeHtml, options?: LambderResponseOptions): LambderResponse;
|
|
57
|
+
status(statusCode: HttpStatusCode, body?: string, options?: LambderResponseOptions): LambderResponse;
|
|
58
|
+
status404(data: string, options?: LambderResponseOptions): LambderResponse;
|
|
59
|
+
redirect(url: string, statusCode?: HttpStatusCode, options?: LambderResponseOptions): LambderResponse;
|
|
60
|
+
versionExpired(options?: LambderResponseOptions): LambderResponse;
|
|
61
|
+
fileBase64(fileBase64: string, mimeType: string, options?: LambderResponseOptions): LambderResponse;
|
|
62
|
+
file(filePath: string, options?: LambderResponseOptions & {
|
|
63
|
+
fallback?: string;
|
|
64
|
+
}): Promise<LambderResponse>;
|
|
65
|
+
/**
|
|
66
|
+
* Render an HTML file under publicPath through LambderTemplatingEngine
|
|
67
|
+
* (comment-based slots/conditionals) and return it as an HTML response.
|
|
68
|
+
* The compiled template is cached across warm invocations; a missing file
|
|
69
|
+
* throws (it is a server-side configuration error, not a client 404).
|
|
70
|
+
* Set htmlVirtualSlots to expose "title"/"head" slots on marker-less files.
|
|
71
|
+
*/
|
|
72
|
+
templateFile(filePath: string, data?: LambderTemplateData, options?: LambderResponseOptions & {
|
|
73
|
+
htmlVirtualSlots?: boolean;
|
|
74
|
+
}): Promise<LambderResponse>;
|
|
75
|
+
api(payload: TResponse | null, { versionExpired, sessionExpired, notAuthorized, message, errorMessage, logList, }?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
|
|
76
|
+
/** Same as api() but forces gzip compression of the response body. */
|
|
77
|
+
apiBinary(payload: TResponse | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
|
|
53
78
|
}
|