概要
VueでSPAを作ってS3に配置し、CloudFront経由で公開できるようにした。
この時、以下の問題が発生する。
- ブラウザで直接SPAが生成したURL(例:
http://oursite.com/user/id
)にアクセスすると404となる - SPAのページごとにヘッダを変えられない。
- OGPを設定できない。
- TwitterにURLをシェアしてもトップのOGPやタイトルしか表示されず残念
- OGP画像はTwitterなどのサイトからJSで読み込まれるため、CORSの同一オリジンポリシーにより画像のリクエストはブロックされる
- S3とCloudFrontにCORSの許可設定が必要
今回はこれを解決するための設定をCloudFront+Lambda@Edgeを利用して行う。
設定にはAWS CDKを利用する。
概要図
- ユーザからのアクセスにはS3に置かれたSPAのリソースを返す
- OGPのアクセス(Twitterなど)にはOGP用のレスポンスを作成して返す
検討
この構成にするまでに検討した事項について記載する
使用するCloudFrontイベント
CloudFrontイベントは以下の4つが存在する。
今回は、ユーザとTwitterbotの区別をUser-agent
を使って行う予定のため、ビューワーリクエストイベントに関数を紐づける。
イベント | タイミング | キャッシュがある場合 | 備考 |
---|---|---|---|
ビューワーリクエスト | ビューワーからリクエストを受け取った時 | 実行する | |
オリジンリクエスト | リクエストをオリジンに転送したとき | 実行しない | 特に設定しないとUser-agentがAmazon CloudFront に書き換えられた状態で関数に渡される |
オリジンレスポンス | オリジンからのレスポンスを受け取った後 | 実行しない | レスポンス内のオブジェクトをキャッシュする前に関数が実行される。関数は、オリジンからエラーが返された場合でも実行されることに注意。 |
ビューワーレスポンス | リクエストされたファイルがビューワーに返される前 | 実行する | ビューワーリクエストイベントによってトリガーされた関数からレスポンスが生成された場合には実行しない |
図はLambda 関数をトリガーできる CloudFront イベントから抜粋
CloudFront Functions vs Lambda@Edge
ビューワーリクエストに設定できる関数はCloudFront Functions
と Lambda@Edge
の2種類が存在する。
(参考: CloudFront Functions と Lambda@Edge の選択)
今回はOGP用の文言を取得するのにHTTP通信を利用するため、最大実行時間が1msのCloudFront Functions
は不適と考えLambda@Edge
を利用する。
特徴 | CloudFront Functions | Lambda@Edge |
---|---|---|
プログラミング言語 | JavaScript (ECMAScript 5.1 準拠) | Node.js と Python |
関数コードと含まれるライブラリの最大サイズ | 10 KB | 1 MB |
最大メモリ | 2 MB | 128MB |
最大実行時間 | 1ミリ秒 * | 5秒 |
CDK CloudFrontのDistributeionについて
cloudfrontを行えるクラスがDistribution
とCloudFrontWebDistribution
の2種類あったため、検討する。
@aws-cdk/aws-cloudfront moduleを見ると、Distribution
はCloudFrontWebDistribution
を置き換えるもの。そのため、今回はDistribution
を利用する。
設定
ここからは設定項目について記載する。
環境
- Windows 10 Home
- nodejs v14.17.5
- @aws/cdk 1.120.0
ローカルのフォルダ構造
+ client ... SPAのVueファイルが格納されたフォルダ
+ dist ... SPAのVueファイルのビルド成果物が格納されたフォルダ
- cdk
- bin
- index.ts ... CDKの起点ファイル
- lib
- cdk-stack.ts ... CDKStackファイル
- dist ... lambda@Edgeのデプロイ用ソース。git管理外
- ogp
- index.js
- src
- ogp
- index.ts ... lambda@Edgeのソース
- build.js ... lambda@Edgeをトランスパイルするためのesbuildの呼び出しファイル
- .env ... lambda@Edgeのトランスパイル時に定数を埋め込むための環境変数ファイル
- package.json
- cdk.json
- tsconfig.json
S3の構造
- data
+ background-images ... OGP用の画像フォルダ
- whitemap ... SPA用のフォルダ
- index.html
https://ドメイン/whitemap/scene/:id
のパスで、各記事のページにアクセスするようにしている。
ブラウザで直接SPAが生成したURLにアクセスすると404となる件の対応
カスタムレスポンスを利用する。
return new cf.Distribution(this, 'Distribution', {
defaultRootObject: '/index.html',
priceClass: cf.PriceClass.PRICE_CLASS_200,
defaultBehavior: {
origin,
allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD,
cachedMethods: cf.CachedMethods.CACHE_GET_HEAD,
cachePolicy: myCachePolicy,
viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS
},
errorResponses: [
+ {
+ httpStatus: 404,
+ responseHttpStatus: 200,
+ responsePagePath: "/whitemap/index.html",
+ ttl: core.Duration.seconds(0),
+ }
]
})
cdk全文
import * as core from '@aws-cdk/core'
import * as s3 from '@aws-cdk/aws-s3'
import * as cf from '@aws-cdk/aws-cloudfront'
import * as iam from '@aws-cdk/aws-iam'
import * as s3deploy from '@aws-cdk/aws-s3-deployment'
import { basePath } from '../../vite.config'
import * as lambda from "@aws-cdk/aws-lambda";
import * as origins from '@aws-cdk/aws-cloudfront-origins';
interface Props extends core.StackProps {
bucketName: string
}
export class AWSWhiteMapClientStack extends core.Stack {
constructor(scope: core.Construct, id: string, props: Props) {
super(scope, id, props)
// CloudFront オリジン用のS3バケットを作成
const bucket = this.createS3(props.bucketName)
// CloudFront で設定する オリジンアクセスアイデンティティ を作成
const identity = this.createIdentity(bucket)
// S3バケットポリシーで、CloudFrontのオリジンアクセスアイデンティティを許可
this.createPolicy(bucket, identity)
// lambda edge作成
const f = this.createLambdaEdge()
// CloudFrontディストリビューションを作成
const distribution = this.createCloudFront(bucket, identity, f)
// 指定したディレクトリをデプロイ
this.deployS3(bucket, distribution, '../dist')
// 確認用にCloudFrontのURLに整形して出力
new core.CfnOutput(this, 'CFTopURL', {
value: `https://${distribution.distributionDomainName}/`,
})
}
private createS3(bucketName: string) {
const bucket = new s3.Bucket(this, 'S3Bucket', {
bucketName,
accessControl: s3.BucketAccessControl.PRIVATE,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: core.RemovalPolicy.DESTROY,
cors: [{ allowedMethods: [s3.HttpMethods.GET], allowedOrigins: ['*'], allowedHeaders: ['*'] }]
})
return bucket
}
private createIdentity(bucket: s3.Bucket) {
const identity = new cf.OriginAccessIdentity(this, 'OriginAccessIdentity', {
comment: `${bucket.bucketName} access identity`,
})
return identity
}
private createPolicy(bucket: s3.Bucket, identity: cf.OriginAccessIdentity) {
const myBucketPolicy = new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:GetObject',
"s3:ListBucket"],
principals: [
new iam.CanonicalUserPrincipal(
identity.cloudFrontOriginAccessIdentityS3CanonicalUserId,
),
],
resources: [bucket.bucketArn + '/*',
bucket.bucketArn],
})
bucket.addToResourcePolicy(myBucketPolicy)
}
private createCloudFront(
bucket: s3.Bucket,
identity: cf.OriginAccessIdentity,
f: cf.experimental.EdgeFunction
) {
const defaultPolicyOption = {
cachePolicyName: 'MyPolicy',
comment: 'A default policy',
defaultTtl: core.Duration.days(2),
minTtl: core.Duration.seconds(0), // core.Duration.minutes(1),
maxTtl: core.Duration.days(365), // core.Duration.days(10),
cookieBehavior: cf.CacheCookieBehavior.all(),
headerBehavior: cf.CacheHeaderBehavior.none(),
queryStringBehavior: cf.CacheQueryStringBehavior.none(),
enableAcceptEncodingGzip: true,
enableAcceptEncodingBrotli: true,
}
const myCachePolicy = new cf.CachePolicy(this, 'myDefaultCachePolicy', defaultPolicyOption);
const imgCachePolicy = new cf.CachePolicy(this, 'myImageCachePolicy', {
headerBehavior: cf.CacheHeaderBehavior.allowList('Access-Control-Request-Headers', 'Access-Control-Request-Method', 'Origin'),
});
const origin = new origins.S3Origin(bucket, { originAccessIdentity: identity })
return new cf.Distribution(this, 'Distribution', {
defaultRootObject: '/index.html',
priceClass: cf.PriceClass.PRICE_CLASS_200,
defaultBehavior: {
origin,
allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD,
cachedMethods: cf.CachedMethods.CACHE_GET_HEAD,
cachePolicy: myCachePolicy,
viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS
},
additionalBehaviors: {
'whitemap/scene/*': {
origin,
viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
edgeLambdas: [
{
eventType: cf.LambdaEdgeEventType.VIEWER_REQUEST,
functionVersion: f.currentVersion,
includeBody: true,
},
],
},
'data': {
origin,
cachePolicy: imgCachePolicy,
allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS
}
},
errorResponses: [
{
httpStatus: 404,
responseHttpStatus: 200,
responsePagePath: "/whitemap/index.html",
ttl: core.Duration.seconds(0),
},
{
httpStatus: 403,
responseHttpStatus: 200,
responsePagePath: "/index.html",
ttl: core.Duration.seconds(0),
}
]
})
}
private deployS3(
siteBucket: s3.Bucket,
distribution: cf.Distribution,
sourcePath: string,
) {
// Deploy site contents to S3 bucket
new s3deploy.BucketDeployment(this, 'DeployWithInvalidation', {
sources: [s3deploy.Source.asset(sourcePath)],
destinationBucket: siteBucket,
distribution,
distributionPaths: ['/*'],
destinationKeyPrefix: basePath,
})
}
private createLambdaEdge() {
const f = new cf.experimental.EdgeFunction(this, "lambda-edge", {
code: lambda.Code.fromAsset("dist/ogp"),
handler: "index.handler",
runtime: lambda.Runtime.NODEJS_14_X,
});
return f;
}
}
SPAのページごとにヘッダを変えられない。
Lambda@Edgeを利用する。
- Botからのアクセスでない場合は通常のCloudFrontのフロー
- Botからのアクセスの場合、FireStoreからデータを取得してOGP用のHTMLを作成する
- FireStoreからの読込については認証の制限をかけていないパスなので、Cloud Firestore REST API を使用してHTTPSアクセスを行い、データを取得している。
ハンドラ
export const handler: CloudFrontRequestHandler = async (event) => {
const request = event.Records[0].cf.request;
const userAgent = request.headers['user-agent'][0].value;
// Botからのアクセスでない場合は通常のCloudFrontのフロー
if (!bots.some((bot) => userAgent.includes(bot))) {
return request;
}
// Botからのアクセスの場合、FireStoreからデータを取得してOGP用のHTMLを作成する
const matches = /scene\/([^/]+)/.exec(request.uri)
if (!matches) {
return request;
}
const [, sceneId] = matches;
const scene = await httpGet<FireStoreScene>(`https://${firestoreApiPath}/scenes/${sceneId}`);
const sceneBgUrl = getBgUrl(scene);
const url = sceneBgUrl ? sceneBgUrl : defaultBg
const title = getTitle(scene);
// Create OGP response
const botResponse = {
status: '200',
headers: { 'content-type': [{ value: 'text/html;charset=UTF-8' }] },
body: getHTML(title, url, DOMAIN + request.uri)
};
return botResponse;
};
HTML作成処理
const getHTML = (title: string, ogImage: string, url: string) => {
return `
<!doctype html>
<html lang="ja" prefix="og: http://ogp.me/ns#">
<head prefix="og: http://ogp.me/ns#">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}|${SERVICE_NAME}</title>
<meta name="description" content="${DESCRIPTION}" />
<meta name="author" content="hibohiboo">
<meta name="keywords" content="TRPG,白地図と足跡,紙芝居" />
<meta property="og:type" content="article" />
<meta property="og:locale" content="ja_JP" />
<meta property="og:site_name" content="${SERVICE_NAME}">
<meta property="og:title" content="${title}|${SERVICE_NAME}" />
<meta property="og:description" content="${DESCRIPTION}" />
<meta property="og:image" content="${ogImage}" />
<meta property="og:url" content="https://${url}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@hibohiboo" />
<meta name="twitter:creator" content="@hibohiboo" />
</head>
<body></body>
</html>
`;
};
lambda全文
import * as https from 'https';
import type { CloudFrontRequestHandler } from 'aws-lambda';
import { FireStoreScene, SceneBg } from './types';
declare var DEFINE_DOMAIN: string;
declare var DEFINE_SERVICE_NAME: string;
declare var DEFINE_DESCRIPTION: string;
declare var DEFINE_FIREBASE_PROJECT_ID: string;
declare var DEFINE_DEFAULT_OGP_IMAGE_URL: string;
const DOMAIN = DEFINE_DOMAIN;
const SERVICE_NAME = DEFINE_SERVICE_NAME;
const DESCRIPTION = DEFINE_DESCRIPTION;
const projectId = DEFINE_FIREBASE_PROJECT_ID;
const defaultBg = DEFINE_DEFAULT_OGP_IMAGE_URL;
const firestoreApiPath = `firestore.googleapis.com/v1/projects/${projectId}/databases/(default)/documents`;
// OGP を返したい User-Agent をリストで定義しておく。
const bots = [
'Twitterbot',
'facebookexternalhit',
'Slackbot-LinkExpanding'
];
const httpGet = <T>(url: string): Promise<T> => new Promise((resolve, reject) => {
https.get(url, (res) => {
const { statusCode } = res;
const contentType = res.headers['content-type'];
let error;
if (statusCode !== 200) {
error = new Error('Request Failed.\n' +
`Status Code: ${statusCode}`);
} else if (!contentType || !/^application\/json/.test(contentType)) {
error = new Error('Invalid content-type.\n' +
`Expected application/json but received ${contentType}`);
}
if (error) {
console.error(error.message);
res.resume();
reject(error);
return;
}
res.setEncoding('utf8');
let rawData = '';
res.on('data', (chunk) => { rawData += chunk; });
res.on('end', () => {
try {
const parsedData = JSON.parse(rawData);
resolve(parsedData)
} catch (e) {
console.error(e.message);
reject(e);
}
});
}).on('error', (e) => {
console.error(`Got error: ${e.message}`);
reject(e)
});
})
const getBgUrl = (scene: FireStoreScene) => {
const bg = scene.fields.bg as SceneBg
if (!bg.mapValue) {
return null;
}
return bg.mapValue.fields.url.stringValue
}
const getTitle = (scene: FireStoreScene) => {
return scene.fields.title.stringValue;
}
const getHTML = (title: string, ogImage: string, url: string) => {
return `
<!doctype html>
<html lang="ja" prefix="og: http://ogp.me/ns#">
<head prefix="og: http://ogp.me/ns#">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}|${SERVICE_NAME}</title>
<meta name="description" content="${DESCRIPTION}" />
<meta name="author" content="hibohiboo">
<meta name="keywords" content="TRPG,白地図と足跡,紙芝居" />
<meta property="og:type" content="article" />
<meta property="og:locale" content="ja_JP" />
<meta property="og:site_name" content="${SERVICE_NAME}">
<meta property="og:title" content="${title}|${SERVICE_NAME}" />
<meta property="og:description" content="${DESCRIPTION}" />
<meta property="og:image" content="${ogImage}" />
<meta property="og:url" content="https://${url}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@hibohiboo" />
<meta name="twitter:creator" content="@hibohiboo" />
</head>
<body></body>
</html>
`;
};
export const handler: CloudFrontRequestHandler = async (event) => {
const request = event.Records[0].cf.request;
const userAgent = request.headers['user-agent'][0].value;
const isBotAccess = bots.some((bot) => userAgent.includes(bot));
if (!isBotAccess) {
return request;
}
const matches = /scene\/([^/]+)/.exec(request.uri)
if (!matches) {
return request;
}
const [, sceneId] = matches;
const scene = await httpGet<FireStoreScene>(`https://${firestoreApiPath}/scenes/${sceneId}`);
const sceneBgUrl = getBgUrl(scene);
const url = sceneBgUrl ? sceneBgUrl : defaultBg
const title = getTitle(scene);
// Create OGP response
const botResponse = {
status: '200',
headers: { 'content-type': [{ value: 'text/html;charset=UTF-8' }] },
body: getHTML(title, url, DOMAIN + request.uri)
};
return botResponse;
};
interface NullValue {
nullValue: null
}
interface StringValue {
stringValue: string
}
interface TimestampValue {
mapValue: {
fields: {
seconds: StringValue
nanoseconds: StringValue
}
}
}
export interface SceneBg {
mapValue: {
fields: {
url: StringValue
}
}
}
export interface FireStoreScene {
name: string,
fields: {
bg: SceneBg | NullValue
title: StringValue
}
}
ドメインなど、環境によって変化する部分はesbuildを使って環境変数を定数に置き換える処理を行っている。(参考:define)
declare var DEFINE_DOMAIN: string;
declare var DEFINE_SERVICE_NAME: string;
declare var DEFINE_DESCRIPTION: string;
declare var DEFINE_FIREBASE_PROJECT_ID: string;
declare var DEFINE_DEFAULT_OGP_IMAGE_URL: string;
const DOMAIN = DEFINE_DOMAIN;
const SERVICE_NAME = DEFINE_SERVICE_NAME;
const DESCRIPTION = DEFINE_DESCRIPTION;
const projectId = DEFINE_FIREBASE_PROJECT_ID;
const defaultBg = DEFINE_DEFAULT_OGP_IMAGE_URL;
require('dotenv').config();
require('esbuild').build({
entryPoints: ['src/ogp/index.ts'],
bundle: true,
platform: 'node',
outfile: 'dist/ogp/index.js',
define: {
"DEFINE_DOMAIN": `'${process.env.ENV_DEFINE_DOMAIN}'`,
"DEFINE_SERVICE_NAME": `'${process.env.ENV_DEFINE_SERVICE_NAME}'`,
"DEFINE_DESCRIPTION": `'${process.env.ENV_DEFINE_DESCRIPTION}'`,
"DEFINE_FIREBASE_PROJECT_ID": `'${process.env.ENV_DEFINE_FIREBASE_PROJECT_ID}'`,
"DEFINE_DEFAULT_OGP_IMAGE_URL": `'${process.env.ENV_DEFINE_DEFAULT_OGP_IMAGE_URL}'`,
},
minify: false,
sourcemap: false,
target: ['node14.17'],
}).catch(() => process.exit(1))
ENV_DEFINE_DOMAIN=hoge.cloudfront.net
ENV_DEFINE_SERVICE_NAME=サイト名
ENV_DEFINE_DESCRIPTION=説明
ENV_DEFINE_FIREBASE_PROJECT_ID=xxx
ENV_DEFINE_DEFAULT_OGP_IMAGE_URL=https://piyo/fuga.png
package.jsonのスクリプトで、デプロイする前にビルドが走るように設定している。(参考: npm scripts)
{
"scripts": {
+ "predeploy": "node build.js",
+ "deploy": "cdk deploy --all"
},
"devDependencies": {
"dotenv": "^10.0.0",
"esbuild": "^0.12.24",
"typescript": "~4.4.2"
}
}
CDKではCloudFrontのscene
以下のパスが来た時にLambda@Edgeを動かすようにする。
return new cf.Distribution(this, 'Distribution', {
additionalBehaviors: {
'whitemap/scene/*': {
origin,
viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
+ edgeLambdas: [
+ {
+ eventType: cf.LambdaEdgeEventType.VIEWER_REQUEST,
+ functionVersion: f.currentVersion,
+ includeBody: true,
+ },
],
},
Lambda@Edgeの作成には experimental.EdgeFunction
クラスを使用する。
これを使うと、US-East-1 (バージニア北部) リージョン (us-east-1) にStackを作成してLambdaを配置してくれる。
const f = new cf.experimental.EdgeFunction(this, "lambda-edge", {
code: lambda.Code.fromAsset("dist/ogp"),
handler: "index.handler",
runtime: lambda.Runtime.NODEJS_14_X,
});
デプロイの初回でエラーが出た。
新しくUS-East-1にStackが作られるため、そのリージョンでのcdk bootstrap
を忘れていたのが原因。
Error
$npm run deploy
> cdk@0.1.0 deploy cdk
> cdk deploy --all
edge-lambda-stack-xxx
edge-lambda-stack-xxx: deploying...
❌ edge-lambda-stack-xxx failed: Error: This stack uses assets, so the toolkit stack must be deployed to the environment
(Run "cdk bootstrap aws://unknown-account/us-east-1")
at Object.addMetadataAssetsToManifest
at Object.deployStack
at processTicksAndRejections (internal/process/task_queues.js:95:5)
at CdkToolkit.deploy
at initCommandLine
This stack uses assets, so the toolkit stack must be deployed to the environment (Run "cdk bootstrap aws://unknown-account/us-east-1")
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! cdk@0.1.0 deploy: `cdk deploy --all`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the cdk@0.1.0 deploy script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
CORSの同一オリジンポリシーにより画像のリクエストがブロックされる
S3のCORS設定。(*)
private createS3(bucketName: string) {
const bucket = new s3.Bucket(this, 'S3Bucket', {
bucketName,
accessControl: s3.BucketAccessControl.PRIVATE,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: core.RemovalPolicy.DESTROY,
+ cors: [{ allowedMethods: [s3.HttpMethods.GET], allowedOrigins: ['*'], allowedHeaders: ['*'] }]
})
return bucket
}
cloud frontのcors設定。(*)
data以下のパスの場合にCORSのヘッダを許可する。
return new cf.Distribution(this, 'Distribution', {
additionalBehaviors: {
+ 'data': {
+ origin,
+ cachePolicy: imgCachePolicy,
+ allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
+ viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS
+ }
},
動作確認
lambda@Edgeの確認
実際のレスポンスの確認
- User Agentの有無でレスポンスが変わることを、直接URLを叩いて確認する。
GET https://hoge.cloudfront.net/whitemap/scene/fuga/ HTTP/1.1
###
GET https://hoge.cloudfront.net/whitemap/scene/fuga/ HTTP/1.1
user-agent: Twitterbot
ログの確認
- Lambda@EdgeはCloudFrontの実行されたリージョンにログが出力される。
まずはCloudFront
のモニタリングから動作ログを確認。
どのリージョンで実行されているかを確認できる。
Us-east
で動作していることを確認したので、そのリージョンのログを確認する。
OGPの確認
【配布】OGP画像のテンプレートと作成方法を参考にした。
twitter - Card Validator ... 実際のOGP確認
OGP画像シミュレータ ... OGP画像確認
OGP確認 ... OGP表示できず。各種OGP確認できるらしいが、今回は失敗した。
なお、ドメインの変更は、次の記事で行っている。
Route53とCloudFrontの紐づけとSSL証明書の発行をCDKを使って行ったメモ
参考
OGP
Lambda@EdgeをCDKで書いてみる
@AWS 環境の SPA で動的 OGP を実現する
CloudFront+S3なSPAにLambda@EdgeでOGP対応する
AmplifyでOGP対応はできない。でもLambda@edgeを使えば大丈夫!
Lambda@EdgeでSPAのOGPを動的に設定する
Firebase + SPA + CloudFront + Lambda で SSR なしに OGP 対応
AWS Lambda@Edgeのログはどこ?AWS Lambda@Edgeのログ出力先について
AWS CDK — A Beginner’s Guide with Examples
【配布】OGP画像のテンプレートと作成方法
AWS CDKでLambdaのTypescriptをトランスパイルしてローカル実行したメモ
esbuild
話題のesbuildをさっくりと調査してみた
build api
define
dotenv
そのほか
CloudFront FunctionsはLambda@Edgeより安い。それ本当?!
[AWS CDK] CloudFront Functionでレスポンスにセキュリティヘッダーを追加する
@aws-cdk/aws-cloudfront module
class CachePolicy
node.js https
Cloud Firestore REST API を使用する
Cloudfront+S3で最近話題のSPAを構築する(設定項目の解説有)
Vue Router HTML5 History モード
OGP
OGP 和訳
twitter - 公式: ツイートをカードで最適化する
aws公式
CloudFront ソリューションの概要
リクエストトリガーでの HTTP レスポンスの生成
Lambda@Edge
Lambda@Edge イベント構造
Lambda@Edge 関数の例
CloudFront Functions と Lambda@Edge の選択
Lambda 関数をトリガーできる CloudFront イベント
カスタムエラーレスポンスの生成