lambder 4.3.1 → 4.4.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 +44 -6
- package/dist/client/LambderCaller.d.ts +75 -27
- package/dist/client/LambderCaller.js +18 -2
- package/dist/client.d.ts +1 -1
- package/dist/core/Lambder.d.ts +12 -9
- package/dist/core/Lambder.js +12 -8
- package/dist/core/LambderPublicFiles.d.ts +44 -12
- package/dist/core/LambderPublicFiles.js +60 -40
- package/dist/index.d.ts +7 -4
- package/dist/index.js +2 -1
- package/dist/session/LambderSessionController.d.ts +7 -0
- package/dist/session/LambderSessionController.js +10 -0
- package/dist/session/LambderSessionManager.d.ts +13 -19
- package/dist/session/LambderSessionManager.js +50 -18
- package/dist/stores/LambderDdbCache.d.ts +15 -4
- package/dist/stores/LambderDdbCache.js +49 -40
- package/dist/stores/LambderDdbCompression.d.ts +17 -0
- package/dist/stores/LambderDdbCompression.js +14 -7
- package/dist/stores/LambderDdbIdempotency.d.ts +19 -11
- package/dist/stores/LambderDdbIdempotency.js +17 -18
- package/dist/stores/LambderS3FileSource.d.ts +35 -0
- package/dist/stores/LambderS3FileSource.js +53 -0
- package/package.json +3 -1
|
@@ -8,36 +8,65 @@ const DEFAULT_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
|
8
8
|
const DEFAULT_CACHE_CONTROL = "public, max-age=3600";
|
|
9
9
|
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
10
10
|
const DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
/**
|
|
12
|
+
* Files from a folder on the Lambda's filesystem, typically the build output
|
|
13
|
+
* bundled into the deployment package. Reads stay under root. The default
|
|
14
|
+
* source of servePublicFiles, over publicPath.
|
|
15
|
+
*/
|
|
16
|
+
export class LambderLocalFileSource {
|
|
17
|
+
root;
|
|
18
|
+
constructor({ root }) {
|
|
19
|
+
this.root = root;
|
|
20
|
+
}
|
|
21
|
+
async read(relativePath) {
|
|
22
|
+
const fs = await getFS();
|
|
23
|
+
const path = await getPath();
|
|
24
|
+
if (!fs || !path)
|
|
25
|
+
throw new Error("LambderLocalFileSource requires a Node.js environment.");
|
|
26
|
+
const base = path.resolve(this.root);
|
|
27
|
+
const absolute = path.resolve(base, relativePath);
|
|
28
|
+
if (absolute !== base && !absolute.startsWith(base + path.sep))
|
|
29
|
+
return null;
|
|
30
|
+
const stat = await fs.promises.stat(absolute).catch(() => null);
|
|
31
|
+
if (!stat?.isFile())
|
|
32
|
+
return null;
|
|
33
|
+
return { body: await fs.promises.readFile(absolute) };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Strip the leading slash and reject traversal; null for a path that names no file (empty, or a directory). */
|
|
37
|
+
const toRelativePath = (target) => {
|
|
38
|
+
if (target.split("/").some((segment) => segment === ".."))
|
|
39
|
+
return null;
|
|
40
|
+
const relative = target.startsWith("/") ? target.slice(1) : target;
|
|
41
|
+
if (relative === "" || relative.endsWith("/"))
|
|
42
|
+
return null;
|
|
43
|
+
return relative;
|
|
44
|
+
};
|
|
11
45
|
/**
|
|
12
46
|
* Terminal public-file handler registered via lambder.servePublicFiles().
|
|
13
47
|
* Runs only when no route matched, so it can never shadow routes registered
|
|
14
|
-
* after it. Serves
|
|
48
|
+
* after it. Serves files from its source (traversal-safe, mime-typed,
|
|
15
49
|
* memory-cached, immutable-cache heuristic for content-hashed assets) and
|
|
16
|
-
* falls through to the route fallback when the
|
|
50
|
+
* falls through to the route fallback when the source has no such file.
|
|
17
51
|
*/
|
|
18
52
|
export class LambderPublicFilesHandler {
|
|
19
|
-
|
|
53
|
+
source;
|
|
20
54
|
options;
|
|
21
55
|
fileCache = new Map();
|
|
22
56
|
fileCacheBytes = 0;
|
|
23
|
-
constructor(
|
|
24
|
-
this.
|
|
57
|
+
constructor(source, options) {
|
|
58
|
+
this.source = source;
|
|
25
59
|
this.options = options;
|
|
26
60
|
}
|
|
27
61
|
/** Serve the mapped file, or return null to fall through. */
|
|
28
62
|
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
63
|
const mappedPath = this.options.path ? this.options.path(ctx) : ctx.path;
|
|
34
64
|
if (!mappedPath)
|
|
35
65
|
return null;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
if (!filePath)
|
|
66
|
+
const relativePath = toRelativePath(mappedPath);
|
|
67
|
+
if (relativePath === null)
|
|
39
68
|
return null;
|
|
40
|
-
const file = await this.
|
|
69
|
+
const file = await this.readCached(relativePath);
|
|
41
70
|
if (!file)
|
|
42
71
|
return null;
|
|
43
72
|
const compressOption = this.options.compress;
|
|
@@ -46,61 +75,52 @@ export class LambderPublicFilesHandler {
|
|
|
46
75
|
statusCode: 200,
|
|
47
76
|
headers: {
|
|
48
77
|
"Content-Type": file.mimeType,
|
|
49
|
-
"Cache-Control": this.cacheControlFor(ctx,
|
|
78
|
+
"Cache-Control": this.cacheControlFor(ctx, relativePath),
|
|
50
79
|
},
|
|
51
80
|
body: file.body,
|
|
52
81
|
compress,
|
|
53
82
|
});
|
|
54
83
|
}
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
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);
|
|
84
|
+
/** Read from the source, caching small files in memory for warm invocations. */
|
|
85
|
+
async readCached(relativePath) {
|
|
86
|
+
const cached = this.fileCache.get(relativePath);
|
|
68
87
|
if (cached)
|
|
69
88
|
return cached;
|
|
70
|
-
const
|
|
71
|
-
if (!
|
|
89
|
+
const file = await this.source.read(relativePath);
|
|
90
|
+
if (!file)
|
|
72
91
|
return null;
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
const entry = {
|
|
93
|
+
body: file.body,
|
|
94
|
+
mimeType: file.mimeType || mimeTypeResolver.lookup(relativePath) || "application/octet-stream",
|
|
95
|
+
};
|
|
76
96
|
const cacheConfig = this.options.memoryCache;
|
|
77
97
|
if (cacheConfig !== false) {
|
|
78
98
|
const maxBytes = cacheConfig?.maxBytes ?? DEFAULT_MEMORY_CACHE_MAX_BYTES;
|
|
79
99
|
const maxFileBytes = cacheConfig?.maxFileBytes ?? DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES;
|
|
80
|
-
if (body.length <= maxFileBytes) {
|
|
100
|
+
if (entry.body.length <= maxFileBytes) {
|
|
81
101
|
// Evict oldest entries until the new file fits the budget.
|
|
82
102
|
for (const [key, value] of this.fileCache) {
|
|
83
|
-
if (this.fileCacheBytes + body.length <= maxBytes)
|
|
103
|
+
if (this.fileCacheBytes + entry.body.length <= maxBytes)
|
|
84
104
|
break;
|
|
85
105
|
this.fileCache.delete(key);
|
|
86
106
|
this.fileCacheBytes -= value.body.length;
|
|
87
107
|
}
|
|
88
|
-
if (this.fileCacheBytes + body.length <= maxBytes) {
|
|
89
|
-
this.fileCache.set(
|
|
90
|
-
this.fileCacheBytes += body.length;
|
|
108
|
+
if (this.fileCacheBytes + entry.body.length <= maxBytes) {
|
|
109
|
+
this.fileCache.set(relativePath, entry);
|
|
110
|
+
this.fileCacheBytes += entry.body.length;
|
|
91
111
|
}
|
|
92
112
|
}
|
|
93
113
|
}
|
|
94
114
|
return entry;
|
|
95
115
|
}
|
|
96
|
-
cacheControlFor(ctx,
|
|
116
|
+
cacheControlFor(ctx, relativePath) {
|
|
97
117
|
const cacheOption = this.options.cacheControl;
|
|
98
118
|
if (typeof cacheOption === "function")
|
|
99
|
-
return cacheOption(ctx,
|
|
119
|
+
return cacheOption(ctx, relativePath);
|
|
100
120
|
const immutablePattern = this.options.immutablePattern === false
|
|
101
121
|
? null
|
|
102
122
|
: (this.options.immutablePattern ?? DEFAULT_IMMUTABLE_PATTERN);
|
|
103
|
-
if (immutablePattern && immutablePattern.test(
|
|
123
|
+
if (immutablePattern && immutablePattern.test(relativePath)) {
|
|
104
124
|
return this.options.immutableCacheControl ?? DEFAULT_IMMUTABLE_CACHE_CONTROL;
|
|
105
125
|
}
|
|
106
126
|
return cacheOption ?? DEFAULT_CACHE_CONTROL;
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import Lambder from './core/Lambder.js';
|
|
|
2
2
|
export default Lambder;
|
|
3
3
|
export { initLambder } from './core/Lambder.js';
|
|
4
4
|
export { default as LambderCaller } from "./client/LambderCaller.js";
|
|
5
|
-
export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope } from "./client/LambderCaller.js";
|
|
5
|
+
export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope } from "./client/LambderCaller.js";
|
|
6
6
|
export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
|
|
7
7
|
export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
|
|
8
8
|
export { default as LambderResponseBuilder } from "./core/LambderResponseBuilder.js";
|
|
@@ -15,10 +15,13 @@ export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
|
|
|
15
15
|
export type { LambderTemplateData, LambderTemplatingEngineOptions } from "./core/LambderTemplatingEngine.js";
|
|
16
16
|
export type { LambderResponseOptions, LambderRawResponseInit, } from "./core/LambderResponseBuilder.js";
|
|
17
17
|
export type { LambderRouteMatcher, LambderCorsConfig, LambderCreateOptions, LambderSessionOptions, ConditionFunction, RouteCondition, PathParamsOf, LambderActionTools, LambderHandler, LambderIndexHtmlOptions, } from "./core/Lambder.js";
|
|
18
|
-
export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
|
|
19
|
-
export type { LambderPublicFilesOptions } from "./core/LambderPublicFiles.js";
|
|
18
|
+
export { LambderPublicFilesHandler, LambderLocalFileSource } from "./core/LambderPublicFiles.js";
|
|
19
|
+
export type { LambderPublicFilesOptions, LambderPublicFileSource, LambderPublicFile } from "./core/LambderPublicFiles.js";
|
|
20
|
+
export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
|
|
21
|
+
export type { LambderS3FileSourceOptions } from "./stores/LambderS3FileSource.js";
|
|
20
22
|
export type { LambderSessionCookieOptions } from "./session/LambderSessionController.js";
|
|
21
|
-
export type { LambderSessionContext, LambderCreatedSession, LambderSessionDataRefreshConfig
|
|
23
|
+
export type { LambderSessionContext, LambderCreatedSession, LambderSessionDataRefreshConfig } from "./session/LambderSessionManager.js";
|
|
24
|
+
export type { LambderCompressionOption, LambderCompressionConfig } from "./stores/LambderDdbCompression.js";
|
|
22
25
|
export { LambderSessionDataRefreshError, LambderSessionReadError } from "./session/LambderSessionManager.js";
|
|
23
26
|
export { LambderDdbCache } from "./stores/LambderDdbCache.js";
|
|
24
27
|
export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./stores/LambderDdbCache.js";
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,8 @@ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtm
|
|
|
15
15
|
// Comment-based HTML templating engine (build-pipeline-safe slots and conditionals, standalone)
|
|
16
16
|
export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
|
|
17
17
|
// Public file serving
|
|
18
|
-
export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
|
|
18
|
+
export { LambderPublicFilesHandler, LambderLocalFileSource } from "./core/LambderPublicFiles.js";
|
|
19
|
+
export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
|
|
19
20
|
export { LambderSessionDataRefreshError, LambderSessionReadError } from "./session/LambderSessionManager.js";
|
|
20
21
|
// DynamoDB-backed compressed cache (standalone, server-only)
|
|
21
22
|
export { LambderDdbCache } from "./stores/LambderDdbCache.js";
|
|
@@ -49,6 +49,13 @@ export default class LambderSessionController<TSessionData = any> {
|
|
|
49
49
|
* session and touches no cookies, so it works on any subject.
|
|
50
50
|
*/
|
|
51
51
|
deleteSessionAllByKey(sessionKey: string): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Marks the data of every session of the given sessionKey stale, so each
|
|
54
|
+
* renews via dataRefresh on its next read: the way to apply a change to
|
|
55
|
+
* a subject's roles or permissions immediately, without logging them
|
|
56
|
+
* out. Needs no fetched session; requires dataRefresh.
|
|
57
|
+
*/
|
|
58
|
+
expireSessionDataAllByKey(sessionKey: string): Promise<void>;
|
|
52
59
|
endSession(): Promise<void>;
|
|
53
60
|
endSessionAll(): Promise<void>;
|
|
54
61
|
}
|
|
@@ -151,6 +151,16 @@ export default class LambderSessionController {
|
|
|
151
151
|
await this.lambderSessionManager.deleteSessionAllByKey(sessionKey);
|
|
152
152
|
}
|
|
153
153
|
;
|
|
154
|
+
/**
|
|
155
|
+
* Marks the data of every session of the given sessionKey stale, so each
|
|
156
|
+
* renews via dataRefresh on its next read: the way to apply a change to
|
|
157
|
+
* a subject's roles or permissions immediately, without logging them
|
|
158
|
+
* out. Needs no fetched session; requires dataRefresh.
|
|
159
|
+
*/
|
|
160
|
+
async expireSessionDataAllByKey(sessionKey) {
|
|
161
|
+
await this.lambderSessionManager.expireSessionDataAllByKey(sessionKey);
|
|
162
|
+
}
|
|
163
|
+
;
|
|
154
164
|
async endSession() {
|
|
155
165
|
if (!this.ctx.session)
|
|
156
166
|
throw new Error("Session not found.");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LambderCompressionOption } from "../stores/LambderDdbCompression.js";
|
|
1
2
|
export type LambderSessionContext<SessionData = any> = {
|
|
2
3
|
[x: string]: any;
|
|
3
4
|
/**
|
|
@@ -53,23 +54,6 @@ export type LambderSessionDataRefreshConfig<SessionData = any> = {
|
|
|
53
54
|
*/
|
|
54
55
|
refresh: (session: LambderSessionContext<SessionData>) => Promise<SessionData | null>;
|
|
55
56
|
};
|
|
56
|
-
/**
|
|
57
|
-
* Brotli compression of session.data at rest. The option is `true` by
|
|
58
|
-
* default, which equals `{ minBytes: 0 }`: every record compressed. A
|
|
59
|
-
* compressed record carries the data's JSON as Brotli bytes (`dataBr`)
|
|
60
|
-
* beside its byte length (`dataBytes`), the scheme LambderDdbCache and
|
|
61
|
-
* LambderDdbIdempotency use. Below minBytes, or with compression off, the
|
|
62
|
-
* record keeps a plain `data` attribute. Reads accept both shapes, so the
|
|
63
|
-
* setting can be switched on or off on a live table: records written under
|
|
64
|
-
* the other setting keep reading, and each is rewritten in the current
|
|
65
|
-
* shape on its next write.
|
|
66
|
-
*/
|
|
67
|
-
export type LambderSessionCompressionConfig = {
|
|
68
|
-
/** JSON byte length from which data is stored compressed. Default: 0 (always). */
|
|
69
|
-
minBytes?: number;
|
|
70
|
-
/** Brotli quality (0-11), like LambderDdbCache. Default: 5. */
|
|
71
|
-
quality?: number;
|
|
72
|
-
};
|
|
73
57
|
/**
|
|
74
58
|
* Wraps errors thrown by the dataRefresh callback so they stay
|
|
75
59
|
* distinguishable from "no session": fetchSessionIfExists() swallows missing
|
|
@@ -108,7 +92,7 @@ export default class LambderSessionManager {
|
|
|
108
92
|
enableSlidingExpiration?: boolean;
|
|
109
93
|
slidingWriteIntervalSeconds?: number;
|
|
110
94
|
dataRefresh?: LambderSessionDataRefreshConfig;
|
|
111
|
-
compression?:
|
|
95
|
+
compression?: LambderCompressionOption;
|
|
112
96
|
});
|
|
113
97
|
private sessionUserKeyHasher;
|
|
114
98
|
/**
|
|
@@ -124,10 +108,10 @@ export default class LambderSessionManager {
|
|
|
124
108
|
* Persists a session record. With compression on, `data` is stored as
|
|
125
109
|
* Brotli bytes (`dataBr`) beside its JSON byte length (`dataBytes`)
|
|
126
110
|
* once the JSON reaches minBytes; otherwise it stays a plain attribute.
|
|
127
|
-
* See LambderSessionCompressionConfig.
|
|
128
111
|
*/
|
|
129
112
|
private ddbPutItem;
|
|
130
113
|
private ddbDeleteItem;
|
|
114
|
+
/** Sort keys of every session under a partition (the callers only need the keys). */
|
|
131
115
|
private ddbQueryAllByPartitionKey;
|
|
132
116
|
private ddbDeleteAllByPartitionKey;
|
|
133
117
|
createSession(sessionKey: string, data?: any, ttlInSeconds?: number, options?: {
|
|
@@ -152,5 +136,15 @@ export default class LambderSessionManager {
|
|
|
152
136
|
* session record.
|
|
153
137
|
*/
|
|
154
138
|
deleteSessionAllByKey(sessionKey: string): Promise<boolean>;
|
|
139
|
+
/**
|
|
140
|
+
* Marks the data of every session of the given sessionKey stale, so each
|
|
141
|
+
* renews via dataRefresh on its next read: "this subject's roles or
|
|
142
|
+
* permissions changed, apply it now", without logging the subject out
|
|
143
|
+
* (deleteSessionAllByKey) and without waiting for the data TTL. Stamps
|
|
144
|
+
* dataExpiresAt only, conditionally on the record still existing, so it
|
|
145
|
+
* neither resurrects a session deleted in between nor overwrites a
|
|
146
|
+
* concurrent write. Requires dataRefresh to be configured.
|
|
147
|
+
*/
|
|
148
|
+
expireSessionDataAllByKey(sessionKey: string): Promise<boolean>;
|
|
155
149
|
regenerateSession(session: LambderSessionContext): Promise<LambderCreatedSession>;
|
|
156
150
|
}
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
3
|
-
import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
|
|
4
|
-
import { brotliCompressText, brotliRestoreText } from "../stores/LambderDdbCompression.js";
|
|
3
|
+
import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
|
|
4
|
+
import { brotliCompressText, brotliRestoreText, resolveCompressionOption, } from "../stores/LambderDdbCompression.js";
|
|
5
|
+
/**
|
|
6
|
+
* Session compression defaults: every record compressed (see
|
|
7
|
+
* LambderCompressionOption for the option's shape and toggle semantics).
|
|
8
|
+
* A compressed record carries the data's JSON as Brotli bytes (`dataBr`)
|
|
9
|
+
* beside its byte length (`dataBytes`), the scheme LambderDdbCache and
|
|
10
|
+
* LambderDdbIdempotency use; below minBytes, or with compression off, the
|
|
11
|
+
* record keeps a plain `data` attribute.
|
|
12
|
+
*/
|
|
13
|
+
const SESSION_COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
|
|
5
14
|
/**
|
|
6
15
|
* Wraps errors thrown by the dataRefresh callback so they stay
|
|
7
16
|
* distinguishable from "no session": fetchSessionIfExists() swallows missing
|
|
@@ -37,7 +46,7 @@ export default class LambderSessionManager {
|
|
|
37
46
|
slidingWriteIntervalSeconds;
|
|
38
47
|
dataRefresh;
|
|
39
48
|
compression;
|
|
40
|
-
constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, compression
|
|
49
|
+
constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, compression, }) {
|
|
41
50
|
this.tableName = tableName;
|
|
42
51
|
this.sessionSalt = sessionSalt;
|
|
43
52
|
this.partitionKey = partitionKey;
|
|
@@ -45,19 +54,7 @@ export default class LambderSessionManager {
|
|
|
45
54
|
this.enableSlidingExpiration = enableSlidingExpiration;
|
|
46
55
|
this.slidingWriteIntervalSeconds = slidingWriteIntervalSeconds ?? null;
|
|
47
56
|
this.dataRefresh = dataRefresh ?? null;
|
|
48
|
-
|
|
49
|
-
this.compression = compressionConfig ? {
|
|
50
|
-
minBytes: compressionConfig.minBytes ?? 0,
|
|
51
|
-
quality: compressionConfig.quality ?? 5,
|
|
52
|
-
} : null;
|
|
53
|
-
if (this.compression) {
|
|
54
|
-
if (!Number.isSafeInteger(this.compression.minBytes) || this.compression.minBytes < 0) {
|
|
55
|
-
throw new Error("compression.minBytes must be a non-negative integer");
|
|
56
|
-
}
|
|
57
|
-
if (!Number.isInteger(this.compression.quality) || this.compression.quality < 0 || this.compression.quality > 11) {
|
|
58
|
-
throw new Error("compression.quality must be an integer from 0 to 11");
|
|
59
|
-
}
|
|
60
|
-
}
|
|
57
|
+
this.compression = resolveCompressionOption(compression, SESSION_COMPRESSION_DEFAULTS);
|
|
61
58
|
const ddbClient = new DynamoDBClient({ region: tableRegion });
|
|
62
59
|
this.ddbDocumentClient = DynamoDBDocumentClient.from(ddbClient);
|
|
63
60
|
}
|
|
@@ -93,7 +90,6 @@ export default class LambderSessionManager {
|
|
|
93
90
|
* Persists a session record. With compression on, `data` is stored as
|
|
94
91
|
* Brotli bytes (`dataBr`) beside its JSON byte length (`dataBytes`)
|
|
95
92
|
* once the JSON reaches minBytes; otherwise it stays a plain attribute.
|
|
96
|
-
* See LambderSessionCompressionConfig.
|
|
97
93
|
*/
|
|
98
94
|
async ddbPutItem(session) {
|
|
99
95
|
const { data, ...item } = session;
|
|
@@ -112,11 +108,13 @@ export default class LambderSessionManager {
|
|
|
112
108
|
return await this.ddbDocumentClient.send(new DeleteCommand({ TableName: this.tableName, Key: key, }));
|
|
113
109
|
}
|
|
114
110
|
;
|
|
111
|
+
/** Sort keys of every session under a partition (the callers only need the keys). */
|
|
115
112
|
async ddbQueryAllByPartitionKey(partitionValue) {
|
|
116
113
|
const params = {
|
|
117
114
|
TableName: this.tableName,
|
|
118
115
|
KeyConditionExpression: "#pk = :pv",
|
|
119
|
-
|
|
116
|
+
ProjectionExpression: "#sk",
|
|
117
|
+
ExpressionAttributeNames: { "#pk": this.partitionKey, "#sk": this.sortKey },
|
|
120
118
|
ExpressionAttributeValues: { ":pv": partitionValue },
|
|
121
119
|
};
|
|
122
120
|
const queryResults = [];
|
|
@@ -345,6 +343,40 @@ export default class LambderSessionManager {
|
|
|
345
343
|
return true;
|
|
346
344
|
}
|
|
347
345
|
;
|
|
346
|
+
/**
|
|
347
|
+
* Marks the data of every session of the given sessionKey stale, so each
|
|
348
|
+
* renews via dataRefresh on its next read: "this subject's roles or
|
|
349
|
+
* permissions changed, apply it now", without logging the subject out
|
|
350
|
+
* (deleteSessionAllByKey) and without waiting for the data TTL. Stamps
|
|
351
|
+
* dataExpiresAt only, conditionally on the record still existing, so it
|
|
352
|
+
* neither resurrects a session deleted in between nor overwrites a
|
|
353
|
+
* concurrent write. Requires dataRefresh to be configured.
|
|
354
|
+
*/
|
|
355
|
+
async expireSessionDataAllByKey(sessionKey) {
|
|
356
|
+
if (!this.dataRefresh)
|
|
357
|
+
throw new Error("dataRefresh is not configured. Pass session.dataRefresh at creation to enable.");
|
|
358
|
+
const partitionValue = this.sessionUserKeyHasher(sessionKey);
|
|
359
|
+
const now = Math.floor(Date.now() / 1000);
|
|
360
|
+
for (const item of await this.ddbQueryAllByPartitionKey(partitionValue)) {
|
|
361
|
+
try {
|
|
362
|
+
await this.ddbDocumentClient.send(new UpdateCommand({
|
|
363
|
+
TableName: this.tableName,
|
|
364
|
+
Key: { [this.partitionKey]: partitionValue, [this.sortKey]: item[this.sortKey] },
|
|
365
|
+
UpdateExpression: "SET #dataExpiresAt = :now",
|
|
366
|
+
ConditionExpression: "attribute_exists(#sk)",
|
|
367
|
+
ExpressionAttributeNames: { "#dataExpiresAt": "dataExpiresAt", "#sk": this.sortKey },
|
|
368
|
+
ExpressionAttributeValues: { ":now": now },
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
// Deleted between the query and the update: nothing left to expire.
|
|
373
|
+
if (err.name !== "ConditionalCheckFailedException")
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
;
|
|
348
380
|
async regenerateSession(session) {
|
|
349
381
|
if (!session)
|
|
350
382
|
throw new Error("Invalid session");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
+
import { type LambderCompressionOption } from "./LambderDdbCompression.js";
|
|
2
3
|
export interface LambderDdbCacheOptions {
|
|
3
4
|
tableName: string;
|
|
4
5
|
region?: string;
|
|
@@ -7,7 +8,13 @@ export interface LambderDdbCacheOptions {
|
|
|
7
8
|
namespace?: string;
|
|
8
9
|
defaultTtlSeconds?: number;
|
|
9
10
|
chunkBytes?: number;
|
|
10
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Brotli compression of stored values. `true` (the default) is
|
|
13
|
+
* `{ minBytes: 0, quality: 5 }`: every value compressed; `false` stores
|
|
14
|
+
* values plain; an object overrides the defaults. The manifest records
|
|
15
|
+
* each value's encoding, so it can be switched on or off on a live table.
|
|
16
|
+
*/
|
|
17
|
+
compression?: LambderCompressionOption;
|
|
11
18
|
maxValueBytes?: number;
|
|
12
19
|
memoryMaxBytes?: number;
|
|
13
20
|
client?: DynamoDBClient;
|
|
@@ -22,8 +29,10 @@ export interface LambderDdbCacheGetOrSetOptions extends LambderDdbCacheSetOption
|
|
|
22
29
|
/**
|
|
23
30
|
* Persistent JSON cache backed by DynamoDB.
|
|
24
31
|
*
|
|
25
|
-
* Values are Brotli-compressed
|
|
26
|
-
*
|
|
32
|
+
* Values are Brotli-compressed by default (`compression` option; the manifest
|
|
33
|
+
* records each value's encoding, so the option can be switched on a live
|
|
34
|
+
* table). Values within the safe DynamoDB item budget are stored directly in
|
|
35
|
+
* the manifest for a single-request read; larger values are
|
|
27
36
|
* split into versioned binary chunks. A manifest is written only after every
|
|
28
37
|
* chunk succeeds, so readers see either the previous complete version or the
|
|
29
38
|
* new complete version. DynamoDB TTL is cleanup only; every read also checks
|
|
@@ -41,7 +50,7 @@ export declare class LambderDdbCache {
|
|
|
41
50
|
private readonly client;
|
|
42
51
|
private readonly defaultTtlSeconds;
|
|
43
52
|
private readonly chunkBytes;
|
|
44
|
-
private readonly
|
|
53
|
+
private readonly compression;
|
|
45
54
|
private readonly maxValueBytes;
|
|
46
55
|
private readonly memory;
|
|
47
56
|
private readonly inFlight;
|
|
@@ -64,6 +73,8 @@ export declare class LambderDdbCache {
|
|
|
64
73
|
private readChunks;
|
|
65
74
|
private invalidateManifest;
|
|
66
75
|
private batchWrite;
|
|
76
|
+
/** The JSON text of a stored payload. */
|
|
77
|
+
private decode;
|
|
67
78
|
private remember;
|
|
68
79
|
private normalizeKey;
|
|
69
80
|
private partitionKey;
|