sst 2.29.2 → 2.30.2

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.
@@ -8,10 +8,20 @@ export const types = (program) => program.command("types", "Generate resource ty
8
8
  try {
9
9
  const project = useProject();
10
10
  const [_metafile, sstConfig] = await Stacks.load(project.paths.config);
11
- await Stacks.synth({
12
- fn: sstConfig.stacks,
11
+ // Note: do not run synth which requires AWS credentials. B/c generating
12
+ // types is usually done inside CI pipelines. And credentials
13
+ // might not be available. ie.
14
+ // await Stacks.synth({
15
+ // fn: sstConfig.stacks,
16
+ // mode: "remove",
17
+ // });
18
+ const app = new App({
13
19
  mode: "remove",
20
+ stage: project.config.stage,
21
+ name: project.config.name,
22
+ region: project.config.region,
14
23
  });
24
+ sstConfig.stacks(app);
15
25
  Colors.line(Colors.success(`✔ `), `Types generated in ${path.resolve(project.paths.out, "types")}`);
16
26
  await exit();
17
27
  }
@@ -1,9 +1,18 @@
1
1
  import { ErrorResponse, DistributionProps, BehaviorOptions, IOrigin } from "aws-cdk-lib/aws-cloudfront";
2
2
  export interface BaseSiteFileOptions {
3
- exclude: string | string[];
4
- include: string | string[];
5
- cacheControl: string;
3
+ filters: {
4
+ [key in "include" | "exclude"]?: string;
5
+ }[];
6
+ cacheControl?: string;
6
7
  contentType?: string;
8
+ contentEncoding?: string;
9
+ }
10
+ export interface BaseSiteFileOptionsDeprecated {
11
+ include?: string | string[];
12
+ exclude?: string | string[];
13
+ cacheControl?: string;
14
+ contentType?: string;
15
+ contentEncoding?: string;
7
16
  }
8
17
  export interface BaseSiteEnvironmentOutputsInfo {
9
18
  path: string;
@@ -132,7 +132,7 @@ export declare class Distribution extends Construct {
132
132
  hostedZone: IHostedZone | undefined;
133
133
  certificate: ICertificate | undefined;
134
134
  };
135
- createInvalidation(buildId?: string): CustomResource;
135
+ createInvalidation(buildId?: string, paths?: string[]): CustomResource;
136
136
  private validateCloudFrontDistributionSettings;
137
137
  private validateCustomDomainSettings;
138
138
  private lookupHostedZone;
@@ -70,7 +70,7 @@ export class Distribution extends Construct {
70
70
  certificate: this.certificate,
71
71
  };
72
72
  }
73
- createInvalidation(buildId) {
73
+ createInvalidation(buildId, paths) {
74
74
  const stack = Stack.of(this);
75
75
  const policy = new Policy(this.scope, "CloudFrontInvalidatorPolicy", {
76
76
  statements: [
@@ -93,7 +93,7 @@ export class Distribution extends Construct {
93
93
  properties: {
94
94
  buildId: buildId || Date.now().toString(),
95
95
  distributionId: this.distribution.distributionId,
96
- paths: ["/*"],
96
+ paths: paths ?? ["/*"],
97
97
  waitForInvalidation: this.props.waitForInvalidation,
98
98
  },
99
99
  });
@@ -3,7 +3,17 @@ import { Runtime, FunctionProps, Architecture } from "aws-cdk-lib/aws-lambda";
3
3
  import { SsrSite, SsrSiteNormalizedProps, SsrSiteProps } from "./SsrSite.js";
4
4
  import { Size } from "./util/size.js";
5
5
  import { Bucket } from "aws-cdk-lib/aws-s3";
6
+ import { CachePolicyProps } from "aws-cdk-lib/aws-cloudfront";
6
7
  export interface NextjsSiteProps extends Omit<SsrSiteProps, "nodejs"> {
8
+ /**
9
+ * OpenNext version for building the Next.js site.
10
+ * @default Latest OpenNext version
11
+ * @example
12
+ * ```js
13
+ * openNextVersion: "2.2.4",
14
+ * ```
15
+ */
16
+ openNextVersion?: string;
7
17
  imageOptimization?: {
8
18
  /**
9
19
  * The amount of memory in MB allocated for image optimization function.
@@ -99,8 +109,8 @@ type NextjsSiteNormalizedProps = NextjsSiteProps & SsrSiteNormalizedProps;
99
109
  */
100
110
  export declare class NextjsSite extends SsrSite {
101
111
  props: NextjsSiteNormalizedProps;
102
- private buildId?;
103
112
  constructor(scope: Construct, id: string, props?: NextjsSiteProps);
113
+ static buildDefaultServerCachePolicyProps(): CachePolicyProps;
104
114
  protected plan(bucket: Bucket): {
105
115
  cloudFrontFunctions?: {
106
116
  serverCfFunction: {
@@ -12,6 +12,14 @@ import { toCdkSize } from "./util/size.js";
12
12
  import { PolicyStatement } from "aws-cdk-lib/aws-iam";
13
13
  import { RetentionDays } from "aws-cdk-lib/aws-logs";
14
14
  import { VisibleError } from "../error.js";
15
+ const DEFAULT_OPEN_NEXT_VERSION = "2.2.4";
16
+ const DEFAULT_CACHE_POLICY_ALLOWED_HEADERS = [
17
+ "accept",
18
+ "rsc",
19
+ "next-router-prefetch",
20
+ "next-router-state-tree",
21
+ "next-url",
22
+ ];
15
23
  /**
16
24
  * The `NextjsSite` construct is a higher level CDK construct that makes it easy to create a Next.js app.
17
25
  * @example
@@ -24,7 +32,6 @@ import { VisibleError } from "../error.js";
24
32
  * ```
25
33
  */
26
34
  export class NextjsSite extends SsrSite {
27
- buildId;
28
35
  constructor(scope, id, props) {
29
36
  const { streaming, disableDynamoDBCache, disableIncrementalCache } = {
30
37
  streaming: false,
@@ -34,7 +41,10 @@ export class NextjsSite extends SsrSite {
34
41
  };
35
42
  super(scope, id, {
36
43
  buildCommand: [
37
- "npx --yes open-next@2.2.3 build",
44
+ "npx",
45
+ "--yes",
46
+ `open-next@${props?.openNextVersion ?? DEFAULT_OPEN_NEXT_VERSION}`,
47
+ "build",
38
48
  ...(streaming ? ["--streaming"] : []),
39
49
  ...(disableDynamoDBCache
40
50
  ? ["--dangerously-disable-dynamodb-cache"]
@@ -52,6 +62,9 @@ export class NextjsSite extends SsrSite {
52
62
  }
53
63
  }
54
64
  }
65
+ static buildDefaultServerCachePolicyProps() {
66
+ return super.buildDefaultServerCachePolicyProps(DEFAULT_CACHE_POLICY_ALLOWED_HEADERS);
67
+ }
55
68
  plan(bucket) {
56
69
  const { path: sitePath, edge, experimental, imageOptimization, } = this.props;
57
70
  const serverConfig = {
@@ -184,13 +197,7 @@ export class NextjsSite extends SsrSite {
184
197
  origin: "s3",
185
198
  })),
186
199
  ],
187
- cachePolicyAllowedHeaders: [
188
- "accept",
189
- "rsc",
190
- "next-router-prefetch",
191
- "next-router-state-tree",
192
- "next-url",
193
- ],
200
+ cachePolicyAllowedHeaders: DEFAULT_CACHE_POLICY_ALLOWED_HEADERS,
194
201
  buildId: this.getBuildId(),
195
202
  warmerConfig: {
196
203
  function: path.join(sitePath, ".open-next", "warmer-function"),
@@ -287,15 +294,22 @@ export class NextjsSite extends SsrSite {
287
294
  };
288
295
  }
289
296
  wrapHandler() {
290
- const { path: sitePath } = this.props;
297
+ const { path: sitePath, experimental } = this.props;
291
298
  const wrapperName = "nextjssite-index";
292
299
  const serverPath = path.join(sitePath, ".open-next", "server-function");
293
- fs.writeFileSync(path.join(serverPath, `${wrapperName}.mjs`), [
294
- `import { handler as rawHandler } from "./index.mjs";`,
295
- `export const handler = (event, context) => {`,
296
- ` return rawHandler(event, context);`,
297
- `};`,
298
- ].join("\n"));
300
+ fs.writeFileSync(path.join(serverPath, `${wrapperName}.mjs`), experimental?.streaming
301
+ ? [
302
+ `export const handler = awslambda.streamifyResponse(async (...args) => {`,
303
+ ` const { handler: rawHandler} = await import("./index.mjs");`,
304
+ ` return rawHandler(...args);`,
305
+ `});`,
306
+ ].join("\n")
307
+ : [
308
+ `export const handler = async (...args) => {`,
309
+ ` const { handler: rawHandler} = await import("./index.mjs");`,
310
+ ` return rawHandler(...args);`,
311
+ `};`,
312
+ ].join("\n"));
299
313
  return `${wrapperName}.handler`;
300
314
  }
301
315
  getRoutes() {
@@ -51,8 +51,7 @@ export interface ScriptProps {
51
51
  function?: FunctionProps;
52
52
  };
53
53
  /**
54
- * Creates the function that runs when the Script is created.
55
- *
54
+ * Specifies the function to be run once when the Script construct is created.
56
55
  * @example
57
56
  * ```js
58
57
  * new Script(stack, "Api", {
@@ -62,8 +61,11 @@ export interface ScriptProps {
62
61
  */
63
62
  onCreate?: FunctionDefinition;
64
63
  /**
65
- * Creates the function that runs on every deploy after the Script is created
64
+ * Specifies the function to be run each time the Script construct is redeployed. If a version is provided,
65
+ * the function is only executed when the version changes.
66
66
  *
67
+ * Note that the `onUpdate` function is not run during the initial creation of the Script construct.
68
+ * For initial creation, use `onCreate`.
67
69
  * @example
68
70
  * ```js
69
71
  * new Script(stack, "Api", {
@@ -73,8 +75,8 @@ export interface ScriptProps {
73
75
  */
74
76
  onUpdate?: FunctionDefinition;
75
77
  /**
76
- * Create the function that runs when the Script is deleted from the stack.
77
- *
78
+ * Specifies the function to be run once when the Script construct is deleted from the stack or
79
+ * when the entire stack is removed from the app.
78
80
  * @example
79
81
  * ```js
80
82
  * new Script(stack, "Api", {
@@ -1,14 +1,14 @@
1
1
  import { Construct } from "constructs";
2
2
  import { Bucket, BucketProps, IBucket } from "aws-cdk-lib/aws-s3";
3
3
  import { Function as CdkFunction, FunctionProps as CdkFunctionProps } from "aws-cdk-lib/aws-lambda";
4
- import { ICachePolicy, IResponseHeadersPolicy, AllowedMethods, ErrorResponse } from "aws-cdk-lib/aws-cloudfront";
4
+ import { ICachePolicy, IResponseHeadersPolicy, AllowedMethods, CachePolicyProps, ErrorResponse } from "aws-cdk-lib/aws-cloudfront";
5
5
  import { Schedule } from "aws-cdk-lib/aws-events";
6
6
  import { DistributionDomainProps } from "./Distribution.js";
7
7
  import { SSTConstruct } from "./Construct.js";
8
8
  import { NodeJSProps, FunctionProps } from "./Function.js";
9
9
  import { SsrFunction, SsrFunctionProps } from "./SsrFunction.js";
10
10
  import { EdgeFunction, EdgeFunctionProps } from "./EdgeFunction.js";
11
- import { BaseSiteFileOptions, BaseSiteReplaceProps, BaseSiteCdkDistributionProps } from "./BaseSite.js";
11
+ import { BaseSiteFileOptions, BaseSiteFileOptionsDeprecated, BaseSiteReplaceProps, BaseSiteCdkDistributionProps } from "./BaseSite.js";
12
12
  import { Size } from "./util/size.js";
13
13
  import { Duration } from "./util/duration.js";
14
14
  import { Permissions } from "./util/permission.js";
@@ -54,6 +54,8 @@ export interface SsrDomainProps extends DistributionDomainProps {
54
54
  }
55
55
  export interface SsrSiteFileOptions extends BaseSiteFileOptions {
56
56
  }
57
+ export interface SsrSiteFileOptionsDeprecated extends BaseSiteFileOptionsDeprecated {
58
+ }
57
59
  export interface SsrSiteReplaceProps extends BaseSiteReplaceProps {
58
60
  }
59
61
  export interface SsrCdkDistributionProps extends BaseSiteCdkDistributionProps {
@@ -202,6 +204,93 @@ export interface SsrSiteProps {
202
204
  */
203
205
  url?: string;
204
206
  };
207
+ cache?: {
208
+ /**
209
+ * Character encoding for text based assets stored in the S3 cache (ex: html, css, js, etc.). If "none" is specified, no charset will be returned in header.
210
+ * @default utf-8
211
+ * @example
212
+ * ```js
213
+ * cache: {
214
+ * textEncoding: "iso-8859-1"
215
+ * }
216
+ * ```
217
+ */
218
+ textEncoding?: "UTF-8" | "ISO-8859-1" | "Windows-1252" | "ASCII" | "none";
219
+ /**
220
+ * The strategy to use for invalidating the CDN cache. By default, the CDN cache will invalidate on changes any cached file, but this could become slow on very large projects.
221
+ * - "never" - No invalidation will be performed.
222
+ * - "all" - All files will be invalidated when any file changes. (Default, requires checking file content which will increase deployment time)
223
+ * - "versioned" - Only versioned files will be invalidated when versioned files change.
224
+ * - "always" - All files are invalidated on every deployment.
225
+ * @default all
226
+ * @example
227
+ * ```js
228
+ * cache: {
229
+ * cdnInvalidationStrategy: "versioned"
230
+ * }
231
+ * ```
232
+ */
233
+ cdnInvalidationStrategy?: "never" | "all" | "versioned" | "always";
234
+ /**
235
+ * The TTL for versioned files (ex: `main-1234.css`) in the CDN and browser cache. Ignored when `versionedFilesCacheHeader` is specified.
236
+ * @default 1 year
237
+ * @example
238
+ * ```js
239
+ * cache: {
240
+ * versionedFilesTTL: "30 days"
241
+ * }
242
+ * ```
243
+ */
244
+ versionedFilesTTL?: number | Duration;
245
+ /**
246
+ * The header to use for versioned files (ex: `main-1234.css`) in the CDN cache. When specified, the `versionedFilesTTL` option is ignored.
247
+ * @default public,max-age=31536000,immutable
248
+ * @example
249
+ * ```js
250
+ * cache: {
251
+ * versionedFilesCacheHeader: "public,max-age=31536000,immutable"
252
+ * }
253
+ * ```
254
+ */
255
+ versionedFilesCacheHeader?: string;
256
+ /**
257
+ * The TTL for non-versioned files (ex: `index.html`) in the CDN cache. Ignored when `nonVersionedFilesCacheHeader` is specified.
258
+ * @default 1 day
259
+ * @example
260
+ * ```js
261
+ * cache: {
262
+ * nonVersionedFilesTTL: "4 hours"
263
+ * }
264
+ * ```
265
+ */
266
+ nonVersionedFilesTTL?: number | Duration;
267
+ /**
268
+ * The header to use for non-versioned files (ex: `index.html`) in the CDN cache. When specified, the `nonVersionedFilesTTL` option is ignored.
269
+ * @default public,max-age=0,s-maxage=86400,stale-while-revalidate=8640
270
+ * @example
271
+ * ```js
272
+ * cache: {
273
+ * nonVersionedFilesCacheHeader: "public,max-age=0,no-cache"
274
+ * }
275
+ * ```
276
+ */
277
+ nonVersionedFilesCacheHeader?: string;
278
+ /**
279
+ * List of file options to specify cache control and content type for cached files. These file options are appended to the default file options so it's possible to override the default file options by specifying an overlapping file pattern.
280
+ * @example
281
+ * ```js
282
+ * cache: {
283
+ * fileOptions: [
284
+ * {
285
+ * filters: [ { exclude: "*" }, { include: "*.zip" } ],
286
+ * cacheControl: "private,no-cache,no-store,must-revalidate",
287
+ * contentType: "application/zip",
288
+ * },
289
+ * ],
290
+ * }
291
+ */
292
+ fileOptions?: SsrSiteFileOptions[];
293
+ };
205
294
  /**
206
295
  * While deploying, SST waits for the CloudFront cache invalidation process to finish. This ensures that the new content will be served once the deploy command finishes. However, this process can sometimes take more than 5 mins. For non-prod environments it might make sense to pass in `false`. That'll skip waiting for the cache to invalidate and speed up the deploy process.
207
296
  * @default false
@@ -251,11 +340,11 @@ export interface SsrSiteProps {
251
340
  server?: Pick<CdkFunctionProps, "layers" | "vpc" | "vpcSubnets" | "securityGroups" | "allowAllOutbound" | "allowPublicSubnet" | "architecture" | "logRetention"> & Pick<FunctionProps, "copyFiles">;
252
341
  };
253
342
  /**
254
- * Pass in a list of file options to customize cache control and content type specific files.
255
- *
343
+ * Pass in a list of file options to customize cache control and content type specific files. Specifying file options will bypass all default file options and only use the ones specified. Most configurations within the `cache` prop will be ignored.
344
+ * @deprecated Use `cache.fileOptions` instead. Note that the `cache.fileOptions` are appended to default file options, not a replacement as this prop was.
256
345
  * @default
257
- * Versioned files cached for 1 year at the CDN and brower level.
258
- * Unversioned files cached for 1 year at the CDN level, but not at the browser level.
346
+ * Versioned files cached for 1 year at the CDN and browser level.
347
+ * Nonversioned files cached for 1 day at the CDN level, but not at the browser level.
259
348
  * ```js
260
349
  * fileOptions: [
261
350
  * {
@@ -301,9 +390,8 @@ export interface SsrSiteProps {
301
390
  * cacheControl: "public,max-age=0,s-maxage=31536000,must-revalidate",
302
391
  * },
303
392
  * ]
304
- * ```
305
393
  */
306
- fileOptions?: SsrSiteFileOptions[];
394
+ fileOptions?: SsrSiteFileOptionsDeprecated[];
307
395
  }
308
396
  export type SsrSiteNormalizedProps = SsrSiteProps & {
309
397
  path: Exclude<SsrSiteProps["path"], undefined>;
@@ -333,6 +421,7 @@ export declare abstract class SsrSite extends Construct implements SSTConstruct
333
421
  private serverFunctionForDev?;
334
422
  private distribution;
335
423
  constructor(scope: Construct, id: string, rawProps?: SsrSiteProps);
424
+ protected static buildDefaultServerCachePolicyProps(allowedHeaders: string[]): CachePolicyProps;
336
425
  /**
337
426
  * The CloudFront URL of the website.
338
427
  */
@@ -67,7 +67,7 @@ export class SsrSite extends Construct {
67
67
  const app = scope.node.root;
68
68
  const stack = Stack.of(this);
69
69
  const self = this;
70
- const { path: sitePath, typesPath, buildCommand, runtime, timeout, memorySize, edge, regional, dev, nodejs, permissions, environment, bind, customDomain, waitForInvalidation, fileOptions, warm, cdk, } = props;
70
+ const { path: sitePath, typesPath, buildCommand, runtime, timeout, memorySize, edge, regional, dev, cache, nodejs, permissions, environment, bind, customDomain, waitForInvalidation, fileOptions, warm, cdk, } = props;
71
71
  this.doNotDeploy = !stack.isActive || (app.mode === "dev" && !dev?.deploy);
72
72
  validateSiteExists();
73
73
  validateTimeout();
@@ -96,7 +96,7 @@ export class SsrSite extends Construct {
96
96
  const edgeFunctions = createEdgeFunctions();
97
97
  const origins = createOrigins();
98
98
  const distribution = createCloudFrontDistribution();
99
- distribution.createInvalidation(plan.buildId ?? generateBuildId());
99
+ createDistributionInvalidation();
100
100
  // Create Warmer
101
101
  createWarmer();
102
102
  this.bucket = bucket;
@@ -411,7 +411,7 @@ function handler(event) {
411
411
  originPath: "/" + (props.originPath ?? ""),
412
412
  });
413
413
  const assets = createS3OriginAssets(props.copy);
414
- const assetFileOptions = fileOptions || createS3OriginAssetFileOptions(props.copy);
414
+ const assetFileOptions = fileOptions || createS3OriginAssetFileOptions(props.copy, cache);
415
415
  const s3deployCR = createS3OriginDeployment(assets, assetFileOptions);
416
416
  s3DeployCRs.push(s3deployCR);
417
417
  return s3Origin;
@@ -562,27 +562,122 @@ function handler(event) {
562
562
  }
563
563
  return assets;
564
564
  }
565
- function createS3OriginAssetFileOptions(copy) {
565
+ function createS3OriginAssetFileOptions(copy, cache) {
566
566
  const fileOptions = [];
567
- for (const files of copy) {
567
+ const textEncoding = cache?.textEncoding ?? "UTF-8";
568
+ const nonVersionedFilesTTL = typeof cache?.nonVersionedFilesTTL === "number"
569
+ ? cache.nonVersionedFilesTTL
570
+ : toCdkDuration(cache?.nonVersionedFilesTTL ?? "1 day").toSeconds();
571
+ const staleWhileRevalidateTTL = Math.max(Math.floor(nonVersionedFilesTTL / 10), 30);
572
+ const nonVersionedFilesCacheHeader = cache?.nonVersionedFilesCacheHeader ??
573
+ `public,max-age=0,s-maxage=${nonVersionedFilesTTL},stale-while-revalidate=${staleWhileRevalidateTTL}`;
574
+ const versionedFilesTTL = typeof cache?.versionedFilesTTL === "number"
575
+ ? cache.versionedFilesTTL
576
+ : toCdkDuration(cache?.versionedFilesTTL ?? "365 days").toSeconds();
577
+ const versionedFilesCacheHeader = cache?.versionedFilesCacheHeader ??
578
+ `public,max-age=${versionedFilesTTL},immutable`;
579
+ const commonWebFileExtensions = {
580
+ txt: { mime: "text/plain", isText: true },
581
+ htm: { mime: "text/html", isText: true },
582
+ html: { mime: "text/html", isText: true },
583
+ xhtml: { mime: "application/xhtml+xml", isText: true },
584
+ css: { mime: "text/css", isText: true },
585
+ js: { mime: "text/javascript", isText: true },
586
+ mjs: { mime: "text/javascript", isText: true },
587
+ apng: { mime: "image/apng", isText: false },
588
+ avif: { mime: "image/avif", isText: false },
589
+ gif: { mime: "image/gif", isText: false },
590
+ jpeg: { mime: "image/jpeg", isText: false },
591
+ jpg: { mime: "image/jpeg", isText: false },
592
+ png: { mime: "image/png", isText: false },
593
+ svg: { mime: "image/svg+xml", isText: true },
594
+ bmp: { mime: "image/bmp", isText: false },
595
+ tiff: { mime: "image/tiff", isText: false },
596
+ webp: { mime: "image/webp", isText: false },
597
+ ico: { mime: "image/vnd.microsoft.icon", isText: false },
598
+ eot: { mime: "application/vnd.ms-fontobject", isText: false },
599
+ ttf: { mime: "font/ttf", isText: false },
600
+ otf: { mime: "font/otf", isText: false },
601
+ woff: { mime: "font/woff", isText: false },
602
+ woff2: { mime: "font/woff2", isText: false },
603
+ json: { mime: "application/json", isText: true },
604
+ jsonld: { mime: "application/ld+json", isText: true },
605
+ xml: { mime: "application/xml", isText: true },
606
+ pdf: { mime: "application/pdf", isText: false },
607
+ zip: { mime: "application/zip", isText: false },
608
+ };
609
+ copy.forEach((files) => {
568
610
  if (!files.cached)
569
- continue;
570
- const filesPath = path.join(sitePath, files.from);
571
- for (const item of fs.readdirSync(filesPath)) {
572
- const itemPath = path.join(filesPath, item);
573
- const isDir = fs.statSync(itemPath).isDirectory();
611
+ return;
612
+ for (const [extension, contentType] of Object.entries(commonWebFileExtensions)) {
613
+ // Create a file option for: common extension + unversioned files
614
+ fileOptions.push({
615
+ filters: [
616
+ { exclude: "*" },
617
+ { include: `${path.posix.join(files.to, "*")}.${extension}` },
618
+ ...(files.versionedSubDir
619
+ ? [
620
+ {
621
+ exclude: path.posix.join(files.to, files.versionedSubDir, "*"),
622
+ },
623
+ ]
624
+ : []),
625
+ ],
626
+ cacheControl: nonVersionedFilesCacheHeader,
627
+ contentType: `${contentType.mime}${contentType.isText && textEncoding !== "none"
628
+ ? `;charset=${textEncoding}`
629
+ : ""}`,
630
+ });
631
+ // Create a file option for: common extension + versioned files
632
+ if (files.versionedSubDir) {
633
+ fileOptions.push({
634
+ filters: [
635
+ { exclude: "*" },
636
+ {
637
+ include: path.posix.join(files.to, files.versionedSubDir, `*.${extension}`),
638
+ },
639
+ ],
640
+ cacheControl: versionedFilesCacheHeader,
641
+ contentType: `${contentType.mime}${contentType.isText && textEncoding !== "none"
642
+ ? `;charset=${textEncoding}`
643
+ : ""}`,
644
+ });
645
+ }
646
+ }
647
+ // Create a file option for: other extensions + unversioned files
648
+ fileOptions.push({
649
+ filters: [
650
+ { include: "*" },
651
+ ...(files.versionedSubDir
652
+ ? [
653
+ {
654
+ exclude: path.posix.join(files.to, files.versionedSubDir, "*"),
655
+ },
656
+ ]
657
+ : []),
658
+ ...Object.entries(commonWebFileExtensions).map(([ext]) => ({
659
+ exclude: `*.${ext}`,
660
+ })),
661
+ ],
662
+ cacheControl: nonVersionedFilesCacheHeader,
663
+ });
664
+ // Create a file option for: other extensions + versioned files
665
+ if (files.versionedSubDir) {
574
666
  fileOptions.push({
575
- exclude: "*",
576
- include: path.posix.join(files.to, item, isDir ? "*" : ""),
577
- cacheControl: item === files.versionedSubDir
578
- ? // Versioned files will be cached for 1 year (immutable) both at
579
- // the CDN and browser level.
580
- "public,max-age=31536000,immutable"
581
- : // Un-versioned files will be cached for 1 year at the CDN level.
582
- // But not at the browser level. CDN cache will be invalidated on deploy.
583
- "public,max-age=0,s-maxage=31536000,must-revalidate",
667
+ filters: [
668
+ {
669
+ include: path.posix.join(files.to, files.versionedSubDir, "*"),
670
+ },
671
+ ...Object.entries(commonWebFileExtensions).map(([ext]) => ({
672
+ exclude: `*.${ext}`,
673
+ })),
674
+ ],
675
+ cacheControl: versionedFilesCacheHeader,
584
676
  });
585
677
  }
678
+ });
679
+ if (cache?.fileOptions) {
680
+ fileOptions.push(...cache.fileOptions);
586
681
  }
587
682
  return fileOptions;
588
683
  }
@@ -622,18 +717,25 @@ function handler(event) {
622
717
  ObjectKey: asset.s3ObjectKey,
623
718
  })),
624
719
  DestinationBucketName: bucket.bucketName,
625
- FileOptions: (fileOptions || []).map(({ exclude, include, cacheControl, contentType }) => {
626
- if (typeof exclude === "string") {
627
- exclude = [exclude];
628
- }
629
- if (typeof include === "string") {
630
- include = [include];
631
- }
720
+ FileOptions: (fileOptions || []).map((o) => {
721
+ const { filters, cacheControl, contentType, contentEncoding } = o;
722
+ const { include, exclude } = o;
632
723
  return [
633
- ...exclude.map((per) => ["--exclude", per]),
634
- ...include.map((per) => ["--include", per]),
635
- ["--cache-control", cacheControl],
724
+ ...(typeof exclude === "string" ? [exclude] : exclude ?? [])
725
+ .map((entry) => ["--exclude", entry])
726
+ .flat(2),
727
+ ...(typeof include === "string" ? [include] : include ?? [])
728
+ .map((entry) => ["--include", entry])
729
+ .flat(2),
730
+ ...(filters || [])
731
+ .map((filter) => Object.entries(filter).map(([key, value]) => [
732
+ `--${key}`,
733
+ value,
734
+ ]))
735
+ .flat(2),
736
+ cacheControl ? ["--cache-control", cacheControl] : [],
636
737
  contentType ? ["--content-type", contentType] : [],
738
+ contentEncoding ? ["--content-encoding", contentEncoding] : [],
637
739
  ].flat();
638
740
  }),
639
741
  ReplaceValues: getS3ContentReplaceValues(),
@@ -656,19 +758,7 @@ function handler(event) {
656
758
  const allowedHeaders = plan.cachePolicyAllowedHeaders || [];
657
759
  singletonCachePolicy =
658
760
  singletonCachePolicy ??
659
- new CachePolicy(self, "ServerCache", {
660
- queryStringBehavior: CacheQueryStringBehavior.all(),
661
- headerBehavior: allowedHeaders.length > 0
662
- ? CacheHeaderBehavior.allowList(...allowedHeaders)
663
- : CacheHeaderBehavior.none(),
664
- cookieBehavior: CacheCookieBehavior.none(),
665
- defaultTtl: CdkDuration.days(0),
666
- maxTtl: CdkDuration.days(365),
667
- minTtl: CdkDuration.days(0),
668
- enableAcceptEncodingBrotli: true,
669
- enableAcceptEncodingGzip: true,
670
- comment: "SST server response cache policy",
671
- });
761
+ new CachePolicy(self, "ServerCache", SsrSite.buildDefaultServerCachePolicyProps(allowedHeaders));
672
762
  return singletonCachePolicy;
673
763
  }
674
764
  function useServerBehaviorOriginRequestPolicy() {
@@ -705,34 +795,91 @@ function handler(event) {
705
795
  });
706
796
  return replaceValues;
707
797
  }
708
- function generateBuildId() {
798
+ function createDistributionInvalidation() {
799
+ const cdnInvalidationStrategy = cache?.cdnInvalidationStrategy ?? "all";
800
+ if (cdnInvalidationStrategy === "never")
801
+ return;
802
+ if (plan.buildId) {
803
+ distribution.createInvalidation(plan.buildId);
804
+ return;
805
+ }
806
+ if (cdnInvalidationStrategy === "always") {
807
+ const buildId = Date.now().toString(16) + Math.random().toString(16).slice(2);
808
+ distribution.createInvalidation(buildId);
809
+ return;
810
+ }
709
811
  // We will generate a hash based on the contents of the S3 files with cache enabled.
710
812
  // This will be used to determine if we need to invalidate our CloudFront cache.
711
813
  const s3Origin = Object.values(plan.origins).find((origin) => origin.type === "s3");
712
814
  if (s3Origin?.type !== "s3")
713
- return "unchanged";
714
- const cachedS3Files = s3Origin.copy.find((item) => item.cached);
715
- if (!cachedS3Files)
716
- return "unchanged";
717
- // The below options are needed to support following symlinks when building zip files:
718
- // - nodir: This will prevent symlinks themselves from being copied into the zip.
719
- // - follow: This will follow symlinks and copy the files within.
720
- const globOptions = {
721
- dot: true,
722
- nodir: true,
723
- follow: true,
724
- cwd: path.resolve(sitePath, cachedS3Files.from),
725
- };
726
- const files = glob.sync("**", globOptions);
727
- const hash = crypto.createHash("sha1");
728
- for (const file of files) {
729
- hash.update(file);
815
+ return;
816
+ const cachedS3Files = s3Origin.copy.filter((file) => file.cached);
817
+ if (cachedS3Files.length === 0)
818
+ return;
819
+ // Build invalidation paths
820
+ const invalidationPaths = [];
821
+ if (cdnInvalidationStrategy === "versioned") {
822
+ cachedS3Files.forEach((item) => {
823
+ if (!item.versionedSubDir)
824
+ return;
825
+ invalidationPaths.push(path.posix.join("/", item.to, item.versionedSubDir, "*"));
826
+ });
730
827
  }
828
+ if (invalidationPaths.length === 0)
829
+ invalidationPaths.push("/*");
830
+ // Build build ID
831
+ const hash = crypto.createHash("md5");
832
+ cachedS3Files.forEach((item) => {
833
+ // The below options are needed to support following symlinks when building zip files:
834
+ // - nodir: This will prevent symlinks themselves from being copied into the zip.
835
+ // - follow: This will follow symlinks and copy the files within.
836
+ const globOptions = {
837
+ dot: true,
838
+ nodir: true,
839
+ follow: true,
840
+ cwd: path.resolve(sitePath, item.from),
841
+ };
842
+ // For versioned files, use file path for digest since file version in name should change on content change
843
+ if (item.versionedSubDir) {
844
+ glob
845
+ .sync("**", {
846
+ ...globOptions,
847
+ cwd: path.resolve(sitePath, item.from, item.versionedSubDir),
848
+ })
849
+ .forEach((filePath) => hash.update(filePath));
850
+ }
851
+ // For non-versioned files, use file content for digest
852
+ if (cdnInvalidationStrategy === "all") {
853
+ glob
854
+ .sync("**", {
855
+ ...globOptions,
856
+ ignore: item.versionedSubDir
857
+ ? [path.posix.join(item.versionedSubDir, "**")]
858
+ : undefined,
859
+ })
860
+ .forEach((filePath) => hash.update(fs.readFileSync(path.resolve(sitePath, item.from, filePath))));
861
+ }
862
+ });
731
863
  const buildId = hash.digest("hex");
732
864
  Logger.debug(`Generated build ID ${buildId}`);
733
- return buildId;
865
+ distribution.createInvalidation(buildId, invalidationPaths);
734
866
  }
735
867
  }
868
+ static buildDefaultServerCachePolicyProps(allowedHeaders) {
869
+ return {
870
+ queryStringBehavior: CacheQueryStringBehavior.all(),
871
+ headerBehavior: allowedHeaders.length > 0
872
+ ? CacheHeaderBehavior.allowList(...allowedHeaders)
873
+ : CacheHeaderBehavior.none(),
874
+ cookieBehavior: CacheCookieBehavior.none(),
875
+ defaultTtl: CdkDuration.days(0),
876
+ maxTtl: CdkDuration.days(365),
877
+ minTtl: CdkDuration.days(0),
878
+ enableAcceptEncodingBrotli: true,
879
+ enableAcceptEncodingGzip: true,
880
+ comment: "SST server response cache policy",
881
+ };
882
+ }
736
883
  /**
737
884
  * The CloudFront URL of the website.
738
885
  */
@@ -2,7 +2,7 @@ import { Construct } from "constructs";
2
2
  import { Bucket, BucketProps, IBucket } from "aws-cdk-lib/aws-s3";
3
3
  import { IDistribution } from "aws-cdk-lib/aws-cloudfront";
4
4
  import { DistributionDomainProps } from "./Distribution.js";
5
- import { BaseSiteFileOptions, BaseSiteReplaceProps, BaseSiteCdkDistributionProps } from "./BaseSite.js";
5
+ import { BaseSiteFileOptions, BaseSiteReplaceProps, BaseSiteCdkDistributionProps, BaseSiteFileOptionsDeprecated } from "./BaseSite.js";
6
6
  import { SSTConstruct } from "./Construct.js";
7
7
  import { FunctionBindingProps } from "./util/functionBinding.js";
8
8
  export interface StaticSiteProps {
@@ -69,18 +69,15 @@ export interface StaticSiteProps {
69
69
  buildOutput?: string;
70
70
  /**
71
71
  * Pass in a list of file options to configure cache control for different files. Behind the scenes, the `StaticSite` construct uses a combination of the `s3 cp` and `s3 sync` commands to upload the website content to the S3 bucket. An `s3 cp` command is run for each file option block, and the options are passed in as the command options.
72
- *
73
72
  * @default No cache control for HTML files, and a 1 year cache control for JS/CSS files.
74
73
  * ```js
75
74
  * [
76
75
  * {
77
- * exclude: "*",
78
- * include: "*.html",
76
+ * filters: [{ exclude: "*" }, { include: "*.html" }],
79
77
  * cacheControl: "max-age=0,no-cache,no-store,must-revalidate",
80
78
  * },
81
79
  * {
82
- * exclude: "*",
83
- * include: ["*.js", "*.css"],
80
+ * filters: [{ exclude: "*" }, { include: "*.js" }, { include: "*.css" }],
84
81
  * cacheControl: "max-age=31536000,public,immutable",
85
82
  * },
86
83
  * ]
@@ -90,14 +87,13 @@ export interface StaticSiteProps {
90
87
  * new StaticSite(stack, "Site", {
91
88
  * buildOutput: "dist",
92
89
  * fileOptions: [{
93
- * exclude: "*",
94
- * include: "*.js",
90
+ * filters: [{ exclude: "*" }, { include: "*.js" }],
95
91
  * cacheControl: "max-age=31536000,public,immutable",
96
92
  * }]
97
93
  * });
98
94
  * ```
99
95
  */
100
- fileOptions?: StaticSiteFileOptions[];
96
+ fileOptions?: StaticSiteFileOptions[] | StaticSiteFileOptionsDeprecated[];
101
97
  /**
102
98
  * Pass in a list of placeholder values to be replaced in the website content. For example, the follow configuration:
103
99
  *
@@ -269,6 +265,8 @@ export interface StaticSiteDomainProps extends DistributionDomainProps {
269
265
  }
270
266
  export interface StaticSiteFileOptions extends BaseSiteFileOptions {
271
267
  }
268
+ export interface StaticSiteFileOptionsDeprecated extends BaseSiteFileOptionsDeprecated {
269
+ }
272
270
  export interface StaticSiteReplaceProps extends BaseSiteReplaceProps {
273
271
  }
274
272
  export interface StaticSiteCdkDistributionProps extends BaseSiteCdkDistributionProps {
@@ -296,15 +296,13 @@ interface ImportMeta {
296
296
  }
297
297
  }
298
298
  createS3Deployment(cliLayer, assets, filenamesAsset) {
299
- const fileOptions = this.props.fileOptions || [
299
+ const fileOptions = this.props.fileOptions ?? [
300
300
  {
301
- exclude: "*",
302
- include: "*.html",
301
+ filters: [{ exclude: "*" }, { include: "*.html" }],
303
302
  cacheControl: "max-age=0,no-cache,no-store,must-revalidate",
304
303
  },
305
304
  {
306
- exclude: "*",
307
- include: ["*.js", "*.css"],
305
+ filters: [{ exclude: "*" }, { include: "*.js" }, { include: "*.css" }],
308
306
  cacheControl: "max-age=31536000,public,immutable",
309
307
  },
310
308
  ];
@@ -348,18 +346,25 @@ interface ImportMeta {
348
346
  BucketName: filenamesAsset.s3BucketName,
349
347
  ObjectKey: filenamesAsset.s3ObjectKey,
350
348
  },
351
- FileOptions: (fileOptions || []).map(({ exclude, include, cacheControl, contentType }) => {
352
- if (typeof exclude === "string") {
353
- exclude = [exclude];
354
- }
355
- if (typeof include === "string") {
356
- include = [include];
357
- }
349
+ FileOptions: (fileOptions || []).map((o) => {
350
+ const { filters, cacheControl, contentType, contentEncoding } = o;
351
+ const { include, exclude } = o;
358
352
  return [
359
- ...exclude.map((per) => ["--exclude", per]),
360
- ...include.map((per) => ["--include", per]),
361
- ["--cache-control", cacheControl],
353
+ ...(typeof exclude === "string" ? [exclude] : exclude ?? [])
354
+ .map((entry) => ["--exclude", entry])
355
+ .flat(2),
356
+ ...(typeof include === "string" ? [include] : include ?? [])
357
+ .map((entry) => ["--include", entry])
358
+ .flat(2),
359
+ ...(filters || [])
360
+ .map((filter) => Object.entries(filter).map(([key, value]) => [
361
+ `--${key}`,
362
+ value,
363
+ ]))
364
+ .flat(2),
365
+ cacheControl ? ["--cache-control", cacheControl] : [],
362
366
  contentType ? ["--content-type", contentType] : [],
367
+ contentEncoding ? ["--content-encoding", contentEncoding] : [],
363
368
  ].flat();
364
369
  }),
365
370
  ReplaceValues: this.getS3ContentReplaceValues(),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "sideEffects": false,
3
3
  "name": "sst",
4
- "version": "2.29.2",
4
+ "version": "2.30.2",
5
5
  "bin": {
6
6
  "sst": "cli/sst.js"
7
7
  },
@@ -119,7 +119,7 @@
119
119
  "@types/ws": "^8.5.3",
120
120
  "@types/yargs": "^17.0.13",
121
121
  "archiver": "^5.3.1",
122
- "astro-sst": "2.29.2",
122
+ "astro-sst": "2.30.2",
123
123
  "tsx": "^3.12.1",
124
124
  "typescript": "^5.2.2",
125
125
  "vitest": "^0.33.0"
@@ -13,6 +13,7 @@ import { useJavaHandler } from "./handlers/java.js";
13
13
  import { usePythonHandler } from "./handlers/python.js";
14
14
  import { useRustHandler } from "./handlers/rust.js";
15
15
  import { lazy } from "../util/lazy.js";
16
+ import { Semaphore } from "../util/semaphore.js";
16
17
  export const useRuntimeHandlers = lazy(() => {
17
18
  const handlers = [
18
19
  useNodeHandler(),
@@ -104,9 +105,12 @@ export const useRuntimeHandlers = lazy(() => {
104
105
  const promise = task();
105
106
  pendingBuilds.set(functionID, promise);
106
107
  Logger.debug("Building function", functionID);
107
- const r = await promise;
108
- pendingBuilds.delete(functionID);
109
- return r;
108
+ try {
109
+ return await promise;
110
+ }
111
+ finally {
112
+ pendingBuilds.delete(functionID);
113
+ }
110
114
  },
111
115
  };
112
116
  return result;
@@ -156,30 +160,3 @@ export const useFunctionBuilder = lazy(() => {
156
160
  });
157
161
  return result;
158
162
  });
159
- class Semaphore {
160
- queue = [];
161
- locked = 0;
162
- maxLocks;
163
- constructor(maxLocks = 1) {
164
- this.maxLocks = maxLocks;
165
- }
166
- lock() {
167
- return new Promise((resolve) => {
168
- const unlock = () => {
169
- this.locked--;
170
- const next = this.queue.shift();
171
- if (next) {
172
- this.locked++;
173
- next(unlock);
174
- }
175
- };
176
- if (this.locked < this.maxLocks) {
177
- this.locked++;
178
- resolve(unlock);
179
- }
180
- else {
181
- this.queue.push(unlock);
182
- }
183
- });
184
- }
185
- }
package/stacks/synth.js CHANGED
@@ -5,6 +5,8 @@ import * as contextproviders from "sst-aws-cdk/lib/context-providers/index.js";
5
5
  import path from "path";
6
6
  import { VisibleError } from "../error.js";
7
7
  import fs from "fs/promises";
8
+ import { Semaphore } from "../util/semaphore.js";
9
+ const sem = new Semaphore(1);
8
10
  export async function synth(opts) {
9
11
  Logger.debug("Synthesizing stacks...");
10
12
  const { App } = await import("../constructs/App.js");
@@ -13,6 +15,7 @@ export async function synth(opts) {
13
15
  const project = useProject();
14
16
  const cwd = process.cwd();
15
17
  process.chdir(project.paths.root);
18
+ const unlock = await sem.lock();
16
19
  try {
17
20
  return await synthInRoot();
18
21
  }
@@ -20,6 +23,7 @@ export async function synth(opts) {
20
23
  throw e;
21
24
  }
22
25
  finally {
26
+ unlock();
23
27
  process.chdir(cwd);
24
28
  }
25
29
  async function synthInRoot() {
@@ -30,15 +34,6 @@ export async function synth(opts) {
30
34
  };
31
35
  await fs.rm(opts.buildDir, { recursive: true, force: true });
32
36
  await fs.mkdir(opts.buildDir, { recursive: true });
33
- /*
34
- console.log(JSON.stringify(cfg.context));
35
- const executable = new CloudExecutable({
36
- sdkProvider: await useAWSProvider(),
37
- configuration: cfg,
38
- synthesizer: async () => app.synth() as any
39
- });
40
- const { assembly } = await executable.synthesize(true);
41
- */
42
37
  const cfg = new Configuration();
43
38
  await cfg.load();
44
39
  let previous = new Set();
@@ -0,0 +1,7 @@
1
+ export declare class Semaphore {
2
+ private queue;
3
+ private locked;
4
+ private maxLocks;
5
+ constructor(maxLocks?: number);
6
+ lock(): Promise<() => void>;
7
+ }
@@ -0,0 +1,27 @@
1
+ export class Semaphore {
2
+ queue = [];
3
+ locked = 0;
4
+ maxLocks;
5
+ constructor(maxLocks = 1) {
6
+ this.maxLocks = maxLocks;
7
+ }
8
+ lock() {
9
+ return new Promise((resolve) => {
10
+ const unlock = () => {
11
+ this.locked--;
12
+ const next = this.queue.shift();
13
+ if (next) {
14
+ this.locked++;
15
+ next(unlock);
16
+ }
17
+ };
18
+ if (this.locked < this.maxLocks) {
19
+ this.locked++;
20
+ resolve(unlock);
21
+ }
22
+ else {
23
+ this.queue.push(unlock);
24
+ }
25
+ });
26
+ }
27
+ }