Node.jsでURLセーフなBase64変換したいときはbase64urlが使える
全然投稿できてないので小ネタをば。
Node.jsでBase64変換するときは Buffer#toString('base64') することが多いが、URLセーフなBase64にしたい場合、 Buffer#toString('base64url') が使える。
- Bufferで指定可能なエンコーディング: Buffer | Node.js v25.2.1 Documentation
base64urlは2025/12月時点でもっとも最近追加されたようだが、それでもNode.js v15.7.0, v14.18.0 なのでもうだいぶ前
- 追加された issue: Support for base64url format · Issue #26512 · nodejs/node · GitHub
コードレビューで buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '') としていたのを見かけたので、こう書けるよと教えたかったのだが、なんて指定するのかど忘れしたのでメモ。
ちなみに結果は上記replaceをしたものと同様。
const buffer = Buffer.from('~~~????') const base64 = buffer.toString('base64') // fn5+Pz8/Pw== const base64url = buffer.toString('base64url') // fn5-Pz8_Pw
いまだに検索すると、 buffer.toString('base64').replace... が上位に出てくるんだよな...
SSM Session Managerを使ってRDSへ接続できる環境をAWS CDKで作成する
検証環境として、RDSにSSM Session ManagerおよびNLBを用いたPrivateLinkでアクセスできる環境を構築することとなった。
今後も同じような環境を作ることになりそうなので、CDKで書くことにしたが、そのものズバリこうすればできる、といったサンプルが見つからなかった。
試行錯誤して環境構築できたので、まずはSSM Session ManagerでRDSにアクセスできる状態までをメモ。
実装
TypeScriptで記述。
環境変数は、 dotenv を追加し、 .env ファイルに記述している。
検証環境のため、EC2およびRDSは無料枠内のスペックで作成。
import { CfnOutput, Duration, SecretValue, Stack, StackProps, } from 'aws-cdk-lib' import { AmazonLinuxCpuType, Instance, InstanceClass, InstanceSize, InstanceType, InterfaceVpcEndpointAwsService, MachineImage, Peer, Port, SubnetType, Vpc, } from 'aws-cdk-lib/aws-ec2' import { Credentials, DatabaseInstance, DatabaseInstanceEngine, MysqlEngineVersion, } from 'aws-cdk-lib/aws-rds' import { Construct } from 'constructs' import 'dotenv/config' const { // RDSの管理者パスワード RDS_MYSQL_ADMIN_PASSWORD = '', } = process.env // 接続元のCIDRを設定 const validCidrs = [ ... ] as const const prefix = (name: string) => 'example-' + name export class CdkStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props) // サブネット指定用に変数を宣言 const ec2SubnetName = prefix('subnet-private-ec2') const rdsSubnetName = prefix('subnet-private-rds') const ec2Subnet = { subnetGroupName: ec2SubnetName } const rdsSubnet = { subnetGroupName: rdsSubnetName } // VPCの作成 // RDSは単一AZに配置するが、VPCのAZが1つだけだとRDSの配置がエラーとなるため、 // maxAzsには2以上を指定しておく const vpc = new Vpc(this, prefix('vpc'), { maxAzs: 2, natGateways: 0, subnetConfiguration: [ { cidrMask: 24, name: ec2SubnetName, subnetType: SubnetType.PRIVATE_ISOLATED, }, { cidrMask: 24, name: rdsSubnetName, subnetType: SubnetType.PRIVATE_ISOLATED, }, ], }) // 各インスタンスを単一のAZに配置するため取得 const availabilityZone = vpc.availabilityZones[0] const availabilityZones = [availabilityZone] // SSM用EC2インスタンスの作成 const ec2Instance = new Instance(this, prefix('bastion'), { vpc, availabilityZone, vpcSubnets: ec2Subnet, instanceType: InstanceType.of( InstanceClass.T4G, InstanceSize.MICRO, ), machineImage: MachineImage.latestAmazonLinux2023({ cpuType: AmazonLinuxCpuType.ARM_64, cachedInContext: true, }), ssmSessionPermissions: true, // SSMを有効化 }) // RDSインスタンスの作成 const rdsMysql = new DatabaseInstance(this, prefix('rds-mysql'), { engine: DatabaseInstanceEngine.mysql({ version: MysqlEngineVersion.VER_8_0_40, }), instanceType: InstanceType.of( InstanceClass.T4G, InstanceSize.MICRO, ), databaseName: 'example', credentials: Credentials.fromUsername('admin', { password: SecretValue.unsafePlainText(RDS_MYSQL_ADMIN_PASSWORD), }), allocatedStorage: 20, vpc, availabilityZone, vpcSubnets: rdsSubnet, multiAz: false, backupRetention: Duration.days(0), // バックアップなしに設定 }) // EC2からRDSへの接続を許可 rdsMysql.connections.allowDefaultPortFrom(ec2Instance) // SSM用VPCエンドポイントを作成 const ssmVpcEndpoints = [ InterfaceVpcEndpointAwsService.SSM, InterfaceVpcEndpointAwsService.SSM_MESSAGES, ].map(service => { const endpointId = prefix('vpc-endpoint-' + service.shortName) return vpc.addInterfaceEndpoint(endpointId, { service, subnets: { availabilityZones, ...ec2Subnet }, }) }) // SSM用VPCエンドポイントに、EC2および特定IPアドレスからの接続を許可 ssmVpcEndpoints.forEach(endpoint => { endpoint.connections.allowFrom(ec2Instance, Port.HTTPS) validCidrs.forEach(cidr => { endpoint.connections.allowFrom(Peer.ipv4(cidr), Port.HTTPS) }) }) // EC2のIDおよびRDSのエンドポイントを出力 const ountput = (id: string, value: string) => { new CfnOutput(this, id, { value }) } ountput('EC2_INSTANCE_ID', ec2Instance.instanceId) ountput('MYSQL_INSTANCE_HOSTNAME', rdsMysql.dbInstanceEndpointAddress) } }
解説
EC2インスタンス
Session Managerを使う場合、EC2インスタンスでAWS Systems Manager Agent(SSMエージェント)が実行されている必要がある。
Amazon Linux系AMIであれば、デフォルトでインストール済みかつインスタンス起動時に自動実行されるため、2025年現在は latestAmazonLinux2023 を使っておけばいい。
SSM Session Managerを使うためには、IAMロールを作成してAWS 管理ポリシー AmazonSSMManagedInstanceCore のアタッチを行う必要があるが、 ssmSessionPermissions を true にすることで、このIAMロール作成などを自動的に行ってくれる。
SSMエージェントはVPCエンドポイントに送信されたコマンドをポーリングするため、EC2のインバウンド許可は不要。また、サブネットタイプはEC2も PRIVATE_ISOLATED でいい。
VPCエンドポイント
公式ドキュメントを見ると、以下3つのVPCエンドポイントへのHTTPSトラフィックを許可する必要があると記載されている。
- ec2messages.<region>.amazonaws.com
- ssm.<region>.amazonaws.com
- ssmmessages.<region>.amazonaws.com
CDKではそれぞれ、 InterfaceVpcEndpointAwsService の EC2_MESSAGES, SSM, SSM_MESSAGES に該当。
ただ、 EC2_MESSAGES は不要になった模様。
公式ドキュメントをもう少し見てみる。
- https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-setting-up-messageAPIs.html
- 「2024 年以降にローンチされたリージョンでは、ssmmessages:* API オペレーションのみがサポート」との記載
- https://docs.aws.amazon.com/systems-manager/latest/userguide/setup-create-vpc.html
結果としては、 EC2_MESSAGES なしでも接続可能だった。
また、SSMエージェントからポーリングされるため、EC2からのHTTPSアクセスを許可しておく。
振り返り
SSM Session Managerを使うのが初めてだったので、どういった仕組みなのか調べたが、EC2をISOLATEDサブネットに配置してもOKというのは面白い。
今回はVPCエンドポイントへのアクセスをCIDRで制限したが、IAMロールで実行可能な処理を細かく指定したりもできるので、パブリックサブネットにEC2を置いてSSHより、Session Managerを使ったほうがセキュアな環境を構築できると感じた。
LLMを用いたテスト自動作成の記事のご紹介
社内向けにTDDの説明資料を書いており、その調べ物をしているときにたまたま見つけた、LLMを用いたテストの自動作成に関する記事が大変興味深かったのでメモ。
経緯
TDDについての勉強会をすることになり、もろもろ資料としてまとめていた。
その中で、 Testing vs. Checking および Testing and Checking Refined に絡めて、 Checking vs Testing についても軽く触れておこうと該当記事を確認。
その際、記事を執筆された jason arbon 氏のその他の記事として、おすすめされていたのが「AI QuickStart for Testers」。
記事の内容
詳しくは該当の記事を見てもらえばわかるが、ざっくり意訳&要約。
- テストしたいページのキャプチャをLLMに添付し、バグを見つけさせる
- テストしたいページのキャプチャをLLMに添付し、テストケースを生成させる
- 「経験豊富なテスターでも見逃す可能性のある、重要なバグを発見できるテストケースを作成してください。各テストケースについて、テスターがそれを見逃す可能性がある理由と、重要なバグである理由を説明してください。」のようなプロンプトを指定すると、さらに複雑なテストケースを作成させることができる
- 「~のテストケースについて、問題やバグを起こす可能性のある例をいくつか挙げ、なぜそれが問題なのかを技術的な観点から説明してください。」のようなプロンプトで、具体例と詳細な説明をさせることができる
- 自動テストのためのコードを生成させる
- テスト計画の作成や、リバースエンジニアリングして製品要件/仕様のドキュメント作成を行うことも可能
画面キャプチャだけでなく、製品仕様をプロンプトに追加することでより精度を高めることも可能とのこと。
振り返り
ソフトウェアテストの効率化について、ちょうど考えていたところだったので大変参考になった。
特に、Page Object Modelを使うときに面倒だったページオブジェクトの作成を、この方法を使えばだいぶ簡略化できると感じた。
また、リバースエンジニアリングによる要件/使用のドキュメント作成も、どの程度のクオリティのものが出力されるのか興味深い。
いずれも機会があれば試してみたい。
Slack APIのconversations.historyはPOSTなのかGETなのか
Slack APIでチャンネルのメッセージを抽出するときに conversations.history を使っている。オプションを確認しようと思ってドキュメントを確認したら、ちょっと気になることがあった。
ドキュメントの「Reference docs」ではPOSTだが、「Sample code」ではGETになっている。
もともとはGETを使っており、POSTのリクエスト方法も複数種類あり、どれを使うか迷ったのでメモ。
状況
以下が conversations.history のドキュメント。
2024/11/15 現在、リクエスト時のHTTPメソッドが、「Reference docs」タブではPOST、「Sample code」タブではGETになっている。ただし、サンプルコードではリクエストボディが設定されているように見える。


どちらが正か
試してみたところ、GET/POSTともに実行できた。また、書き込みではないが、JSONリクエストボディも許可されている。
SLACK_TOKEN=... SLACK_CHANNEL_ID=... AUTH_HEADER="Authorization: Bearer ${SLACK_BOT_TOKEN}" # GET curl "https://slack.com/api/conversations.history?channel=${SLACK_CHANNEL_ID}" \ -H "${AUTH_HEADER}" # POST # curlの場合、 `Content-Type: x-www-form-urlencoded` が自動的に付与されるため省略 curl -X POST "https://slack.com/api/conversations.history" \ -H "${AUTH_HEADER}" \ -d "channel=${SLACK_CHANNEL_ID}" # POST JSON、Content-Typeの理由は後述 curl -X POST "https://slack.com/api/conversations.history" \ -H "${AUTH_HEADER}" \ -H "Content-Type: application/json; charset=UTF-8" \ -d "{ \"channel\": \"${SLACK_CHANNEL_ID}\" }" # POSTでもクエリパラメータが有効 # この場合はContent-Type未指定となるが問題ない curl -X POST "https://slack.com/api/conversations.history?channel=${SLACK_CHANNEL_ID}" \ -H "${AUTH_HEADER}"
どの方法がいいか
POSTについては、クエリパラメータ指定以外だと、 Content-Type を指定する必要がある。それぞれ Content-Type: x-www-form-urlencoded または Content-Type: application/json を指定しないと、以下のエラーが発生。
{ "ok": false, "error": "missing_post_type" }
また、JSON形式の場合、 Content-Type: application/json を指定しても、レスポンスに警告が含まれる。この警告を消すには Content-Type: application/json; charset=UTF-8 のように、文字コードを指定する必要がある。
{ "ok": true, // 中略 "warning": "missing_charset", "response_metadata": { "warnings": [ "missing_charset" ] } }
楽なのはGETかPOSTのクエリパラメータ指定で、 Content-Type の指定は不要。
シンプルなのはGETだが、はたして利用していいのか判断がつかない。
とはいえ今まで使えていたので、結局はシンプルさを重視してGETを使うことにした。
振り返り
当然GETだろうと思っていたが、ドキュメントを見たらPOSTになっていたのでびっくり。
というか、以前からPOSTだったとしたら、なぜ自分はGETにしたのだろうか...
使用頻度が高いメソッドだと思うので、GETできるならできると明記してほしいな。
Azure FunctionsをVSCodeからデプロイする際、files.excludeで指定したファイルがデプロイ対象から除外される
新しいプロジェクトの一環で、Azure FunctionsをVisual Studio Codeで開発しているが、デプロイしても関数が認識されない事態が発生。
めちゃくちゃハマったのでメモ。
環境
Azure Functions Core Tools v4.0.6280、VSCode v1.94.2、VSCodeのAzure Functions拡張機能v1.15.4。
Azure Functionsのランタイムバージョンはv4.36.0.23246だが、クライアント側の問題なので関係ない。
問題
Azure FunctionsをVSCodeで開発し始めた。言語はTypeScriptを使用。
プロジェクト作成やVSCode上でのデバッグ、デプロイパイプラインの作成など、一通り検証が済んだため本格的な開発に入った。
しかし、いざAzureにデプロイすると、デプロイは成功するが関数が反映されない。コマンドパレットから「Azure Functions: Execute Function Now」しても「No functions found.」となる。
調査
F5でのローカルデバッグでは関数が動き、デプロイも成功しているので、デプロイ時にアップロードされるZIPファイルに問題があるのではと考えた。
Azureポータルの関数アプリからデプロイ先の関数を選択し、「概要」の「アプリ コンテンツのダウンロード」から、アップロードされたZIPファイルをダウンロードできる。
ZIPファイルの内容を確認すると、distおよびnode_modulesディレクトリが含まれていなかった。これらは本格的に開発を開始した段階で、 .vscode/settings.json の files.exclude に指定し、VSCodeのエクスプローラーで非表示としていた。
{ ... "files.exclude": { "dist": true, "node_modules": true }, ...
files.exclude からdistおよびnode_modulesの記述を削除し、デプロイを実行すると、ZIPファイルのサイズが増加。関数が認識され、実行可能となった。ZIPファイルの内容も確認したが、両ディレクトリとも含まれていた。
対応
issueがあるだろうと思い、Azure Functions for Visual Studio Codeのissueを検索。
似たものはあったが、こちらは .funcignore ファイルが存在しない場合、 files.exclude で指定したファイルがZIPファイルから除外されるというもの。
同じ原因のissueはなさそう。 .funcignore に !**/dist などの指定を追加してみたが変わらず。
結局、 .vscode/settings.json から files.exclude の記述を削除して対応した。
また、 files.exclude の代わりに files.readonlyInclude および search.exclude に指定し、読み取り専用および検索対象外にしておく。
{ ... "files.exclude": { }, "files.readonlyInclude": { "dist/**/*": true, "node_modules/**/*": true }, "search.exclude": { "dist/**/*": true, "node_modules/**/*": true }, ...
振り返り
今回はTypeScriptの場合だが、JavaScriptでnode_modulesを非表示にしたり、コンパイルが必要な言語で出力ディレクトリを非表示にしたりするので、他の言語でも発生すると思われる。
適当なプロジェクト設定で進めていた検証時は動き、検証終わってきっちりプロジェクト設定したら動かなくなるという罠。
ZIPファイル化するときに、 files.exclude の設定を反映するか選択できるようになればいいんだが。
Slack Events APIで飛んでくるJSONの内容を確認するだけのLambdaコード
複数サービスを連携して処理するとき、Slack Events API(いわゆるWebhook)を使って、AWS LambdaやAzure Logic Apps経由でもろもろ処理させることが増えた。
ただ、Events APIで飛んでくるJSONが、公式ドキュメントを見てもわかりにくい(というか、メッセージの種類が多くて把握できていない)。
ドキュメント読むよりは、実際にWebhookを設定し、メッセージ送信してログ出力したほうが楽だなと思い、適当なLambdaを書いたのでメモ。
環境
LambdaをNode.js v20で作成。おそらくバージョンには依存していないと思う。
Lambdaの関数URLを有効にし、認証タイプは NONE でパブリックアクセス可能にしておく。
実装
チャレンジリクエスト(url_verification)には対応しているが、Verification Tokenの検証はしていない、あくまで検証用。
index.mjs に張り付ければOK。
export const handler = async event => { const { requestContext: { http: { method, }, }, body: bodyString, // stringのため、JSON.parseが必要 } = event if (method !== 'POST') { console.error('invalid method', { method, bodyString }) return { statusCode: 404 } } const { // token, // 本番ではチェックが必要だが、検証なので無視 challenge, type: eventType, event: slackEvent, } = JSON.parse(bodyString) // チャレンジ if (eventType === 'url_verification') { return { statusCode: 200, headers: { 'Content-Type': 'text/plain' }, body: challenge, } } console.log(bodyString) return { statusCode: 200 } }
Slack Apps側の設定
Slack Appsの「Event Subscriptions」にて、「Enable Events」を有効化。
「Request URL」にLambdaの関数URLを設定すればいい。
イベント通知が来れば、Lambdaに紐づくCloudWatch LogsにリクエストされたJSONが出力される。
振り返り
とりあえずこのLambdaを書いておいて、必要に応じて関数URLの有効/無効を切り替えて使っている。非公開であれば課金も発生しないので、似たようなLambdaをサービスごとに用意している。
Slackのメッセージ、チャンネルがpublic/private、送信者がユーザー/ボット、チャンネル直下/スレッド内、スレッド内から「以下にも投稿する」でチャンネルにも投稿した場合、などなど種類と組み合わせが多いし、それが全部メッセージとして飛んでくる。
一度やったことを焼き直すのは簡単なんだが、別種のメッセージを処理しようと思うと結構大変。
サブタイプなどで分かれてはいるが、もう少し細かく分けてくれると嬉しいと思いつつ、大多数のユースケースではそこまで細かな分類/制御はしないだろうとも思う。
ただでさえAPI設計は大変だし、もともと小さなサービスだったのが、これほど急成長するとは中の人も思ってなかったのではなかろうか。
Amplify Gen2のLINEログイン時にLINEユーザーIDを保存する
前回、 Amplify Gen2のLINEログイン時にLINEのIDトークンを取得、検証できた。
今回はIDトークンの検証結果から、LINEユーザーIDを取得し、保存するまでをメモ。
環境
前々回・前回同様、 @aws-amplify/backend v1.0.4, @aws-amplify/backend-cli (ampx) v1.2.1。
実装
前回、preSignUpトリガーにてIDトークンのverifyを行った。ただ、どうやらpreSignUp内ではCognitoの属性の変更ができないらしい。
今回はpostConfirmationにて同様の処理を行い、Cognitoの属性を更新することでLINEユーザーIDを保存する。
Googleログイン時に、postConfirmationトリガーは作成済みのため、そこに追記する。
トリガーへのアクセス許可の追加
amplify/auth/resource.ts にて、postConfirmationへアクセス許可を追加する。もともと addUserToGroup を設定していたが、今回は updateUserAttributes, deleteUserAttributes の2つを追加。
export const auth = defineAuth({ // 中略 // 前回追加しておいた、custom:line_user_idをユーザーID保存に用いる userAttributes: { 'custom:line_id_token': { dataType: 'String', maxLen: 2048 }, 'custom:line_user_id': { dataType: 'String', maxLen: 33 }, }, triggers: { preSignUp, postConfirmation }, access: allow => [ allow .resource(postConfirmation) // updateUserAttributes, deleteUserAttributesを追加 .to(['addUserToGroup', 'updateUserAttributes', 'deleteUserAttributes']), ], })
postConfirmationトリガーの実装
まずはpreSignUpと同様、 amplify/auth/postConfirmation/resource.ts でLINEチャネルIDを環境変数として参照できるようにしておく。
export const postConfirmation = defineFunction({ environment: { LINE_CHANNEL_ID: secret('LINE_CHANNEL_ID'), }, })
続いて、 amplify/auth/postConfirmation/handler.ts を実装。IDトークンの検証はpreSignUpと同じだが、検証結果として取得したユーザーIDをCognitoのカスタム属性として保存し、あわせてIDトークンを保存した属性を削除している。
import { AdminDeleteUserAttributesCommand, AdminUpdateUserAttributesCommand, CognitoIdentityProviderClient, } from '@aws-sdk/client-cognito-identity-provider' const cognitoClient = new CognitoIdentityProviderClient() export const handler: PostConfirmationTriggerHandler = async event => { const { userName, userPoolId, request: { userAttributes }, } = event // IDトークンからLINEユーザーIDの取得は前回と同じため省略 const { sub: lineUserId } = await verifyLineIdToken(userAttributes['custom:line_id_token']) const commonCommandInput = { UserPoolId: userPoolId, Username: userName, } // LINEユーザーIDの保存 const updateCommand = new AdminUpdateUserAttributesCommand({ ...commonCommandInput, UserAttributes: [{ Name: 'custom:line_user_id', Value: lineUserId }], }) await cognitoClient.send(updateCommand) // 不要となったIDトークンの削除 const deleteCommand = new AdminDeleteUserAttributesCommand({ ...commonCommandInput, UserAttributeNames: ['custom:line_id_token'], }) await cognitoClient.send(deleteCommand) return event }
これでLINEユーザーIDが custom:line_user_id に保存される。その値を用いて、LINE公式アカウントのユーザーとのマッピングが可能となった。
振り返り
preSignUpでCognitoの属性の更新ができれば、そこで完結できて楽だったんだが。
ひとまずトリガーを分けて対応できたので、良しとしておこう。