megutech

自身の備忘録として主にWEBサーバー周りの技術について投稿しています。

Sveltekitでファイルダウンロードをさせたい

sveltekitでPDFなどを返し、ダウンロードしてもらいたかったのだが、少しつまづいたので備忘録。

つまずきポイント

+page.server.tsloadでは対応できない

まず最初に試したのは、+page.server.tsloadでResponseを返すといった方法。

しかし+page.server.tsload+page.svelte表示前にサーバー側で処理を行うファイルであり、つまりページを表示するためのルートであるため、ここでResponseは返すことが出来ないようだ。

+page.server.tsactionsでは対応できない

次に試したのは、+page.server.tsactionsでResponseを返すといった方法。

ページ表示で対応できないのであれば、POSTで受け取ろうという発想。 しかしこれもまた+page.server.tsに記述することからわかる通り、POST処理後にはリダイレクトするか、+page.sveleteを表示するためのメソッド。 ここでResponseを返すことは出来なかった。

解決策

+server.tsを利用する

SveltekitのAPIエンドポイントである+servet.tsを利用することで、Responseを返すことが出来る。

export const GET: RequestHandler = async (event) => {
  const stream = await getStream(event);

  return new Response(stream, {
    headers: {
      'Content-Disposition': 'attachment; filename="example.pdf"',
      'Content-Type': 'application/pdf',
    };
  });
};

後はフロントでfetchして、streamをblobに変換して良しなにすればOK。

感想

普段はNext.jsを使っていたため、SveltekitでMPAライクにPOSTが受け付けることが出来ることに感動し、何とかAPIを使わずに出来ないかと試したがダメだった。 他にも方法があるのかもしれない。悔しい。

Zodiosでファイルのダウンロード

APIからstreamを取得してblob変換してダウンロードさせようとしたとき、vanillaのfetchならすんなり出来たのだが、Zodiosを利用すると少し躓いたので備忘録。

前提

サーバー側はこんな感じでstreamを返している。

  return new Response(stream, {
    headers: {
        'Content-Disposition': 'attachment; filename="sample.pdf',
        'Content-Type': 'application/pdf',
    },
  });

ダウンロード

フロント側はZodiosでstreamを受ける。

const api = (() => {
    const client = createApiClient('/');

    client.axiosInstance.interceptors.response.use(
        (response) => {
            response.data = {
                data: response.data,
                headers: response.headers,
            };
            return response;
        },
        (error) => {
            return Promise.reject(error);
        }
    );

    return client;
})();

const blobDownload = (blob: Blob, fileName: string): void => {
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.style.display = 'none';
    a.download = fileName;
    a.href = url;
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
};

const getFileName = (headers: AxiosHeaders): string | undefined => {
    const contentDispositionHeader = headers['content-disposition'];
    if (!contentDispositionHeader) {
        return;
    }

    const contentDisposition = contentDispositionHeader
        .split(';')
        .map((item: string): string => item.trim());
        const filenameParameter = contentDisposition.find((item: string): boolean =>
            item.toLowerCase().startsWith('filename='),
        );
    if (!filenameParameter) {
        return;
    }

    const fileName = filenameParameter.split('=')[1];
    return decodeURIComponent(fileName.replace(/['"]+/g, ''));
};

// ダウンロード
const res = await api.getPdf({
    params,
    queries,
    responseType: 'blob'
});
blobDownload(res.data, getFileName(res.headers) ?? 'default.pdf');

client.axiosInstance.interceptors.response.use

Zodios のレスポンスは Axios の response.data の部分のみ返してくる。 今回だと直接 blob が返ってきてしまう。
しかし後続処理で headers の内容を参照したいので、レスポンスを改変する。

注意点としては、Zodiosが生成する api の Axios Instance をいじってしまうと他にも影響があるため、この処理専用の Api Client を生成すること。

なお設定などは変わらないのでAxios Instanceを自作するの面倒だということで、client の axiosInstance に直接ごにょごにょしているが、勿論以下のように Axios Instance を渡す形でもいい。

const axiosInstance = Axios();
axiosInstance.interceptors.response.use(
    (response) => {
        response.data = {
            data: response.data,
            headers: response.headers,
        };
        return response;
    },
    (error) => {
        return Promise.reject(error);
    }
);
const client = createApiClient('/', { axiosInstance });

responseType: 'blob'

Zodiosはある程度Axiosのオプションを受け付けているので、忘れずにresponseTypeを指定する。
responseType を指定することで stream を blob に変換までしてくれる。
指定しない場合は json 扱いされ、上手く後続処理に続けることが出来なくなる。

getFileName

ここでファイル名を取得したかったので headers が必要だったのですね。 ファイル名をフロントで決めていいのであれば、axios をいじる必要もなければ、この処理も不要です。

blobDownload

ここはよくある処理なので説明割愛。

感想

今回のはまりどころは2点。

  1. Axios で Stream を受け取るには responseType の指定が必要。
  2. Zodios で Headers を受け取るにはちょっとした改良が必要。

Rspecでsessionにダミーデータを入れたい

rspecでsessionにデータを入れてテストしたいシーンがあるかと思います。
ぱっと調べた感じだと方法が古いのか上手くいかないものが多かったので、動くものを備忘録として記事化しようと思います。

環境

Service Version
Ruby 3.3.2
Ruby on Rails 7.1
rspec-rails 6.1.3

対応内容

supportファイルの有効化

もし有効化していなければ有効化してください。

spec/rails_helper.rb

- # Rails.root.glob('spec/support/**/*.rb').sort.each { |f| require f }
+ Rails.root.glob('spec/support/**/*.rb').sort.each { |f| require f }

Session用のサポートファイル作成

spec/support/session.rb

shared_context 'session double' do
  let(:session_hash) { {} }

  before do
    session_double = instance_double(ActionDispatch::Request::Session, enabled?: true, loaded?: false)

    allow(session_double).to receive(:[]) do |key|
      session_hash[key]
    end

    allow(session_double).to receive(:[]=) do |key, value|
      session_hash[key] = value
    end

    allow(session_double).to receive(:delete) do |key|
      session_hash.delete(key)
    end

    allow(session_double).to receive(:clear) do |_key|
      session_hash.clear
    end

    allow(session_double).to receive(:fetch) do |key|
      session_hash.fetch(key)
    end

    allow(session_double).to receive(:key?) do |key|
      session_hash.key?(key)
    end

    allow_any_instance_of(ActionDispatch::Request)
      .to receive(:session).and_return(session_double)
  end
end

使い方

以下のようにsession_hashに値を設定してあげれば、それがそのままsessionとなる。

let(:session_hash) { { sample: :value } }

感想

新規案件始めるたびに忘れる。 ので記事化。

参考

Set Session in RSpec with Rails 7 API

備忘録のためにここに書いていることを日本語にしただけです。

ユーザー固有のジョブの登録と起動

自動起動させたいサービスやジョブを systemd のユニットファイルにまとめて、sudo systemctl enable myservice.service といったコマンドで登録するシーンがよくあると思います。

しかし恥ずかしながら長らくユーザー固有のジョブも登録できることを知らなかったため備忘録。

ユーザー固有の systemd ユニットファイルを追加

~/.config/systemd/user/ 以下にユニットファイルを作成します。
この際、/etc/systemd/system に設置するシステム全体向けユニットファイルとはいくつかの違いがあります。

UserGroup の使用

システム全体向けユニットファイルでは UserGroup を指定しますが、ユーザーレベルのユニットファイルではこれらを使用できません。

[Service]
User=username
Group=groupname

multi-user.target の代替

ユーザー固有のユニットファイルでは multi-user ターゲットは指定できません。
代わりに default.target を指定する必要があります。

[Install]
- WantedBy=multi-user.target
+ WantedBy=default.target

サンプルユニットファイル

以下は上記の点に注意して書いた、Pumaを起動する myservice サービスのサンプルユニットファイルです。

~/.config/systemd/user/myservice.service

[Unit]
Description=Puma HTTP Server
After=network.target

[Service]
Type=simple
Environment=PUMA_DEBUG=1
WorkingDirectory=/var/www/app/myservice/current
ExecStart=/home/username/.rbenv/bin/rbenv exec bundle exec puma -e production -C /var/www/app/myservice/current/config/puma.rb
ExecStop=/home/username/.rbenv/bin/rbenv exec bundle exec pumactl -S /var/www/app/myservice/shared/tmp/pids/puma.state stop
ExecReload=/bin/kill -USR1 $MAINPID
Restart=always
KillMode=mixed

[Install]
WantedBy=default.target

サービスの起動

システム全体のユニットファイルなら sudo systemctl start myservice.service としますが、ユーザー固有のサービスを操作するためには --user オプションを使用します。

systemctl --user start myservice.service

自動起動設定

自動起動の設定も同様に --user オプションを使用します。

systemctl --user enable myservice.service

しかしこれだけではユーザーがログアウトするとサービスが停止してしまいます。
そこでログアウトしてもサービスが持続するように linger 設定を追加します。

sudo loginctl enable-linger username

これでユーザーがログアウトしてもサービスが立ち上がり続けます。

よくあるトラブルシューティング

サービスが起動しない場合

  • ログファイルを確認する: journalctl --user -u myservice.service でサービスのログを確認し、エラーメッセージをチェックします。
  • パーミッションの問題: 実行ファイルやディレクトリのパーミッションが適切か確認します。

サービスが自動起動しない場合

  • linger 設定の確認: loginctl show-user username | grep Lingerlinger 設定が有効になっているか確認します。

感想

恥ずかしながらユーザーレベルでユニットファイルを管理できることを知らず、sudoers に特定ユーザーの systemctl コマンドを許可するなどして対応していました。
そんな時、Capistranoの設定で /bin/systemctl --user restart myservice.service というコマンドを見かけたのです! --user とついているだけあって、ユーザー固有のものでは?と調べてみると大正解。 知らなかったー!恥ずかしい!

ただ実際に設定してみると、linger 設定の必要性を知らなかったため、環境構築後にサービスが定期的に停止してしまい、ちょっと躓いたり。

しかし今後はこの機能を積極的に活用していく予定です。

まとめ

ユーザー固有の systemd ユニットファイルを利用することで、より細かいサービス管理が可能になります。
特に複数ユーザーが利用するシステムでは、各ユーザーが独自のサービスを管理できるメリットがあります。
この記事が、ユーザー固有のサービス管理に役立つ参考資料となれば幸いです。

S3バケットを別アカウントへ移設

ダウンタイムなしにS3のアカウントを移設したかったのですが、巷の情報では1回のみのsyncで済ませており、ちょっと困ったので備忘録。

他との違いとしてはs3:GetObjectTaggingの追加がかなめ。

手順

移設用IAMユーザー

移設先のアカウントで以下のポリシーを持ったユーザーを作成してください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:GetObjectTagging",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::from-bucket",
                "arn:aws:s3:::from-bucket/*"
            ]
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:ListBucket",
                "s3:PutObjectAcl"
            ],
            "Resource": [
                "arn:aws:s3:::to-bucket",
                "arn:aws:s3:::to-bucket/*"
            ]
        }
    ]
}

configureの登録

後程aws cliで同期を行うので、作成したIAMユーザーのconfigureを登録しておいてください。

$ aws configure --profile s3-sync-profile

移設元バケットポリシー

移設元のバケットポリシーに下記jsonを設定してください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ReadAccess",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::xxxxxxxxxxxx:user/sync-user"
            },
            "Action": [
                "s3:ListBucket",
                "s3:GetObject",
                "s3:GetObjectTagging"
            ],
            "Resource": [
                "arn:aws:s3:::from-bucket/*",
                "arn:aws:s3:::from-bucket"
            ]
        }
    ]
}

移設実行

$ aws s3 sync s3://from-bucket/ s3://to-bucket/ --profile s3-sync-plofile

s3:GetObjectTagging

これがない場合、2回目のsyncでAn error occurred (AccessDenied) when calling the GetObjectTagging operation: Access Deniedが発生して差分をsync出来ませんでした。
要注意!

備考

aws s3 syncは差分のみコピーしてくれます。
なのでまず最初に実行し、その後サービス側のS3の向き差を変更、再度aws s3 syncを実行すれば影響範囲を最小に移設が可能です。
ただしすごく活発なサービスや、少しの問題が発生することは許されない、ということであれば、しっかりメンテ時間をとりませう。

KusanagiでNginxが何度も再起動する

Nginxのログを見ていると何度も再起動が行われていました。
幸い動作に影響は無かったものの、ひどいときには数分に一回レベルで再起動が起こるので原因を調査しました。

原因

Kusanagiはデフォルトでmonitが動いています。
monitの設定でNginxで500番台が計測されればNginxを再起動するよう設定されていました。

普段Kusanagiも使わないし、監視にはmonitも使っていなかったのでなかなか気づけず。。

余談

そもそもなぜ500番台が発生していたかというと、Next.jsでBOTからのアクセス時に500になるが原因でした。
普段ならエラー検知サービスを入れておくのですぐ気づけたんですが、今回はいろいろな制約によりサードパーティ製のアレコレが使えず、気づくのが遅れたのでした。

Next.jsでBOTからのアクセス時に500になる

本番稼働中のNext.js アプリケーションのログを眺めていると、稀にエラーを吐いていました。

環境

Key Val
Node.js 20.9.0
Next.js 13.4.13

エラーの内容

- error Failed to handle request for /
TypeError: fetch failed
    at Object.fetch (node:internal/deps/undici/undici:11372:11)
    at async invokeRequest (/var/www/app/sample-app/node_modules/next/dist/server/lib/server-ipc/invoke-request.js:21:12)
    at async requestHandler (/var/www/app/sample-app/node_modules/next/dist/server/lib/start-server.js:336:33)
    at async Server.<anonymous> (/var/www/app/sample-app/node_modules/next/dist/server/lib/start-server.js:152:13) {
  cause: _RequestContentLengthMismatchError: Request body length does not match content-length header
      at write (node:internal/deps/undici/undici:8302:41)
      at _resume (node:internal/deps/undici/undici:8276:33)
      at resume (node:internal/deps/undici/undici:8173:7)
      at connect (node:internal/deps/undici/undici:8162:7) {
    code: 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH'
  }
}

原因

UND_ERR_REQ_CONTENT_LENGTH_MISMATCHググると、お有難いことに下記記事がヒットしました。
本当にありがとうございます。

本番環境でだけ発生するundiciのエラー

※Version 13.4.13時点 本番環境で以下のようなエラーが発生することがある

さて、今回の環境を見てみましょう。

Next.js 13.4.13ですね!!! まさに引用元のバージョンでした。

これにより、OGPを取得しに来たBOTのアクセス時にエラーが発生していたようです。
この影響でOGPが表示されていませんでした。

対応

NextJS fails with UND_ERR_REQ_CONTENT_LENGTH_MISMATCH after redirect from server

I was experiencing a similar issue. Upgrading to the latest version of NEXT helped resolve it for me. Version 13.4.19 as of this comment.

対応方法についてもお有難いことに既に試している方がいました。
13.4.19では直っているそうです。

ということでnextのバージョンを上げて終了です。