@everystack/server 0.4.37 → 0.4.38
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/CHANGELOG.md +27 -0
- package/package.json +11 -3
- package/src/cache-control.ts +36 -0
- package/src/index.ts +7 -1
- package/src/kvs-metrics.ts +153 -0
- package/src/kvs.ts +326 -29
- package/src/plugin.ts +7 -1
- package/src/provision.ts +97 -1
- package/src/purge-graph.ts +91 -0
- package/src/purge-log.ts +63 -0
- package/src/purge.ts +337 -0
- package/src/role-chain.ts +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ All notable changes to `@everystack/server` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.38] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- make a broken KVS purge loud with EMF metrics and kvsPurgeCheck
|
|
12
|
+
- cache.htmlByPath, a per-path Cache-Control for unauthenticated HTML
|
|
13
|
+
- db:provision creates the reserved lifecycle role (P2.7)
|
|
14
|
+
- everystack.purge_log, append-only
|
|
15
|
+
- purgeExpired, the core
|
|
16
|
+
- purge refuses a graph it does not understand
|
|
17
|
+
- purge cannot starve, and is bounded
|
|
18
|
+
- db:doctor reports lifecycle rows past the purge window
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- sign KVS purges with an explicit SigV4a signer
|
|
23
|
+
- KVS client shares the signer's credentials; prove the send path signs
|
|
24
|
+
- batch KVS purges and retry on ETag conflicts
|
|
25
|
+
- retry KVS purges per path with PutKey, not UpdateKeys
|
|
26
|
+
- a bad metrics setting never stops a KVS purge; check region and retryable faults
|
|
27
|
+
- htmlByPath matches the path the server renders (resolve dot segments)
|
|
28
|
+
- purge_log ensure revokes only from roles that hold a grant
|
|
29
|
+
- check purge deletion before destroy hooks
|
|
30
|
+
- surface purge after-destroy failures
|
|
31
|
+
- purge refuses an undeclared lifecycle child, and the revoke exempts the table owner
|
|
32
|
+
- a DELETE issued from inside a trigger on a lifecycle table is a soft delete
|
|
33
|
+
|
|
7
34
|
## [0.4.37] - 2026-10-18
|
|
8
35
|
|
|
9
36
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.38",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -125,6 +125,11 @@
|
|
|
125
125
|
"source": "./src/restore-witness.ts",
|
|
126
126
|
"types": "./src/restore-witness.ts",
|
|
127
127
|
"default": "./src/restore-witness.ts"
|
|
128
|
+
},
|
|
129
|
+
"./purge": {
|
|
130
|
+
"source": "./src/purge.ts",
|
|
131
|
+
"types": "./src/purge.ts",
|
|
132
|
+
"default": "./src/purge.ts"
|
|
128
133
|
}
|
|
129
134
|
},
|
|
130
135
|
"peerDependencies": {
|
|
@@ -192,8 +197,11 @@
|
|
|
192
197
|
"sst": "4.13.1",
|
|
193
198
|
"ts-jest": "29.4.9",
|
|
194
199
|
"typescript": "5.9.3",
|
|
195
|
-
"@everystack/
|
|
196
|
-
"@everystack/
|
|
200
|
+
"@everystack/cli": "0.4.102",
|
|
201
|
+
"@everystack/auth": "0.4.9"
|
|
202
|
+
},
|
|
203
|
+
"dependencies": {
|
|
204
|
+
"@everystack/model": "0.4.23"
|
|
197
205
|
},
|
|
198
206
|
"scripts": {
|
|
199
207
|
"test": "jest",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ServerCacheConfig } from './index';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_HTML = 'public, max-age=0, s-maxage=60';
|
|
4
|
+
|
|
5
|
+
function normalize(path: string): string {
|
|
6
|
+
let p = path;
|
|
7
|
+
try { p = decodeURIComponent(path); } catch { /* malformed escape: match the raw path */ }
|
|
8
|
+
// The server renders the URL after dot segments are resolved (eventToRequest -> new URL), so
|
|
9
|
+
// `/x/../admin` is the admin shell. Collapse leading slashes first: `//admin` would otherwise
|
|
10
|
+
// parse as a host. Over-matching here only ever picks the stricter rule.
|
|
11
|
+
p = '/' + p.split('/').filter(Boolean).join('/');
|
|
12
|
+
try { p = new URL(p, 'http://h').pathname; } catch { /* keep the collapsed path */ }
|
|
13
|
+
return '/' + p.split('/').filter(Boolean).join('/');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Cache-Control for an unauthenticated HTML GET: the longest `htmlByPath` prefix that matches
|
|
18
|
+
* whole path segments, else `html`, else the built-in default. The path is decoded, its slashes
|
|
19
|
+
* collapsed and its dot segments resolved first, so `/%61dmin`, `//admin` and `/x/../admin` cannot
|
|
20
|
+
* slip past a rule on `/admin`. Keys that normalize alike: the first declared wins.
|
|
21
|
+
*/
|
|
22
|
+
export function htmlCacheControl(path: string, cache: ServerCacheConfig | undefined): string {
|
|
23
|
+
const rules = cache?.htmlByPath;
|
|
24
|
+
if (rules) {
|
|
25
|
+
const target = normalize(path);
|
|
26
|
+
let best = '';
|
|
27
|
+
let value: string | undefined;
|
|
28
|
+
for (const [prefix, cc] of Object.entries(rules)) {
|
|
29
|
+
const p = normalize(prefix);
|
|
30
|
+
const hit = p === '/' || target === p || target.startsWith(p + '/');
|
|
31
|
+
if (hit && (value === undefined || p.length > best.length)) { best = p; value = cc; }
|
|
32
|
+
}
|
|
33
|
+
if (value !== undefined) return value;
|
|
34
|
+
}
|
|
35
|
+
return cache?.html ?? DEFAULT_HTML;
|
|
36
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
import { log } from './log';
|
|
19
19
|
import { buildTimingHeaders } from './timing';
|
|
20
20
|
import { DEFAULT_STRIP_QUERY_PARAMS, stripQueryParams } from './strip-query-params';
|
|
21
|
+
import { htmlCacheControl } from './cache-control';
|
|
21
22
|
|
|
22
23
|
export { buildTimingHeaders } from './timing';
|
|
23
24
|
export type { TimingHeaderOptions } from './timing';
|
|
@@ -72,6 +73,11 @@ export interface ServerCacheConfig {
|
|
|
72
73
|
* with no CloudFront invalidation. No stale-while-revalidate: the cap is hard.
|
|
73
74
|
* Default: 'public, max-age=0, s-maxage=60' */
|
|
74
75
|
html?: string;
|
|
76
|
+
/** Per-path override of `html`: path prefix → Cache-Control, for unauthenticated HTML.
|
|
77
|
+
* Keys match whole path segments (`/admin` covers `/admin` and `/admin/x`, not
|
|
78
|
+
* `/administrator`); the longest match wins; no match falls back to `html`.
|
|
79
|
+
* Example: `{ '/admin': 'private, no-store' }`. */
|
|
80
|
+
htmlByPath?: Record<string, string>;
|
|
75
81
|
/** Authenticated GET/HEAD responses.
|
|
76
82
|
* Default: 'private, no-store' */
|
|
77
83
|
private?: string;
|
|
@@ -295,7 +301,7 @@ export function createLambdaHandler(
|
|
|
295
301
|
// GETs are version-keyed and can cache long.
|
|
296
302
|
const isHtml = String(result.headers['content-type'] || '').includes('text/html');
|
|
297
303
|
result.headers['cache-control'] = isHtml
|
|
298
|
-
? (options.cache
|
|
304
|
+
? htmlCacheControl(path, options.cache)
|
|
299
305
|
: (options.cache?.public ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5');
|
|
300
306
|
}
|
|
301
307
|
} else {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudWatch embedded metric format (EMF) lines for the KVS purge path.
|
|
3
|
+
*
|
|
4
|
+
* A line written to stdout in Lambda becomes a CloudWatch metric with no SDK
|
|
5
|
+
* call and no extra IAM. It is written with `process.stdout.write`, never a
|
|
6
|
+
* `console` method: under the Lambda JSON log format, Lambda wraps console
|
|
7
|
+
* output in its own JSON event and CloudWatch does not extract metrics from
|
|
8
|
+
* the wrapped object.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_KVS_METRICS_NAMESPACE = 'Everystack/Cache';
|
|
12
|
+
export const MAX_KVS_METRIC_DIMENSIONS = 2;
|
|
13
|
+
/** The unpurgedPaths property keeps this many entries; the metric keeps the full count. */
|
|
14
|
+
export const MAX_LOGGED_UNPURGED_PATHS = 20;
|
|
15
|
+
|
|
16
|
+
export type KvsMetricName =
|
|
17
|
+
| 'KvsPurgeFailure'
|
|
18
|
+
| 'KvsPurgePaths'
|
|
19
|
+
| 'KvsPurgeUnpurgedPaths'
|
|
20
|
+
| 'KvsPurgeCheckFailure';
|
|
21
|
+
|
|
22
|
+
export interface KvsMetricsOptions {
|
|
23
|
+
/** CloudWatch namespace. Default `Everystack/Cache`. */
|
|
24
|
+
namespace?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Replaces the default dimension (`FunctionName` from
|
|
27
|
+
* AWS_LAMBDA_FUNCTION_NAME). At most 2 keys; `{}` means no dimension.
|
|
28
|
+
*/
|
|
29
|
+
dimensions?: Record<string, string>;
|
|
30
|
+
/** Written as a property so one request can be found in Logs Insights. */
|
|
31
|
+
requestId?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `false` turns emission off. */
|
|
35
|
+
export type KvsMetricsSetting = false | KvsMetricsOptions;
|
|
36
|
+
|
|
37
|
+
export interface ResolvedKvsMetrics {
|
|
38
|
+
namespace: string;
|
|
39
|
+
dimensions: Record<string, string>;
|
|
40
|
+
requestId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface KvsMetricPoint {
|
|
44
|
+
kvsArn: string;
|
|
45
|
+
metrics: Partial<Record<KvsMetricName, number>>;
|
|
46
|
+
unpurgedPaths?: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const PROPERTY_NAMES = ['_aws', 'kvsArn', 'source', 'unpurgedPaths', 'requestId'];
|
|
50
|
+
const METRIC_NAMES: KvsMetricName[] = [
|
|
51
|
+
'KvsPurgeFailure',
|
|
52
|
+
'KvsPurgePaths',
|
|
53
|
+
'KvsPurgeUnpurgedPaths',
|
|
54
|
+
'KvsPurgeCheckFailure',
|
|
55
|
+
];
|
|
56
|
+
const RESERVED = new Set<string>([...PROPERTY_NAMES, ...METRIC_NAMES]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the namespace and dimensions once, at call time. Returns null when
|
|
60
|
+
* metrics are off. Throws on more than 2 dimensions or a reserved dimension
|
|
61
|
+
* name; the purge and the check catch that through resolveKvsMetricsOrDefault.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveKvsMetrics(
|
|
64
|
+
setting?: KvsMetricsSetting,
|
|
65
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
66
|
+
): ResolvedKvsMetrics | null {
|
|
67
|
+
if (setting === false) return null;
|
|
68
|
+
const options = setting ?? {};
|
|
69
|
+
|
|
70
|
+
let dimensions: Record<string, string>;
|
|
71
|
+
if (options.dimensions) {
|
|
72
|
+
const names = Object.keys(options.dimensions);
|
|
73
|
+
if (names.length > MAX_KVS_METRIC_DIMENSIONS) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`KVS metrics: at most ${MAX_KVS_METRIC_DIMENSIONS} dimensions, got ${names.length} `
|
|
76
|
+
+ `(${names.join(', ')}). Put high-cardinality values in properties, not dimensions.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const reserved = names.filter((n) => RESERVED.has(n));
|
|
80
|
+
if (reserved.length > 0) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`KVS metrics: dimension name ${reserved.join(', ')} is reserved for a property or metric.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
dimensions = { ...options.dimensions };
|
|
86
|
+
} else {
|
|
87
|
+
const fn = env.AWS_LAMBDA_FUNCTION_NAME;
|
|
88
|
+
dimensions = fn ? { FunctionName: fn } : {};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
namespace: options.namespace ?? DEFAULT_KVS_METRICS_NAMESPACE,
|
|
93
|
+
dimensions,
|
|
94
|
+
...(options.requestId ? { requestId: options.requestId } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve the setting for a purge or a check. A bad setting must never stop a
|
|
100
|
+
* purge (stale beats down): log one error line naming the problem and fall
|
|
101
|
+
* back to the default metrics.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveKvsMetricsOrDefault(
|
|
104
|
+
setting?: KvsMetricsSetting,
|
|
105
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
106
|
+
): ResolvedKvsMetrics | null {
|
|
107
|
+
try {
|
|
108
|
+
return resolveKvsMetrics(setting, env);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error(`KVS metrics setting ignored, using the defaults: ${(err as Error).message}`);
|
|
111
|
+
return resolveKvsMetrics(undefined, env);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Build one EMF object. Dimensions and metrics are top-level members. */
|
|
116
|
+
export function buildKvsEmf(
|
|
117
|
+
resolved: ResolvedKvsMetrics,
|
|
118
|
+
point: KvsMetricPoint,
|
|
119
|
+
now: number = Date.now(),
|
|
120
|
+
): Record<string, unknown> {
|
|
121
|
+
const names = METRIC_NAMES.filter((n) => point.metrics[n] !== undefined);
|
|
122
|
+
return {
|
|
123
|
+
_aws: {
|
|
124
|
+
Timestamp: now,
|
|
125
|
+
CloudWatchMetrics: [{
|
|
126
|
+
Namespace: resolved.namespace,
|
|
127
|
+
Dimensions: [Object.keys(resolved.dimensions)],
|
|
128
|
+
Metrics: names.map((Name) => ({ Name, Unit: 'Count' })),
|
|
129
|
+
}],
|
|
130
|
+
},
|
|
131
|
+
...resolved.dimensions,
|
|
132
|
+
...Object.fromEntries(names.map((n) => [n, point.metrics[n]])),
|
|
133
|
+
kvsArn: point.kvsArn,
|
|
134
|
+
source: 'server',
|
|
135
|
+
...(point.unpurgedPaths
|
|
136
|
+
? { unpurgedPaths: point.unpurgedPaths.slice(0, MAX_LOGGED_UNPURGED_PATHS) }
|
|
137
|
+
: {}),
|
|
138
|
+
...(resolved.requestId ? { requestId: resolved.requestId } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Write one EMF line to stdout. Never throws: a metric that cannot be written
|
|
144
|
+
* must not turn a purge, or a purge failure, into a different error.
|
|
145
|
+
*/
|
|
146
|
+
export function emitKvsMetrics(resolved: ResolvedKvsMetrics | null, point: KvsMetricPoint): void {
|
|
147
|
+
if (!resolved) return;
|
|
148
|
+
try {
|
|
149
|
+
process.stdout.write(JSON.stringify(buildKvsEmf(resolved, point)) + '\n');
|
|
150
|
+
} catch {
|
|
151
|
+
// Nothing to do: the metric is lost, the caller's outcome is not.
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/kvs.ts
CHANGED
|
@@ -9,42 +9,339 @@
|
|
|
9
9
|
* import { invalidateKvsPaths } from '@everystack/server/kvs';
|
|
10
10
|
* await invalidateKvsPaths(kvsArn, ['/api/posts', '/api/profiles']);
|
|
11
11
|
*/
|
|
12
|
+
import type { CloudFrontKeyValueStoreClient } from '@aws-sdk/client-cloudfront-keyvaluestore';
|
|
13
|
+
import { emitKvsMetrics, resolveKvsMetricsOrDefault, type KvsMetricsSetting } from './kvs-metrics';
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
buildKvsEmf,
|
|
17
|
+
DEFAULT_KVS_METRICS_NAMESPACE,
|
|
18
|
+
emitKvsMetrics,
|
|
19
|
+
resolveKvsMetrics,
|
|
20
|
+
resolveKvsMetricsOrDefault,
|
|
21
|
+
type KvsMetricName,
|
|
22
|
+
type KvsMetricPoint,
|
|
23
|
+
type KvsMetricsOptions,
|
|
24
|
+
type KvsMetricsSetting,
|
|
25
|
+
type ResolvedKvsMetrics,
|
|
26
|
+
} from './kvs-metrics';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a KVS client that signs with an explicit SigV4a signer.
|
|
30
|
+
*
|
|
31
|
+
* KVS endpoints require SigV4a. A side-effect `import('@aws-sdk/signature-v4a')`
|
|
32
|
+
* registers the signer into one copy of @smithy/signature-v4, but the KVS client
|
|
33
|
+
* can carry a nested copy whose container is empty, and every request then fails
|
|
34
|
+
* with "Neither CRT nor JS SigV4a implementation is available". When
|
|
35
|
+
* `config.signer` is set the SDK uses it directly and reads no container.
|
|
36
|
+
*
|
|
37
|
+
* The credentials provider and sha256 come from a throwaway client, so the
|
|
38
|
+
* signer uses the same default chain and hash the SDK would have used.
|
|
39
|
+
* Same fix as `putKvsKey` in packages/cli/src/cli/aws.ts.
|
|
40
|
+
*/
|
|
41
|
+
export async function createKvsClient(region?: string): Promise<CloudFrontKeyValueStoreClient> {
|
|
42
|
+
const { SignatureV4a } = await import('@aws-sdk/signature-v4a');
|
|
43
|
+
const { CloudFrontKeyValueStoreClient } = await import('@aws-sdk/client-cloudfront-keyvaluestore');
|
|
44
|
+
|
|
45
|
+
const { credentials, sha256 } = new CloudFrontKeyValueStoreClient({ region }).config;
|
|
46
|
+
const signer = new SignatureV4a({
|
|
47
|
+
credentials,
|
|
48
|
+
region: '*',
|
|
49
|
+
service: 'cloudfront-keyvaluestore',
|
|
50
|
+
sha256,
|
|
51
|
+
});
|
|
52
|
+
// Same provider on the kept client, so there is one credential chain, not two.
|
|
53
|
+
return new CloudFrontKeyValueStoreClient({ region, signer, credentials });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One cached client promise per region. The credentials provider refreshes
|
|
58
|
+
* itself, so a long-lived client is safe; a build that fails is evicted so the
|
|
59
|
+
* next call tries again.
|
|
60
|
+
*/
|
|
61
|
+
const clients = new Map<string, Promise<CloudFrontKeyValueStoreClient>>();
|
|
62
|
+
|
|
63
|
+
function getKvsClient(region?: string): Promise<CloudFrontKeyValueStoreClient> {
|
|
64
|
+
const key = region ?? '';
|
|
65
|
+
let client = clients.get(key);
|
|
66
|
+
if (!client) {
|
|
67
|
+
client = createKvsClient(region);
|
|
68
|
+
const built = client;
|
|
69
|
+
built.catch(() => {
|
|
70
|
+
if (clients.get(key) === built) clients.delete(key);
|
|
71
|
+
});
|
|
72
|
+
clients.set(key, client);
|
|
73
|
+
}
|
|
74
|
+
return client;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Test-only: drop the cached KVS clients so the next purge builds a new one. */
|
|
78
|
+
export function resetKvsClientForTests(): void {
|
|
79
|
+
clients.clear();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
83
|
+
const BACKOFF_BASE_MS = 25;
|
|
84
|
+
const BACKOFF_CAP_MS = 400;
|
|
85
|
+
|
|
86
|
+
export interface InvalidateKvsOptions {
|
|
87
|
+
/** Wait between conflict retries. Injected by tests. */
|
|
88
|
+
sleep?: (ms: number) => Promise<void>;
|
|
89
|
+
/** Attempts per path before giving up. Default 5. */
|
|
90
|
+
maxAttempts?: number;
|
|
91
|
+
/**
|
|
92
|
+
* One EMF line per call, success and failure. `false` turns it off. A bad
|
|
93
|
+
* setting (more than 2 dimensions, a reserved name) never stops the purge:
|
|
94
|
+
* it logs one error line and the default metrics are used.
|
|
95
|
+
*/
|
|
96
|
+
metrics?: KvsMetricsSetting;
|
|
97
|
+
/**
|
|
98
|
+
* Region for the KVS client. Default: the SDK's own resolution. Give
|
|
99
|
+
* `kvsPurgeCheck` the same value so both test and use one client.
|
|
100
|
+
*/
|
|
101
|
+
region?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Called once on failure, before the KvsPurgeError is thrown. Never on
|
|
104
|
+
* success. If it throws or rejects, the KvsPurgeError is still thrown.
|
|
105
|
+
*/
|
|
106
|
+
onError?: (error: KvsPurgeError) => void | Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A purge that stopped part way. `unpurgedPaths` are the keys NOT written. */
|
|
110
|
+
export class KvsPurgeError extends Error {
|
|
111
|
+
readonly unpurgedPaths: string[];
|
|
112
|
+
readonly writtenPaths: string[];
|
|
113
|
+
|
|
114
|
+
constructor(
|
|
115
|
+
message: string,
|
|
116
|
+
details: { unpurgedPaths: string[]; writtenPaths: string[]; cause: unknown },
|
|
117
|
+
) {
|
|
118
|
+
super(message, { cause: details.cause });
|
|
119
|
+
this.name = 'KvsPurgeError';
|
|
120
|
+
this.unpurgedPaths = details.unpurgedPaths;
|
|
121
|
+
this.writtenPaths = details.writtenPaths;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A write refused because another writer moved the ETag first. The KVS API
|
|
127
|
+
* models this as ConflictException (409); 412 PreconditionFailed is matched
|
|
128
|
+
* too in case the service ever answers with the HTTP precondition status.
|
|
129
|
+
*/
|
|
130
|
+
function isEtagConflict(err: unknown): boolean {
|
|
131
|
+
const e = err as { name?: string; $metadata?: { httpStatusCode?: number } } | null;
|
|
132
|
+
const status = e?.$metadata?.httpStatusCode;
|
|
133
|
+
return status === 409 || status === 412
|
|
134
|
+
|| e?.name === 'ConflictException'
|
|
135
|
+
|| e?.name === 'PreconditionFailed'
|
|
136
|
+
|| e?.name === 'PreconditionFailedException';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function backoffMs(attempt: number): number {
|
|
140
|
+
const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1));
|
|
141
|
+
return ceiling / 2 + Math.random() * (ceiling / 2);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const realSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
12
145
|
|
|
13
146
|
/**
|
|
14
147
|
* Write a fresh timestamp to each KVS path, invalidating CloudFront caches.
|
|
15
148
|
*
|
|
16
|
-
* Each put
|
|
17
|
-
*
|
|
149
|
+
* Paths are deduped. Each put reads the current ETag and writes with IfMatch.
|
|
150
|
+
* When another writer moved the ETag first, that path re-reads and retries
|
|
151
|
+
* with a short jittered backoff. When a path cannot be written, or the client
|
|
152
|
+
* cannot be built, this throws a KvsPurgeError that lists the paths not
|
|
153
|
+
* written. Dynamically imports the AWS SDK to avoid bundling it when unused.
|
|
154
|
+
*
|
|
155
|
+
* Every call writes ONE EMF metric line after the retries are done, on success
|
|
156
|
+
* too (`KvsPurgeFailure` 0 or 1, `KvsPurgePaths`, `KvsPurgeUnpurgedPaths`), so
|
|
157
|
+
* a missing data point means the purge never ran, not that it was healthy.
|
|
18
158
|
*/
|
|
19
|
-
export async function invalidateKvsPaths(
|
|
159
|
+
export async function invalidateKvsPaths(
|
|
160
|
+
kvsArn: string,
|
|
161
|
+
paths: string[],
|
|
162
|
+
options: InvalidateKvsOptions = {},
|
|
163
|
+
): Promise<void> {
|
|
20
164
|
if (!kvsArn || paths.length === 0) return;
|
|
21
165
|
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
166
|
+
const metrics = resolveKvsMetricsOrDefault(options.metrics);
|
|
167
|
+
const sleep = options.sleep ?? realSleep;
|
|
168
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
169
|
+
const unique = [...new Set(paths)];
|
|
170
|
+
|
|
171
|
+
let written = 0;
|
|
172
|
+
try {
|
|
173
|
+
const {
|
|
174
|
+
DescribeKeyValueStoreCommand,
|
|
175
|
+
PutKeyCommand,
|
|
176
|
+
} = await import('@aws-sdk/client-cloudfront-keyvaluestore');
|
|
177
|
+
|
|
178
|
+
const client = await getKvsClient(options.region);
|
|
179
|
+
const value = String(Math.floor(Date.now() / 1000));
|
|
180
|
+
|
|
181
|
+
for (; written < unique.length; written++) {
|
|
182
|
+
for (let attempt = 1; ; attempt++) {
|
|
183
|
+
// The ETag moves after every put, ours or another writer's: read it fresh.
|
|
184
|
+
const desc = await client.send(new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }));
|
|
185
|
+
try {
|
|
186
|
+
await client.send(new PutKeyCommand({
|
|
187
|
+
KvsARN: kvsArn,
|
|
188
|
+
Key: unique[written],
|
|
189
|
+
Value: value,
|
|
190
|
+
IfMatch: desc.ETag!,
|
|
191
|
+
}));
|
|
192
|
+
break;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (!isEtagConflict(err) || attempt >= maxAttempts) throw err;
|
|
195
|
+
await sleep(backoffMs(attempt));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const unpurged = unique.slice(written);
|
|
201
|
+
const error = new KvsPurgeError(
|
|
202
|
+
`KVS purge stopped: ${written} of ${unique.length} paths written; `
|
|
203
|
+
+ `${unpurged.length} ${unpurged.length === 1 ? 'path' : 'paths'} not purged`,
|
|
204
|
+
{ unpurgedPaths: unpurged, writtenPaths: unique.slice(0, written), cause: err },
|
|
47
205
|
);
|
|
48
|
-
|
|
206
|
+
emitKvsMetrics(metrics, {
|
|
207
|
+
kvsArn,
|
|
208
|
+
metrics: { KvsPurgeFailure: 1, KvsPurgePaths: written, KvsPurgeUnpurgedPaths: unpurged.length },
|
|
209
|
+
unpurgedPaths: unpurged,
|
|
210
|
+
});
|
|
211
|
+
if (options.onError) {
|
|
212
|
+
try {
|
|
213
|
+
await options.onError(error);
|
|
214
|
+
} catch {
|
|
215
|
+
// The purge failure is the error the caller needs; a broken hook must not replace it.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
emitKvsMetrics(metrics, {
|
|
222
|
+
kvsArn,
|
|
223
|
+
metrics: { KvsPurgeFailure: 0, KvsPurgePaths: unique.length, KvsPurgeUnpurgedPaths: 0 },
|
|
224
|
+
unpurgedPaths: [],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const DEFAULT_CHECK_TIMEOUT_MS = 3000;
|
|
229
|
+
|
|
230
|
+
export interface KvsPurgeCheckOptions {
|
|
231
|
+
/** Give up after this many ms; a timeout is retryable. Default 3000. */
|
|
232
|
+
timeout?: number;
|
|
233
|
+
/**
|
|
234
|
+
* Region for the KVS client. Default: the SDK's own resolution. The check
|
|
235
|
+
* tests the same client the purges use only when both get the same region:
|
|
236
|
+
* none here and none on `invalidateKvsPaths`, or the same value on both.
|
|
237
|
+
*/
|
|
238
|
+
region?: string;
|
|
239
|
+
/** Same setting as `invalidateKvsPaths`, with the same bad-setting fallback. */
|
|
240
|
+
metrics?: KvsMetricsSetting;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export type KvsPurgeCheckResult =
|
|
244
|
+
| { ok: true }
|
|
245
|
+
| { ok: false; retryable: boolean; error: unknown };
|
|
246
|
+
|
|
247
|
+
class KvsCheckTimeoutError extends Error {
|
|
248
|
+
constructor(ms: number) {
|
|
249
|
+
super(`KVS purge check timed out after ${ms}ms`);
|
|
250
|
+
this.name = 'KvsCheckTimeoutError';
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const RETRYABLE_NAMES = new Set([
|
|
255
|
+
'Throttling',
|
|
256
|
+
'ThrottlingException',
|
|
257
|
+
'TooManyRequestsException',
|
|
258
|
+
'RequestTimeout',
|
|
259
|
+
'RequestTimeoutException',
|
|
260
|
+
'TimeoutError',
|
|
261
|
+
'KvsCheckTimeoutError',
|
|
262
|
+
]);
|
|
263
|
+
const NETWORK_CODES = new Set([
|
|
264
|
+
'ECONNRESET',
|
|
265
|
+
'ECONNREFUSED',
|
|
266
|
+
'ETIMEDOUT',
|
|
267
|
+
'EPIPE',
|
|
268
|
+
'ENOTFOUND',
|
|
269
|
+
'EAI_AGAIN',
|
|
270
|
+
'ENETUNREACH',
|
|
271
|
+
'EHOSTUNREACH',
|
|
272
|
+
'ECONNABORTED',
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Retryable: timeout, throttling, 429, 5xx, a server fault, an error the SDK
|
|
277
|
+
* marks retryable, network. Everything else (403, signer errors, validation,
|
|
278
|
+
* not found) is a broken purge path.
|
|
279
|
+
*/
|
|
280
|
+
function isRetryableCheckError(err: unknown): boolean {
|
|
281
|
+
const e = err as {
|
|
282
|
+
name?: string;
|
|
283
|
+
code?: string;
|
|
284
|
+
$fault?: string;
|
|
285
|
+
$retryable?: unknown;
|
|
286
|
+
$metadata?: { httpStatusCode?: number };
|
|
287
|
+
} | null;
|
|
288
|
+
const status = e?.$metadata?.httpStatusCode;
|
|
289
|
+
if (status === 429 || (status !== undefined && status >= 500)) return true;
|
|
290
|
+
if (e?.$fault === 'server' || e?.$retryable) return true;
|
|
291
|
+
if (e?.name && RETRYABLE_NAMES.has(e.name)) return true;
|
|
292
|
+
return !!e?.code && NETWORK_CODES.has(e.code);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* A read-only check that this Lambda can reach and sign for the store: ONE
|
|
297
|
+
* DescribeKeyValueStore through the cached client for `region`. With no
|
|
298
|
+
* `region`, that is the same client purges with no `region` use; pass the
|
|
299
|
+
* same `region` to both otherwise. It never writes, since a test write would
|
|
300
|
+
* change the store and race live purges. It never throws.
|
|
301
|
+
*
|
|
302
|
+
* Emits `KvsPurgeCheckFailure` 0 on a pass and 1 on a hard failure. A
|
|
303
|
+
* retryable failure emits nothing, so a cold-start burst does not page.
|
|
304
|
+
* Nothing calls it for you: call it from your Lambda `init` or a deploy smoke test.
|
|
305
|
+
*/
|
|
306
|
+
export async function kvsPurgeCheck(
|
|
307
|
+
kvsArn: string,
|
|
308
|
+
options: KvsPurgeCheckOptions = {},
|
|
309
|
+
): Promise<KvsPurgeCheckResult> {
|
|
310
|
+
const metrics = resolveKvsMetricsOrDefault(options.metrics);
|
|
311
|
+
|
|
312
|
+
const fail = (error: unknown): KvsPurgeCheckResult => {
|
|
313
|
+
const retryable = isRetryableCheckError(error);
|
|
314
|
+
if (!retryable) emitKvsMetrics(metrics, { kvsArn, metrics: { KvsPurgeCheckFailure: 1 } });
|
|
315
|
+
return { ok: false, retryable, error };
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
if (!kvsArn) return fail(new Error('kvsPurgeCheck: kvsArn is empty; purges would do nothing'));
|
|
319
|
+
|
|
320
|
+
const ms = options.timeout ?? DEFAULT_CHECK_TIMEOUT_MS;
|
|
321
|
+
const abort = new AbortController();
|
|
322
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
323
|
+
const timedOut = new Promise<never>((_, reject) => {
|
|
324
|
+
timer = setTimeout(() => {
|
|
325
|
+
// Reject first so the race settles on the timeout, not on the abort it causes.
|
|
326
|
+
reject(new KvsCheckTimeoutError(ms));
|
|
327
|
+
abort.abort();
|
|
328
|
+
}, ms);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const describe = async (): Promise<void> => {
|
|
332
|
+
const { DescribeKeyValueStoreCommand } = await import('@aws-sdk/client-cloudfront-keyvaluestore');
|
|
333
|
+
const client = await getKvsClient(options.region);
|
|
334
|
+
await client.send(new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }), { abortSignal: abort.signal });
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
await Promise.race([describe(), timedOut]);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
return fail(error);
|
|
341
|
+
} finally {
|
|
342
|
+
clearTimeout(timer);
|
|
49
343
|
}
|
|
344
|
+
|
|
345
|
+
emitKvsMetrics(metrics, { kvsArn, metrics: { KvsPurgeCheckFailure: 0 } });
|
|
346
|
+
return { ok: true };
|
|
50
347
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from './index';
|
|
38
38
|
import { splitSqlStatements, stripSqlComments } from './sql-lex';
|
|
39
39
|
import { DEFAULT_STRIP_QUERY_PARAMS, stripQueryParams } from './strip-query-params';
|
|
40
|
+
import { htmlCacheControl } from './cache-control';
|
|
40
41
|
|
|
41
42
|
// --- Plugin Types ---
|
|
42
43
|
|
|
@@ -316,7 +317,7 @@ export function createPluginLambdaHandler(
|
|
|
316
317
|
// GETs are version-keyed and can cache long at the edge.
|
|
317
318
|
const isHtml = String(result.headers['content-type'] || '').includes('text/html');
|
|
318
319
|
result.headers['cache-control'] = isHtml
|
|
319
|
-
? (options.cache
|
|
320
|
+
? htmlCacheControl(path, options.cache)
|
|
320
321
|
: (options.cache?.public ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5');
|
|
321
322
|
}
|
|
322
323
|
} else {
|
|
@@ -1103,6 +1104,11 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1103
1104
|
(payload || {}) as Parameters<typeof runProvision>[0],
|
|
1104
1105
|
{
|
|
1105
1106
|
execute: async (statement) => opsDb.execute(sql.raw(statement)),
|
|
1107
|
+
// Reads for the lifecycle role's existence and member report.
|
|
1108
|
+
query: async (statement) => {
|
|
1109
|
+
const res: any = await opsDb.execute(sql.raw(statement));
|
|
1110
|
+
return Array.isArray(res) ? res : (res?.rows ?? []);
|
|
1111
|
+
},
|
|
1106
1112
|
connection,
|
|
1107
1113
|
// Additive-only: an already-live login role keeps its password (never reset on re-provision).
|
|
1108
1114
|
roleHasLogin: async (role) => {
|