@sitblueprint/website-construct 0.1.4 → 0.1.6
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 +85 -0
- package/docs/CHANGELOG.md +26 -0
- package/lambda/index.d.ts +11 -0
- package/lambda/index.js +238 -0
- package/lambda/index.ts +335 -0
- package/lib/index.d.ts +44 -0
- package/lib/index.js +173 -10
- package/lib/index.ts +250 -8
- package/package.json +5 -5
- package/test/website-construct.test.js +212 -8
- package/test/website-construct.test.ts +229 -0
package/lambda/index.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
type APIGatewayProxyEvent = {
|
|
2
|
+
body: string | null;
|
|
3
|
+
path: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
type APIGatewayProxyResult = {
|
|
7
|
+
statusCode: number;
|
|
8
|
+
headers: Record<string, string>;
|
|
9
|
+
body: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const AWS = require("aws-sdk");
|
|
13
|
+
|
|
14
|
+
type SlotDefinition = {
|
|
15
|
+
slotId: number;
|
|
16
|
+
bucketName: string;
|
|
17
|
+
previewUrl: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type SlotLease = {
|
|
21
|
+
slotId: string;
|
|
22
|
+
repoPrKey: string;
|
|
23
|
+
bucketName: string;
|
|
24
|
+
previewUrl: string;
|
|
25
|
+
lastUsedAt?: number;
|
|
26
|
+
leaseExpiresAt?: number;
|
|
27
|
+
commitSha?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ddb = new AWS.DynamoDB.DocumentClient();
|
|
31
|
+
const tableName = process.env.TABLE_NAME ?? "";
|
|
32
|
+
const slotDefinitions: SlotDefinition[] = JSON.parse(
|
|
33
|
+
process.env.SLOT_DEFINITIONS ?? "[]",
|
|
34
|
+
);
|
|
35
|
+
const maxLeaseMs = Number(process.env.MAX_LEASE_MS ?? "86400000");
|
|
36
|
+
|
|
37
|
+
const ok = (body: Record<string, unknown>): APIGatewayProxyResult => ({
|
|
38
|
+
statusCode: 200,
|
|
39
|
+
headers: { "content-type": "application/json" },
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const badRequest = (message: string): APIGatewayProxyResult => ({
|
|
44
|
+
statusCode: 400,
|
|
45
|
+
headers: { "content-type": "application/json" },
|
|
46
|
+
body: JSON.stringify({ error: message }),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const conflict = (message: string): APIGatewayProxyResult => ({
|
|
50
|
+
statusCode: 409,
|
|
51
|
+
headers: { "content-type": "application/json" },
|
|
52
|
+
body: JSON.stringify({ error: message }),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const toRepoPrKey = (repo: string, prNumber: number): string =>
|
|
56
|
+
`${repo}#${prNumber}`;
|
|
57
|
+
|
|
58
|
+
const nowMs = (): number => Date.now();
|
|
59
|
+
const nowEpochSeconds = (): number => Math.floor(Date.now() / 1000);
|
|
60
|
+
|
|
61
|
+
const isConditionalCheckFailure = (error: unknown): boolean => {
|
|
62
|
+
if (!(error instanceof Error)) return false;
|
|
63
|
+
return error.name === "ConditionalCheckFailedException";
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const parseBody = (event: APIGatewayProxyEvent): Record<string, unknown> => {
|
|
67
|
+
if (!event.body) return {};
|
|
68
|
+
return JSON.parse(event.body) as Record<string, unknown>;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const getClaimBody = (body: Record<string, unknown>) => {
|
|
72
|
+
const repo = typeof body.repo === "string" ? body.repo : "";
|
|
73
|
+
const prNumber = Number(body.prNumber);
|
|
74
|
+
const commitSha = typeof body.commitSha === "string" ? body.commitSha : "";
|
|
75
|
+
return { repo, prNumber, commitSha };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const getReleaseBody = (body: Record<string, unknown>) => {
|
|
79
|
+
const repo = typeof body.repo === "string" ? body.repo : "";
|
|
80
|
+
const prNumber = Number(body.prNumber);
|
|
81
|
+
return { repo, prNumber };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const queryLeaseByRepoPr = async (
|
|
85
|
+
repoPrKey: string,
|
|
86
|
+
): Promise<SlotLease | null> => {
|
|
87
|
+
const result = await ddb
|
|
88
|
+
.query({
|
|
89
|
+
TableName: tableName,
|
|
90
|
+
IndexName: "RepoPrKeyIndex",
|
|
91
|
+
KeyConditionExpression: "repoPrKey = :repoPrKey",
|
|
92
|
+
ExpressionAttributeValues: {
|
|
93
|
+
":repoPrKey": repoPrKey,
|
|
94
|
+
},
|
|
95
|
+
Limit: 1,
|
|
96
|
+
})
|
|
97
|
+
.promise();
|
|
98
|
+
|
|
99
|
+
if (!result.Items || result.Items.length === 0) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return result.Items[0] as SlotLease;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const loadSlots = async (): Promise<
|
|
107
|
+
Array<SlotDefinition & { lease: SlotLease | null }>
|
|
108
|
+
> => {
|
|
109
|
+
if (slotDefinitions.length === 0) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const result = await ddb
|
|
114
|
+
.batchGet({
|
|
115
|
+
RequestItems: {
|
|
116
|
+
[tableName]: {
|
|
117
|
+
Keys: slotDefinitions.map((slot) => ({
|
|
118
|
+
slotId: String(slot.slotId),
|
|
119
|
+
})),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
.promise();
|
|
124
|
+
|
|
125
|
+
const tableItems = (result.Responses?.[tableName] ?? []) as SlotLease[];
|
|
126
|
+
const bySlotId = new Map<string, SlotLease>();
|
|
127
|
+
tableItems.forEach((item) => bySlotId.set(item.slotId, item));
|
|
128
|
+
|
|
129
|
+
return slotDefinitions.map((slot) => ({
|
|
130
|
+
...slot,
|
|
131
|
+
lease: bySlotId.get(String(slot.slotId)) ?? null,
|
|
132
|
+
}));
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const chooseSlot = (
|
|
136
|
+
slots: Array<SlotDefinition & { lease: SlotLease | null }>,
|
|
137
|
+
repoPrKey: string,
|
|
138
|
+
now: number,
|
|
139
|
+
): {
|
|
140
|
+
slot: SlotDefinition & { lease: SlotLease | null };
|
|
141
|
+
expectedLastUsedAt: number | null;
|
|
142
|
+
} => {
|
|
143
|
+
const existing = slots.find((slot) => slot.lease?.repoPrKey === repoPrKey);
|
|
144
|
+
if (existing) {
|
|
145
|
+
return {
|
|
146
|
+
slot: existing,
|
|
147
|
+
expectedLastUsedAt: Number(existing.lease?.lastUsedAt ?? 0),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const available = slots
|
|
152
|
+
.filter(
|
|
153
|
+
(slot) => !slot.lease || Number(slot.lease.leaseExpiresAt ?? 0) < now,
|
|
154
|
+
)
|
|
155
|
+
.sort(
|
|
156
|
+
(a, b) =>
|
|
157
|
+
Number(a.lease?.lastUsedAt ?? 0) - Number(b.lease?.lastUsedAt ?? 0),
|
|
158
|
+
);
|
|
159
|
+
if (available.length > 0) {
|
|
160
|
+
return {
|
|
161
|
+
slot: available[0],
|
|
162
|
+
expectedLastUsedAt: available[0].lease
|
|
163
|
+
? Number(available[0].lease.lastUsedAt ?? 0)
|
|
164
|
+
: null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const lru = [...slots].sort(
|
|
169
|
+
(a, b) =>
|
|
170
|
+
Number(a.lease?.lastUsedAt ?? 0) - Number(b.lease?.lastUsedAt ?? 0),
|
|
171
|
+
)[0];
|
|
172
|
+
return {
|
|
173
|
+
slot: lru,
|
|
174
|
+
expectedLastUsedAt: Number(lru.lease?.lastUsedAt ?? 0),
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const claim = async (
|
|
179
|
+
repo: string,
|
|
180
|
+
prNumber: number,
|
|
181
|
+
commitSha: string,
|
|
182
|
+
): Promise<APIGatewayProxyResult> => {
|
|
183
|
+
if (!repo || !Number.isInteger(prNumber)) {
|
|
184
|
+
return badRequest("repo and integer prNumber are required");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const repoPrKey = toRepoPrKey(repo, prNumber);
|
|
188
|
+
for (let attempts = 0; attempts < 5; attempts += 1) {
|
|
189
|
+
const now = nowMs();
|
|
190
|
+
const slots = await loadSlots();
|
|
191
|
+
if (slots.length === 0) {
|
|
192
|
+
return badRequest("No preview slots configured");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const selection = chooseSlot(slots, repoPrKey, now);
|
|
196
|
+
const slot = selection.slot;
|
|
197
|
+
|
|
198
|
+
const expressionAttributeValues: Record<string, unknown> = {
|
|
199
|
+
":repo": repo,
|
|
200
|
+
":prNumber": prNumber,
|
|
201
|
+
":repoPrKey": repoPrKey,
|
|
202
|
+
":commitSha": commitSha,
|
|
203
|
+
":now": now,
|
|
204
|
+
":expiresAt": now + maxLeaseMs,
|
|
205
|
+
":ttlEpochSeconds": nowEpochSeconds() + Math.floor(maxLeaseMs / 1000),
|
|
206
|
+
":bucketName": slot.bucketName,
|
|
207
|
+
":previewUrl": slot.previewUrl,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
let conditionExpression = "attribute_not_exists(slotId)";
|
|
211
|
+
if (selection.expectedLastUsedAt !== null) {
|
|
212
|
+
conditionExpression =
|
|
213
|
+
"lastUsedAt = :expectedLastUsedAt OR repoPrKey = :repoPrKey";
|
|
214
|
+
expressionAttributeValues[":expectedLastUsedAt"] =
|
|
215
|
+
selection.expectedLastUsedAt;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await ddb
|
|
220
|
+
.update({
|
|
221
|
+
TableName: tableName,
|
|
222
|
+
Key: { slotId: String(slot.slotId) },
|
|
223
|
+
UpdateExpression:
|
|
224
|
+
"SET repo = :repo, prNumber = :prNumber, repoPrKey = :repoPrKey, commitSha = :commitSha, bucketName = :bucketName, previewUrl = :previewUrl, leasedAt = if_not_exists(leasedAt, :now), lastUsedAt = :now, leaseExpiresAt = :expiresAt, ttlEpochSeconds = :ttlEpochSeconds",
|
|
225
|
+
ConditionExpression: conditionExpression,
|
|
226
|
+
ExpressionAttributeValues: expressionAttributeValues,
|
|
227
|
+
})
|
|
228
|
+
.promise();
|
|
229
|
+
|
|
230
|
+
return ok({
|
|
231
|
+
slotId: slot.slotId,
|
|
232
|
+
bucketName: slot.bucketName,
|
|
233
|
+
previewUrl: slot.previewUrl,
|
|
234
|
+
});
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (!isConditionalCheckFailure(error)) {
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return conflict("Failed to claim preview slot due to concurrent updates");
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const heartbeat = async (
|
|
246
|
+
repo: string,
|
|
247
|
+
prNumber: number,
|
|
248
|
+
commitSha: string,
|
|
249
|
+
): Promise<APIGatewayProxyResult> => {
|
|
250
|
+
if (!repo || !Number.isInteger(prNumber)) {
|
|
251
|
+
return badRequest("repo and integer prNumber are required");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const repoPrKey = toRepoPrKey(repo, prNumber);
|
|
255
|
+
const existing = await queryLeaseByRepoPr(repoPrKey);
|
|
256
|
+
if (!existing) {
|
|
257
|
+
return badRequest("No active lease found for this pull request");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const now = nowMs();
|
|
261
|
+
await ddb
|
|
262
|
+
.update({
|
|
263
|
+
TableName: tableName,
|
|
264
|
+
Key: { slotId: existing.slotId },
|
|
265
|
+
UpdateExpression:
|
|
266
|
+
"SET commitSha = :commitSha, lastUsedAt = :now, leaseExpiresAt = :expiresAt, ttlEpochSeconds = :ttlEpochSeconds",
|
|
267
|
+
ConditionExpression: "repoPrKey = :repoPrKey",
|
|
268
|
+
ExpressionAttributeValues: {
|
|
269
|
+
":repoPrKey": repoPrKey,
|
|
270
|
+
":commitSha": commitSha || existing.commitSha || "",
|
|
271
|
+
":now": now,
|
|
272
|
+
":expiresAt": now + maxLeaseMs,
|
|
273
|
+
":ttlEpochSeconds": nowEpochSeconds() + Math.floor(maxLeaseMs / 1000),
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
.promise();
|
|
277
|
+
|
|
278
|
+
return ok({
|
|
279
|
+
slotId: existing.slotId,
|
|
280
|
+
bucketName: existing.bucketName,
|
|
281
|
+
previewUrl: existing.previewUrl,
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const release = async (
|
|
286
|
+
repo: string,
|
|
287
|
+
prNumber: number,
|
|
288
|
+
): Promise<APIGatewayProxyResult> => {
|
|
289
|
+
if (!repo || !Number.isInteger(prNumber)) {
|
|
290
|
+
return badRequest("repo and integer prNumber are required");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const repoPrKey = toRepoPrKey(repo, prNumber);
|
|
294
|
+
const existing = await queryLeaseByRepoPr(repoPrKey);
|
|
295
|
+
if (!existing) {
|
|
296
|
+
return ok({ released: false });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await ddb
|
|
300
|
+
.delete({
|
|
301
|
+
TableName: tableName,
|
|
302
|
+
Key: { slotId: existing.slotId },
|
|
303
|
+
ConditionExpression: "repoPrKey = :repoPrKey",
|
|
304
|
+
ExpressionAttributeValues: {
|
|
305
|
+
":repoPrKey": repoPrKey,
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
.promise();
|
|
309
|
+
|
|
310
|
+
return ok({ released: true, slotId: existing.slotId });
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
export const handler = async (
|
|
314
|
+
event: APIGatewayProxyEvent,
|
|
315
|
+
): Promise<APIGatewayProxyResult> => {
|
|
316
|
+
const body = parseBody(event);
|
|
317
|
+
const path = event.path ?? "";
|
|
318
|
+
|
|
319
|
+
if (path.endsWith("/claim")) {
|
|
320
|
+
const { repo, prNumber, commitSha } = getClaimBody(body);
|
|
321
|
+
return claim(repo, prNumber, commitSha);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (path.endsWith("/heartbeat")) {
|
|
325
|
+
const { repo, prNumber, commitSha } = getClaimBody(body);
|
|
326
|
+
return heartbeat(repo, prNumber, commitSha);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (path.endsWith("/release")) {
|
|
330
|
+
const { repo, prNumber } = getReleaseBody(body);
|
|
331
|
+
return release(repo, prNumber);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return badRequest("Unsupported route");
|
|
335
|
+
};
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
2
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
3
3
|
import * as cloudfont from "aws-cdk-lib/aws-cloudfront";
|
|
4
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
|
|
6
|
+
import * as apigateway from "aws-cdk-lib/aws-apigateway";
|
|
4
7
|
export interface DomainConfig {
|
|
5
8
|
/** The root domain name (e.g., example.com).
|
|
6
9
|
* There must be an associated hosted zone in Route 53 for this domain.
|
|
@@ -10,6 +13,11 @@ export interface DomainConfig {
|
|
|
10
13
|
subdomainName: string;
|
|
11
14
|
/** The ARN of the SSL certificate to use for the domain. */
|
|
12
15
|
certificateArn: string;
|
|
16
|
+
/**
|
|
17
|
+
* If true, creates an additional Route 53 record for the root domain pointing to the CloudFront distribution.
|
|
18
|
+
* @default false
|
|
19
|
+
*/
|
|
20
|
+
includeRootDomain?: boolean;
|
|
13
21
|
}
|
|
14
22
|
export interface WebsiteProps {
|
|
15
23
|
/** The name of the S3 bucket that will host the website content. */
|
|
@@ -22,11 +30,47 @@ export interface WebsiteProps {
|
|
|
22
30
|
domainConfig?: DomainConfig;
|
|
23
31
|
/** Optional path to a custom 404 page. If not specified, the error file will be used. */
|
|
24
32
|
notFoundResponsePagePath?: string;
|
|
33
|
+
/** Optional configuration for pull request preview environments. */
|
|
34
|
+
previewConfig?: PreviewConfig;
|
|
35
|
+
}
|
|
36
|
+
export interface PreviewConfig {
|
|
37
|
+
/** Prefix used to name preview buckets. Buckets are created as `${prefix}-0`, `${prefix}-1`, ... */
|
|
38
|
+
bucketPrefix: string;
|
|
39
|
+
/** Number of preview buckets to create.
|
|
40
|
+
* @default 2
|
|
41
|
+
*/
|
|
42
|
+
bucketCount?: number;
|
|
43
|
+
/** If true, creates one CloudFront distribution per preview bucket.
|
|
44
|
+
* @default true
|
|
45
|
+
*/
|
|
46
|
+
createDistributions?: boolean;
|
|
47
|
+
/** Maximum lease lifetime in hours before a slot is considered expired.
|
|
48
|
+
* @default 24
|
|
49
|
+
*/
|
|
50
|
+
maxLeaseHours?: number;
|
|
51
|
+
}
|
|
52
|
+
export interface PreviewEnvironmentProps extends PreviewConfig {
|
|
53
|
+
/** Index document for preview buckets. */
|
|
54
|
+
indexFile: string;
|
|
55
|
+
/** Error document for preview buckets. */
|
|
56
|
+
errorFile: string;
|
|
25
57
|
}
|
|
26
58
|
export declare class Website extends Construct {
|
|
27
59
|
readonly bucket: s3.Bucket;
|
|
28
60
|
readonly distribution: cloudfont.Distribution;
|
|
61
|
+
readonly previewEnvironment?: PreviewEnvironment;
|
|
29
62
|
constructor(scope: Construct, id: string, props: WebsiteProps);
|
|
30
63
|
private _getFullDomainName;
|
|
31
64
|
private _getCertificate;
|
|
32
65
|
}
|
|
66
|
+
export declare class PreviewEnvironment extends Construct {
|
|
67
|
+
readonly buckets: s3.Bucket[];
|
|
68
|
+
readonly distributions: cloudfont.Distribution[];
|
|
69
|
+
readonly leaseTable: dynamodb.Table;
|
|
70
|
+
readonly api: apigateway.RestApi;
|
|
71
|
+
readonly claimEndpoint: string;
|
|
72
|
+
readonly heartbeatEndpoint: string;
|
|
73
|
+
readonly releaseEndpoint: string;
|
|
74
|
+
constructor(scope: Construct, id: string, props: PreviewEnvironmentProps);
|
|
75
|
+
grantDeploymentAccess(grantee: iam.IGrantable): void;
|
|
76
|
+
}
|