@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.
@@ -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
+ }