@staticforge/aws 0.0.0-stage → 0.1.0

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,191 @@
1
+ locals {
2
+ custom_domain = var.domain != null
3
+ aliases = local.custom_domain ? concat(
4
+ [var.domain],
5
+ var.preview_domain == null ? [] : ["*.${var.preview_domain}"],
6
+ ) : []
7
+ # Served by CloudFront's error responses; sforge copies the active version's
8
+ # 404.html here on every deploy and rollback.
9
+ error_page = "/_sforge/errors/404.html"
10
+ headers = var.security_headers
11
+ header_policies = merge(
12
+ { default = local.headers.content_security_policy },
13
+ length(var.search_paths) > 0 ? { search = local.headers.search_content_security_policy } : {},
14
+ )
15
+ }
16
+
17
+ resource "aws_cloudfront_origin_access_control" "site" {
18
+ name = substr("sforge-${local.id}", 0, 64)
19
+ description = "staticforge ${local.id}"
20
+ origin_access_control_origin_type = "s3"
21
+ signing_behavior = "always"
22
+ signing_protocol = "sigv4"
23
+ }
24
+
25
+ # Honors the Cache-Control set on each object: immutable for fingerprinted
26
+ # assets, max-age=0 + must-revalidate (ETag revalidation) for everything else.
27
+ resource "aws_cloudfront_cache_policy" "site" {
28
+ name = "sforge-${local.id}"
29
+ comment = "staticforge: origin Cache-Control, no cookies or query strings"
30
+ min_ttl = 0
31
+ default_ttl = 86400
32
+ max_ttl = 31536000
33
+
34
+ parameters_in_cache_key_and_forwarded_to_origin {
35
+ enable_accept_encoding_brotli = true
36
+ enable_accept_encoding_gzip = true
37
+ cookies_config {
38
+ cookie_behavior = "none"
39
+ }
40
+ headers_config {
41
+ header_behavior = "none"
42
+ }
43
+ query_strings_config {
44
+ query_string_behavior = "none"
45
+ }
46
+ }
47
+ }
48
+
49
+ resource "aws_cloudfront_response_headers_policy" "site" {
50
+ for_each = local.header_policies
51
+ name = "sforge-${local.id}-${each.key}"
52
+ comment = "staticforge security headers (${each.key})"
53
+
54
+ security_headers_config {
55
+ content_type_options {
56
+ override = true
57
+ }
58
+
59
+ dynamic "frame_options" {
60
+ for_each = local.headers.frame_options == null ? [] : [local.headers.frame_options]
61
+ content {
62
+ frame_option = frame_options.value
63
+ override = true
64
+ }
65
+ }
66
+
67
+ dynamic "referrer_policy" {
68
+ for_each = local.headers.referrer_policy == null ? [] : [local.headers.referrer_policy]
69
+ content {
70
+ referrer_policy = referrer_policy.value
71
+ override = true
72
+ }
73
+ }
74
+
75
+ dynamic "strict_transport_security" {
76
+ for_each = local.headers.hsts_max_age == null ? [] : [local.headers.hsts_max_age]
77
+ content {
78
+ access_control_max_age_sec = strict_transport_security.value
79
+ include_subdomains = local.headers.hsts_include_subdomains
80
+ preload = local.headers.hsts_preload
81
+ override = true
82
+ }
83
+ }
84
+
85
+ dynamic "content_security_policy" {
86
+ for_each = each.value == null ? [] : [each.value]
87
+ content {
88
+ content_security_policy = content_security_policy.value
89
+ override = true
90
+ }
91
+ }
92
+ }
93
+
94
+ dynamic "custom_headers_config" {
95
+ for_each = local.headers.permissions_policy == null ? [] : [local.headers.permissions_policy]
96
+ content {
97
+ items {
98
+ header = "Permissions-Policy"
99
+ value = custom_headers_config.value
100
+ override = true
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ resource "aws_cloudfront_distribution" "site" {
107
+ enabled = true
108
+ comment = "staticforge ${local.id}"
109
+ is_ipv6_enabled = true
110
+ http_version = "http2and3"
111
+ price_class = var.price_class
112
+ aliases = local.aliases
113
+ web_acl_id = var.waf_web_acl_arn
114
+ tags = local.tags
115
+
116
+ origin {
117
+ origin_id = "site"
118
+ domain_name = aws_s3_bucket.site.bucket_regional_domain_name
119
+ origin_access_control_id = aws_cloudfront_origin_access_control.site.id
120
+ }
121
+
122
+ default_cache_behavior {
123
+ target_origin_id = "site"
124
+ viewer_protocol_policy = "redirect-to-https"
125
+ allowed_methods = ["GET", "HEAD"]
126
+ cached_methods = ["GET", "HEAD"]
127
+ compress = true
128
+ cache_policy_id = aws_cloudfront_cache_policy.site.id
129
+ response_headers_policy_id = aws_cloudfront_response_headers_policy.site["default"].id
130
+
131
+ function_association {
132
+ event_type = "viewer-request"
133
+ function_arn = aws_cloudfront_function.router.arn
134
+ }
135
+ }
136
+
137
+ dynamic "ordered_cache_behavior" {
138
+ for_each = var.search_paths
139
+ content {
140
+ path_pattern = ordered_cache_behavior.value
141
+ target_origin_id = "site"
142
+ viewer_protocol_policy = "redirect-to-https"
143
+ allowed_methods = ["GET", "HEAD"]
144
+ cached_methods = ["GET", "HEAD"]
145
+ compress = true
146
+ cache_policy_id = aws_cloudfront_cache_policy.site.id
147
+ response_headers_policy_id = aws_cloudfront_response_headers_policy.site["search"].id
148
+
149
+ function_association {
150
+ event_type = "viewer-request"
151
+ function_arn = aws_cloudfront_function.router.arn
152
+ }
153
+ }
154
+ }
155
+
156
+ # S3 answers 404 for missing objects (403 if listing were not allowed).
157
+ dynamic "custom_error_response" {
158
+ for_each = [403, 404]
159
+ content {
160
+ error_code = custom_error_response.value
161
+ response_code = 404
162
+ response_page_path = local.error_page
163
+ error_caching_min_ttl = 10
164
+ }
165
+ }
166
+
167
+ restrictions {
168
+ geo_restriction {
169
+ restriction_type = "none"
170
+ }
171
+ }
172
+
173
+ viewer_certificate {
174
+ cloudfront_default_certificate = !local.custom_domain
175
+ acm_certificate_arn = local.custom_domain ? aws_acm_certificate_validation.site[0].certificate_arn : null
176
+ ssl_support_method = local.custom_domain ? "sni-only" : null
177
+ # The default *.cloudfront.net certificate does not allow choosing the policy.
178
+ minimum_protocol_version = local.custom_domain ? "TLSv1.2_2021" : "TLSv1"
179
+ }
180
+
181
+ dynamic "logging_config" {
182
+ for_each = var.logging ? [1] : []
183
+ content {
184
+ bucket = aws_s3_bucket.logs[0].bucket_domain_name
185
+ include_cookies = false
186
+ prefix = "cloudfront/"
187
+ }
188
+ }
189
+
190
+ depends_on = [aws_s3_bucket_acl.logs]
191
+ }
@@ -0,0 +1,117 @@
1
+ # GitHub Actions deploy role, assumed through OIDC (no long-lived keys). The
2
+ # trust policy accepts only the configured branches, environments and, if
3
+ # enabled, pull_request workflows of one repository.
4
+
5
+ locals {
6
+ ci = var.ci
7
+ ci_create = local.ci != null && try(local.ci.create_oidc_provider, false)
8
+ ci_subjects = local.ci == null ? [] : concat(
9
+ [for branch in local.ci.branches : "repo:${local.ci.repo}:ref:refs/heads/${branch}"],
10
+ [for environment in local.ci.environments : "repo:${local.ci.repo}:environment:${environment}"],
11
+ local.ci.pull_requests ? ["repo:${local.ci.repo}:pull_request"] : [],
12
+ )
13
+ oidc_provider_arn = local.ci == null ? null : (
14
+ local.ci_create ? aws_iam_openid_connect_provider.github[0].arn : local.ci.oidc_provider_arn
15
+ )
16
+ state_bucket = local.ci == null ? "" : (local.ci.state_bucket == null ? "" : local.ci.state_bucket)
17
+ state_key = local.ci == null ? "" : (local.ci.state_key == null ? "" : local.ci.state_key)
18
+ state_object = "arn:aws:s3:::${local.state_bucket}/${local.state_key}"
19
+ # This target's state (and its lock file) only, for `sforge status` and plans in CI.
20
+ state_statements = [for statement in [
21
+ {
22
+ Sid = "ListState"
23
+ Effect = "Allow"
24
+ Action = "s3:ListBucket"
25
+ Resource = "arn:aws:s3:::${local.state_bucket}"
26
+ Condition = { StringLike = { "s3:prefix" = [local.state_key, "${local.state_key}.tflock"] } }
27
+ },
28
+ {
29
+ Sid = "ReadWriteState"
30
+ Effect = "Allow"
31
+ Action = ["s3:GetObject", "s3:PutObject"]
32
+ Resource = [local.state_object, "${local.state_object}.tflock"]
33
+ },
34
+ {
35
+ Sid = "UnlockState"
36
+ Effect = "Allow"
37
+ Action = "s3:DeleteObject"
38
+ Resource = "${local.state_object}.tflock"
39
+ },
40
+ ] : statement if local.state_bucket != ""]
41
+ }
42
+
43
+ # One per account. sforge reuses an existing provider and creates one (tagged
44
+ # with this site and target) only when the account has none.
45
+ resource "aws_iam_openid_connect_provider" "github" {
46
+ count = local.ci_create ? 1 : 0
47
+ url = "https://token.actions.githubusercontent.com"
48
+ client_id_list = ["sts.amazonaws.com"]
49
+ tags = merge(local.tags, { "sforge:owner" = local.id })
50
+ }
51
+
52
+ resource "aws_iam_role" "ci" {
53
+ count = local.ci == null ? 0 : 1
54
+ name = substr("sforge-${local.id}-deploy", 0, 64)
55
+ description = "staticforge: GitHub Actions deploys of ${local.id} (${local.ci.repo})"
56
+ max_session_duration = 3600
57
+ tags = local.tags
58
+
59
+ assume_role_policy = jsonencode({
60
+ Version = "2012-10-17"
61
+ Statement = [{
62
+ Effect = "Allow"
63
+ Principal = { Federated = local.oidc_provider_arn }
64
+ Action = "sts:AssumeRoleWithWebIdentity"
65
+ Condition = {
66
+ StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
67
+ StringLike = { "token.actions.githubusercontent.com:sub" = local.ci_subjects }
68
+ }
69
+ }]
70
+ })
71
+ }
72
+
73
+ resource "aws_iam_role_policy" "ci" {
74
+ count = local.ci == null ? 0 : 1
75
+ name = "deploy"
76
+ role = aws_iam_role.ci[0].id
77
+
78
+ policy = jsonencode({
79
+ Version = "2012-10-17"
80
+ Statement = concat(
81
+ [
82
+ {
83
+ Sid = "ListContent"
84
+ Effect = "Allow"
85
+ Action = "s3:ListBucket"
86
+ Resource = aws_s3_bucket.site.arn
87
+ },
88
+ {
89
+ Sid = "WriteContent"
90
+ Effect = "Allow"
91
+ Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
92
+ Resource = "${aws_s3_bucket.site.arn}/*"
93
+ },
94
+ {
95
+ Sid = "SwitchVersions"
96
+ Effect = "Allow"
97
+ Action = [
98
+ "cloudfront-keyvaluestore:DescribeKeyValueStore",
99
+ "cloudfront-keyvaluestore:GetKey",
100
+ "cloudfront-keyvaluestore:ListKeys",
101
+ "cloudfront-keyvaluestore:PutKey",
102
+ "cloudfront-keyvaluestore:DeleteKey",
103
+ "cloudfront-keyvaluestore:UpdateKeys",
104
+ ]
105
+ Resource = aws_cloudfront_key_value_store.site.arn
106
+ },
107
+ {
108
+ Sid = "Invalidate"
109
+ Effect = "Allow"
110
+ Action = ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation", "cloudfront:GetDistribution"]
111
+ Resource = aws_cloudfront_distribution.site.arn
112
+ },
113
+ ],
114
+ local.state_statements,
115
+ )
116
+ })
117
+ }
@@ -0,0 +1,77 @@
1
+ # Optional custom domain: an ACM certificate in us-east-1 (required by
2
+ # CloudFront), validated through Route 53 when hosted_zone is set. Without a
3
+ # hosted zone, sforge creates the certificate first, prints the validation
4
+ # records and waits until they resolve.
5
+
6
+ resource "aws_acm_certificate" "site" {
7
+ count = local.custom_domain ? 1 : 0
8
+ provider = aws.us_east_1
9
+ domain_name = var.domain
10
+ subject_alternative_names = var.preview_domain == null ? [] : ["*.${var.preview_domain}"]
11
+ validation_method = "DNS"
12
+ tags = local.tags
13
+
14
+ lifecycle {
15
+ create_before_destroy = true
16
+ }
17
+ }
18
+
19
+ data "aws_route53_zone" "site" {
20
+ count = local.custom_domain && var.hosted_zone != null ? 1 : 0
21
+ name = var.hosted_zone
22
+ private_zone = false
23
+ }
24
+
25
+ locals {
26
+ # Keyed by the configured names, so the keys are known when planning.
27
+ certificate_names = local.custom_domain ? toset(concat(
28
+ [var.domain],
29
+ var.preview_domain == null ? [] : ["*.${var.preview_domain}"],
30
+ )) : toset([])
31
+ validation_options = local.custom_domain ? {
32
+ for name in local.certificate_names : name => one([
33
+ for option in aws_acm_certificate.site[0].domain_validation_options : option if option.domain_name == name
34
+ ])
35
+ } : {}
36
+ }
37
+
38
+ resource "aws_route53_record" "validation" {
39
+ for_each = var.hosted_zone == null ? toset([]) : local.certificate_names
40
+
41
+ zone_id = data.aws_route53_zone.site[0].zone_id
42
+ name = local.validation_options[each.key].resource_record_name
43
+ type = local.validation_options[each.key].resource_record_type
44
+ records = [local.validation_options[each.key].resource_record_value]
45
+ ttl = 300
46
+ allow_overwrite = true
47
+ }
48
+
49
+ resource "aws_acm_certificate_validation" "site" {
50
+ count = local.custom_domain ? 1 : 0
51
+ provider = aws.us_east_1
52
+ certificate_arn = aws_acm_certificate.site[0].arn
53
+ validation_record_fqdns = var.hosted_zone == null ? null : [for record in aws_route53_record.validation : record.fqdn]
54
+
55
+ timeouts {
56
+ create = "2h"
57
+ }
58
+ }
59
+
60
+ resource "aws_route53_record" "alias" {
61
+ for_each = var.hosted_zone == null ? {} : {
62
+ for pair in setproduct(local.aliases, ["A", "AAAA"]) : "${pair[0]} ${pair[1]}" => {
63
+ name = pair[0]
64
+ type = pair[1]
65
+ }
66
+ }
67
+
68
+ zone_id = data.aws_route53_zone.site[0].zone_id
69
+ name = each.value.name
70
+ type = each.value.type
71
+
72
+ alias {
73
+ name = aws_cloudfront_distribution.site.domain_name
74
+ zone_id = aws_cloudfront_distribution.site.hosted_zone_id
75
+ evaluate_target_health = false
76
+ }
77
+ }
@@ -0,0 +1,82 @@
1
+ # Optional CloudFront standard (legacy) access logs. They are delivered with
2
+ # ACLs, so this bucket, unlike the content bucket, keeps ACLs enabled.
3
+
4
+ data "aws_canonical_user_id" "current" {
5
+ count = var.logging ? 1 : 0
6
+ }
7
+
8
+ resource "aws_s3_bucket" "logs" {
9
+ count = var.logging ? 1 : 0
10
+ bucket_prefix = "${trimsuffix(substr("sforge-${local.id}", 0, 31), "-")}-logs-"
11
+ force_destroy = var.force_destroy
12
+ tags = local.tags
13
+ }
14
+
15
+ resource "aws_s3_bucket_public_access_block" "logs" {
16
+ count = var.logging ? 1 : 0
17
+ bucket = aws_s3_bucket.logs[0].id
18
+ block_public_acls = true
19
+ block_public_policy = true
20
+ ignore_public_acls = true
21
+ restrict_public_buckets = true
22
+ }
23
+
24
+ resource "aws_s3_bucket_ownership_controls" "logs" {
25
+ count = var.logging ? 1 : 0
26
+ bucket = aws_s3_bucket.logs[0].id
27
+ rule {
28
+ object_ownership = "BucketOwnerPreferred"
29
+ }
30
+ }
31
+
32
+ resource "aws_s3_bucket_acl" "logs" {
33
+ count = var.logging ? 1 : 0
34
+ bucket = aws_s3_bucket.logs[0].id
35
+
36
+ access_control_policy {
37
+ owner {
38
+ id = data.aws_canonical_user_id.current[0].id
39
+ }
40
+ grant {
41
+ permission = "FULL_CONTROL"
42
+ grantee {
43
+ type = "CanonicalUser"
44
+ id = data.aws_canonical_user_id.current[0].id
45
+ }
46
+ }
47
+ grant {
48
+ # The awslogsdelivery account, which writes CloudFront logs.
49
+ permission = "FULL_CONTROL"
50
+ grantee {
51
+ type = "CanonicalUser"
52
+ id = "c4c1ede66af53448b93c283ce9448c4ba468c9432aa01d700d3878632f77d2d0"
53
+ }
54
+ }
55
+ }
56
+
57
+ depends_on = [aws_s3_bucket_ownership_controls.logs]
58
+ }
59
+
60
+ resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
61
+ count = var.logging ? 1 : 0
62
+ bucket = aws_s3_bucket.logs[0].id
63
+ rule {
64
+ apply_server_side_encryption_by_default {
65
+ sse_algorithm = "AES256"
66
+ }
67
+ }
68
+ }
69
+
70
+ resource "aws_s3_bucket_lifecycle_configuration" "logs" {
71
+ count = var.logging ? 1 : 0
72
+ bucket = aws_s3_bucket.logs[0].id
73
+
74
+ rule {
75
+ id = "expire-logs"
76
+ status = "Enabled"
77
+ filter {}
78
+ expiration {
79
+ days = 90
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,69 @@
1
+ output "bucket" {
2
+ description = "Content bucket (private)."
3
+ value = aws_s3_bucket.site.id
4
+ }
5
+
6
+ output "bucket_region" {
7
+ value = aws_s3_bucket.site.region
8
+ }
9
+
10
+ output "distribution_id" {
11
+ value = aws_cloudfront_distribution.site.id
12
+ }
13
+
14
+ output "distribution_arn" {
15
+ value = aws_cloudfront_distribution.site.arn
16
+ }
17
+
18
+ output "distribution_domain" {
19
+ value = aws_cloudfront_distribution.site.domain_name
20
+ }
21
+
22
+ output "kvs_arn" {
23
+ description = "KeyValueStore with the active version, preview hosts and route exceptions."
24
+ value = aws_cloudfront_key_value_store.site.arn
25
+ }
26
+
27
+ output "url" {
28
+ value = "https://${local.custom_domain ? var.domain : aws_cloudfront_distribution.site.domain_name}"
29
+ }
30
+
31
+ output "preview_domain" {
32
+ value = var.preview_domain
33
+ }
34
+
35
+ output "trailing_slash" {
36
+ value = var.trailing_slash
37
+ }
38
+
39
+ output "certificate_arn" {
40
+ value = local.custom_domain ? aws_acm_certificate.site[0].arn : null
41
+ }
42
+
43
+ output "validation_records" {
44
+ description = "DNS records that validate the certificate (create them when there is no hosted_zone)."
45
+ value = local.custom_domain ? [
46
+ for option in aws_acm_certificate.site[0].domain_validation_options : {
47
+ name = option.resource_record_name
48
+ type = option.resource_record_type
49
+ value = option.resource_record_value
50
+ }
51
+ ] : []
52
+ }
53
+
54
+ output "dns_records" {
55
+ description = "Records pointing the custom domain at CloudFront (created when hosted_zone is set)."
56
+ value = [for name in local.aliases : {
57
+ name = name
58
+ type = "CNAME"
59
+ value = aws_cloudfront_distribution.site.domain_name
60
+ }]
61
+ }
62
+
63
+ output "ci_role_arn" {
64
+ value = local.ci == null ? null : aws_iam_role.ci[0].arn
65
+ }
66
+
67
+ output "oidc_provider_arn" {
68
+ value = local.oidc_provider_arn
69
+ }
@@ -0,0 +1,136 @@
1
+ // staticforge viewer-request router (CloudFront Functions, cloudfront-js-2.0).
2
+ //
3
+ // 1. Picks the version to serve from the KeyValueStore: "active", or for a PR
4
+ // preview host (pr-42.<previewDomain>) the key "host:pr-42".
5
+ // 2. Maps the URL to an object the way `sforge dev`/`preview` do
6
+ // (resolveRequest in @staticforge/server), by rule:
7
+ // trailingSlash "always": /a/ → a/index.html, /a → 301 /a/
8
+ // trailingSlash "never": /a → a.html, /a/ → 301 /a
9
+ // /file.ext → file.ext; dot-segments and malformed paths → 404
10
+ // Files that break the rule (a section index with "never", an
11
+ // extensionless file, ...) are listed per version in the KVS as
12
+ // "r:<version>:<path>" exceptions by `sforge deploy`.
13
+ // 3. Prefixes the object with v/<version>/. The rewritten URI is the cache
14
+ // key, so a new version never serves stale cached objects.
15
+
16
+ import cf from 'cloudfront';
17
+
18
+ // Replaced at provision time, e.g. {"trailingSlash":"always","previewSuffix":".preview.example.com"}.
19
+ const CONFIG = __SFORGE_CONFIG__;
20
+ const kvs = cf.kvs();
21
+
22
+ async function lookup(key) {
23
+ try {
24
+ return await kvs.get(key);
25
+ } catch (e) {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function route(uri) {
31
+ if (uri.charAt(0) !== '/') return { missing: true };
32
+ const raw = uri.slice(1).split('/');
33
+ const trailing = raw[raw.length - 1] === '';
34
+ if (trailing) raw.pop();
35
+ const canonical = [];
36
+ for (let i = 0; i < raw.length; i++) {
37
+ let segment;
38
+ try {
39
+ segment = decodeURIComponent(raw[i]);
40
+ } catch (e) {
41
+ return { missing: true };
42
+ }
43
+ if (
44
+ segment === '' ||
45
+ segment.charAt(0) === '.' ||
46
+ segment.indexOf('\\') !== -1 ||
47
+ segment.indexOf('\0') !== -1
48
+ ) {
49
+ return { missing: true };
50
+ }
51
+ canonical.push(encodeURIComponent(segment));
52
+ }
53
+ if (raw.length === 0) return { file: 'index.html' };
54
+
55
+ const path = raw.join('/');
56
+ const key = '/' + canonical.join('/') + (trailing ? '/' : '');
57
+ if (trailing) {
58
+ if (CONFIG.trailingSlash === 'always') return { file: path + '/index.html', key: key };
59
+ // "/style.css/" is not a page; only pages lose their slash.
60
+ return raw[raw.length - 1].indexOf('.') !== -1
61
+ ? { missing: true, key: key }
62
+ : { redirect: '/' + path, key: key };
63
+ }
64
+ if (raw[raw.length - 1].indexOf('.') !== -1) return { file: path };
65
+ return CONFIG.trailingSlash === 'always'
66
+ ? { redirect: '/' + path + '/', key: key }
67
+ : { file: path + '.html', key: key };
68
+ }
69
+
70
+ function exception(value) {
71
+ if (value === '404') return { missing: true };
72
+ if (value.slice(0, 2) === 'f:') return { file: value.slice(2) };
73
+ if (value.slice(0, 2) === 'r:') return { redirect: value.slice(2) };
74
+ return null;
75
+ }
76
+
77
+ function queryString(querystring) {
78
+ const parts = [];
79
+ for (const name in querystring) {
80
+ const entry = querystring[name];
81
+ const values = entry.multiValue ? entry.multiValue : [entry];
82
+ for (let i = 0; i < values.length; i++) {
83
+ parts.push(values[i].value === '' ? name : name + '=' + values[i].value);
84
+ }
85
+ }
86
+ return parts.length > 0 ? '?' + parts.join('&') : '';
87
+ }
88
+
89
+ function textResponse(statusCode, statusDescription, text) {
90
+ return {
91
+ statusCode: statusCode,
92
+ statusDescription: statusDescription,
93
+ headers: {
94
+ 'content-type': { value: 'text/plain; charset=utf-8' },
95
+ 'cache-control': { value: 'no-store' },
96
+ },
97
+ body: { encoding: 'text', data: text },
98
+ };
99
+ }
100
+
101
+ async function handler(event) {
102
+ const request = event.request;
103
+ // The error page fetched by CloudFront's custom error responses; nothing
104
+ // else outside v/<version>/ (such as _sforge/versions.json) is reachable.
105
+ if (request.uri === '/_sforge/errors/404.html') return request;
106
+
107
+ const host = request.headers.host ? request.headers.host.value.toLowerCase() : '';
108
+ const suffix = CONFIG.previewSuffix;
109
+ let key = 'active';
110
+ if (suffix !== '' && host.length > suffix.length && host.slice(-suffix.length) === suffix) {
111
+ key = 'host:' + host.slice(0, host.length - suffix.length);
112
+ }
113
+ const version = await lookup(key);
114
+ if (!version || version === 'none') {
115
+ return key === 'active'
116
+ ? textResponse(503, 'Service Unavailable', 'This site has not been deployed yet.')
117
+ : textResponse(404, 'Not Found', 'No preview is deployed at this address.');
118
+ }
119
+
120
+ let target = route(request.uri);
121
+ if (target.key !== undefined) {
122
+ const value = await lookup('r:' + version + ':' + target.key);
123
+ const override = value ? exception(value) : null;
124
+ if (override !== null) target = override;
125
+ }
126
+ if (target.redirect !== undefined) {
127
+ return {
128
+ statusCode: 301,
129
+ statusDescription: 'Moved Permanently',
130
+ headers: { location: { value: target.redirect + queryString(request.querystring) } },
131
+ };
132
+ }
133
+ // A key that never exists, so S3 answers 404 and CloudFront serves the 404 page.
134
+ request.uri = '/v/' + version + '/' + (target.missing ? '_sforge/missing' : target.file);
135
+ return request;
136
+ }