TORICO Tech ブログhttps://tech.torico-corp.com/blog/2024-03-28T10:13:39+00:00株式会社TORICO 技術開発チームのブログSvelte で作ったWebコンポーネントを接続国によって出し分ける2022-12-03T15:17:43+00:002024-03-28T10:13:39+00:00四柳剛https://tech.torico-corp.com/blog/author/yotsuyanagi/https://tech.torico-corp.com/blog/svelte-web-component-cloudfront-country-header-dispatch/<p></p>
<h2>概要</h2>
<p>11月28日、当社 TORICO の初の海外店舗、マンガ展 台湾 がオープンしました。</p>
<p><a href="https://world.manga10.com/" target="_blank">漫畫展 台灣</a></p>
<p>オープンする直前で、「漫画全巻ドットコム、スキマ、マンガ展、ホーリンラブブックスのサイトに、台湾からアクセスされた方に告知を出したい。告知を1回クリックしたらもう出さないようにしたい。」という開発要件が出たので、作ってみました。</p>
<h3>接続元の判定と出し分け</h3>
<p>今回は、 AWS の CloudFront で、接続元の国名のヘッダを付与する機能を使うことにしました。</p>
<p>CloudFront の入口でリクエストヘッダが付与されますので、CloudFront Functions でそのヘッダを見て動的な処理が行えます。<br/>ヘッダに応じて、対象国であれば、バックエンドのパスを変化させることで、接続国に応じた処理を行えるように設計しました。</p>
<p>サーバの管理はなるべく減らしたいため、今回は完全にサーバレスでサービスを作っています。動的な処理は、CloudFront Function を使いました。</p>
<h3>コンポーネントの開発</h3>
<p>全サイトに共通のモーダルポップアップ表示を行う必要があります。当社エンジニアのまるさんから、Svelteを進められたので、Svelte で Web コンポーネントを書きました。</p>
<h2>完成物</h2>
<ul>
<li><a href="https://www.mangazenkan.com/" target="_blank">漫画全巻ドットコム</a></li>
<li><a href="https://www.sukima.me/" target="_blank">スキマ</a></li>
<li><a href="https://www.manga10.com/" target="_blank">マンガ展</a></li>
<li><a href="https://www.horinlovebooks.com/" target="_blank">ホーリンラブブックス</a></li>
</ul>
<p>これらのサイトに、台湾からアクセスすると、モーダルポップアップが表示されます。</p>
<p>仕組みとしては、リクエストするスクリプトの内容が、接続国に応じて変化するようになっています。</p>
<h2>Svelte で Web コンポーネントを作る</h2>
<h3>Svelte の概要</h3>
<p>Svelte は、Vue のシングルファイルコンポーネントによく似た形で、1ファイルで1コンポーネントをちょうどよく書けます。</p>
<p>CSS はスコープドなものになり、変数は特に意識せずともリアクティブに扱うことができます。</p>
<p>Web コンポーネントとは、JS によって作られた独自の HTML タグをどんなサイトでも使えるようにする技術です。<br/>コンポーネントを定義した JS ファイルをロードすることで、そのサイトで <code><my-component></my-component></code> のような独自のタグを使えるようになります。<br/>そのサイトが 素の HTML + jQuery で作られているサービスでも、React や Vue で作られているサイトでも、Google Tag Manager からでも同じようにコンポーネントが使えます。</p>
<p>複数サービスで共通のチャットウィジェットや、広告ウィジェットの開発に適しています。</p>
<h3>プロジェクトの開始</h3>
<p>Svelte のプロジェクトの開始方法は、チュートリアルがあるのでこの手順で作成できます。</p>
<p><a href="https://svelte.jp/docs#getting-started">https://svelte.jp/docs#getting-started<br/></a></p>
<pre>npm create vite@latest myapp</pre>
<p>対話的に質問されますので、適切に答えます。</p>
<p><img alt="" height="269" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-6/world-visitor/svelte-start.png" width="591"/></p>
<pre>Select a framework: Svelte<br/>Select a variant: TypeScript</pre>
<p>※ SvelteKit は今回使いませんでした。<br/>単純な Web コンポーネントであれば、特に使わなくても良いと思います。</p>
<h3>起動</h3>
<pre>npm run dev</pre>
<p>カウンターが起動します。</p>
<p><img alt="" height="472" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-6/world-visitor/svelte-run.png" width="554"/></p>
<h3>Webコンポーネント化する</h3>
<p>このカウンターを、Webコンポーネントとしてビルドをできるようにします。</p>
<p><span style="color: #999999;">実際の商用コードはカウンターとは違うものですが、今回は例としてカウンターのコンポーネントを使います。</span></p>
<p>Svelte コンポーネントを Web コンポーネントとして書き出すには、既に良い記事がいくつかあります。</p>
<p><a href="https://qiita.com/oekazuma/items/3aa64516362a50be3fbb" target="_blank">SvelteではじめるWeb Components開発 - Qiita</a></p>
<p>src/main.ts の内容を下記の1行に修正</p>
<pre>export * from './lib/Counter.svelte'</pre>
<p>src/lib/Counter.svelte の最上部に1行追加</p>
<pre><svelte:options tag="my-counter" /></pre>
<p>ルートディレクトリの index.html の内容を書きに修正</p>
<pre><span><!DOCTYPE html><br/><html lang="en"><br/> <head><br/> <meta charset="UTF-8" /><br/> <link rel="icon" type="image/svg+xml" href="/vite.svg" /><br/> <meta name="viewport" content="width=device-width, initial-scale=1.0" /><br/> <title>Vite + Svelte + TS</title><br/> </head><br/> <body><br/> <my-counter></my-counter><br/> <script type="module" src= "/src/main.ts"></script><br/> </body><br/></html></span></pre>
<p>vite.config.ts を以下のように修正します。</p>
<pre>import {defineConfig} from 'vite'<br/>import {svelte} from '@sveltejs/vite-plugin-svelte'<br/><br/>// https://vitejs.dev/config/<br/>export default defineConfig({<br/> build: {<br/> lib: {<br/> entry: './src/main.ts',<br/> name: 'my-counter',<br/> fileName: (format) => `my-counter.${format}.js`,<br/> formats: ['es'],<br/> },<br/> },<br/> plugins: [svelte({<br/> compilerOptions: {<br/> customElement: true,<br/> },<br/> }),]<br/>})</pre>
<p><code>formats</code> については、 ブラウザ用の JS を作るか、サーバ用の JS を作るかでいくつかの形式を指定できます。<br/>デフォルトでは <code>es</code> と <code>umd</code> で、<code>umd</code> はサーバサイドでの実行に使うものです。今回は、ブラウザ JS だけあれば良いので、<code>es</code> のみにしています。</p>
<h4>独自のコンポーネントを開発する</h4>
<p>今回は、開発したプロダクションコードの具体的な内容は割愛します。</p>
<h3>開発サーバを実行</h3>
<pre>npm run dev</pre>
<p><img alt="" height="34" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-6/world-visitor/counter-component.gif" width="90"/></p>
<h3>ビルド</h3>
<p>動作が確認できたら、JS ファイルをビルドします。</p>
<pre>npm run build</pre>
<p><code>dist/my-counter.es.js</code> が作成されます。</p>
<h3>成果物のテスト</h3>
<p>ビルド成果物を確認するには、プロジェクトのルートディレクトリに</p>
<pre><my-counter></my-counter><br/><script type="module" src= "dist/my-counter.es.js"></script></pre>
<p>の内容を含む HTML を作り、ダブルクリックで起動すると良いと思います。</p>
<p><code>npm run preview</code> というコマンドもありますが、dist ディレクトリ以下に index.html が必要で、<br/>dist ディレクトリはビルドするたびに消えてしまうのでコンポーネント開発では使いづらそうです。</p>
<p>実際に他のサービスからコンポーネントを使う際も、上記のようなスクリプトタグで使うことができます。その際、 <strong><code>script</code> タグに <code>type="module"</code> を忘れずに指定します</strong>。ビルドしたコードには、ファイル直下に <code>$</code> といった短い名前の関数が作られます。そのため、<code>type="module"</code> なしでグローバル領域に読み込んだ場合、既存のスクリプトで jQuery 等を使っている場合に誤動作につながります。</p>
<p>なお、<code>type="module"</code> をつけた場合、そのスクリプトは CORS 判定の対象となりますので、サーバ側は <strong><code>access-control-allow-origin</code> の HTTP ヘッダを必ず返す</strong>必要があります。</p>
<h2>S3の設定</h2>
<p>本番環境で使えるようにするにするため、ビルド成果物の JS を、S3 にデプロイして静的サイトホスティングの機能でホスティングします。</p>
<h3>バケットの命名の注意</h3>
<p>S3 のバケットを作る際は、<strong>バケット名をサービスを提供する URL のホスト名と完全に同じにする必要があります</strong>。</p>
<p>CloudFront で、CloudFront Functions を使ってリクエストヘッダーを操作する場合、S3に伝わる Host ヘッダーは必ずリクエストしている Host ヘッダーとなり、 <strong>Functions 内で変更することは制限されておりできない</strong>ためです。</p>
<h3>静的ウェブサイトホスティングの設定</h3>
<p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteHosting.html" target="_blank">Hosting a static website using Amazon S3 - Amazon Simple Storage Service</a></p>
<p>S3 の「プロパティ」タブの一番下から設定します。</p>
<h3>バケットポリシーの設定</h3>
<p>「アクセス許可」タブ内にバケットポリシーの設定欄があります。</p>
<p>パブリックでの読み取りを許可するポリシーにします。</p>
<pre>{<br/> "Version": "2012-10-17",<br/> "Statement": [<br/> {<br/> "Sid": "PublicRead",<br/> "Effect": "Allow",<br/> "Principal": "*",<br/> "Action": [<br/> "s3:GetObject",<br/> "s3:GetObjectVersion"<br/> ],<br/> "Resource": "arn:aws:s3:::world-visitor.torico-corp.com/*"<br/> }<br/> ]<br/>}</pre>
<h3>CORSヘッダーの設定</h3>
<p>「アクセス許可」タブ内の一番下に Cross-Origin Resource Sharing (CORS) の設定欄があります。</p>
<pre>[<br/> {<br/> "AllowedHeaders": [<br/> "Authorization"<br/> ],<br/> "AllowedMethods": [<br/> "GET",<br/> "HEAD"<br/> ],<br/> "AllowedOrigins": [<br/> "*"<br/> ],<br/> "ExposeHeaders": []<br/> }<br/>]</pre>
<h3>ファイルのデプロイ</h3>
<p>ビルド成果物を S3 にアップロードします。</p>
<p>接続国により2つのファイルを出し分ける設計とするため、今回のビルド成果物の他に、空の JS も別のファイル名でデプロイしておきます。</p>
<h2>CloudFront の設定</h2>
<h3>オリジン</h3>
<p>先程作成した、S3 の WebホスティングのURLをオリジンとします。</p>
<h3>接続国に応じてリクエストヘッダーを付与する設定</h3>
<p>CloudFront の「オリジンリクエストポリシー」という機能で、リクエスト国別のヘッダを付与することができます。</p>
<p>CloudFront の ポリシーページの、オリジンリクエストのカスタムポリシーで、「オリジンリクエストポリシーの作成」を選択し、追加するヘッダーを選べます。</p>
<p><a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-cloudfront-headers.html" target="_blank">Adding the CloudFront HTTP headers - Amazon CloudFront</a></p>
<p>CloudFront-Viewer-Country を追加すると、ヘッダーの値として2文字の国名コードが追加されます。「JP」等。大文字になります。</p>
<p><img alt="" height="635" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-6/world-visitor/cf-headers.png" width="816"/></p>
<p><img alt="" height="773" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-6/world-visitor/cf-origin-request-policy.png" width="866"/></p>
<h3>リクエストヘッダによってオリジンに要求するファイルのパスを変えるファンクション</h3>
<p>付与したリクエストヘッダーを CloudFront Functions で判定し、リクエストパスを変化させます。<br/>今回はこのようなスクリプトになりました。</p>
<p>なお、handle の引数の event の構造はこのページで紹介されています。</p>
<p><a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/functions-event-structure.html" target="_blank">CloudFront Functions event structure - Amazon CloudFront</a></p>
<pre>/*<br/>接続元の国によってリクエストパスにプレフィックスをつけるファンクション<br/>*/<br/><br/>/// 国コードを取得<br/>function getCountryCode(event) {<br/> return event.request.headers['cloudfront-viewer-country']<br/> ? event.request.headers['cloudfront-viewer-country'].value<br/> : ''<br/>}<br/><br/>/// 国コード(大文字)から判断するディレクトリプレフィックス<br/>/// SG は初回は必要無いが、検証のために追加<br/>function getDirectoryPrefix(countryCode) {<br/> if(['TW', 'SG'].includes(countryCode)) {<br/> return '/tw';<br/> }<br/> return ''<br/>}<br/><br/>/// uri (パス) はルーティング対象か<br/>// 検証用の index.html はルーティングしたくない。<br/>function uriRootingRequired(uri) {<br/> return ['/my-component.es.js'].includes(uri);<br/>}<br/><br/>function handler(event) {<br/> var countryCode = getCountryCode(event);<br/> var directoryPrefix = getDirectoryPrefix(countryCode);<br/> if (directoryPrefix && uriRootingRequired(event.request.uri)) {<br/> event.request.uri = directoryPrefix + event.request.uri;<br/> }<br/> return event.request;<br/>}<br/><br/></pre>
<h2>Google Tag Manager でロードする</h2>
<p>今回作った Svelte のコンポーネントは、 Google Tag Manager からでも使うことができます。</p>
<p>ただし、Google Tag Manager で<strong>独自の HTML タグを書くとエラーとなって公開できない</strong>ため、<br/><strong><code>document.createElement</code> でタグを作成</strong>しています。</p>
<p>また、<strong><code>script</code> タグに <code>type="module"</code> 忘れずに指定</strong>します。これをつけないとグローバル変数領域にロードされてしまうため、既存のスクリプトに悪影響があります。</p>
<p>そして、<code>type="module"</code> を入れた場合、なぜか<strong>「document.writeをサポートする」にチェックが入ってないとスクリプトが動作しない</strong>ため、チェックを入れます。</p>
<p><img alt="" height="646" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-6/world-visitor/google-tag-manager.png" width="942"/></p>