0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Lambda+API GatewayでNext.jsを建てようとしたらハマった

Last updated at Posted at 2025-03-02

背景

Next.jsでフロントエンド個人アプリを作っているが、
ほぼアクセスのない個人用アプリを立てるのに、FargateやAmplifyを使うと高額すぎるから、ほぼ無料で使えるLambdaとAPI Gatewayで建てたかった。

ほとんど下記の記事のやり方で建てられるはずだが、なかなかうまくいかなかったので、差分を記載する。

うまくいかなかったこと

Lambdaから吐き出されるレスポンスが、API Gatewayで受入れられない

API Gateway経由のページアクセスでInternal Server Errorと表示されてしまい、
API Gatewayのログを見るとMALFORMED_PROXY_RESPONSEというエラータイプで次のエラーがおきてしまって

The response from the Lambda function doesn't match the format that API Gateway expects. Lambda body is invalid JSON

Lambdaの関数URLでうまく表示できていたからなんでうまくいかないかしばらくわからなかった。。。

どうやらAPI GatewayにはJSONでstatusCodeなどを含めたパラメータを渡さなければいけないらしい。
(bodyがHTMLの場合にはAPI Gatewayをブラウザから実行したときにHTMLとして解釈してくれるが、渡すのはJSONでなければいけない)

やったこと

Lambdaのレスポンスで、生のNext.jsのレスポンスHTMLを返すのではなく、
HTMLをbodyに詰めてAPI Gatewayにわたす。

Lambda用.Dockerfile
FROM public.ecr.aws/lambda/nodejs:20
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 /lambda-adapter /opt/extensions/lambda-adapter

WORKDIR /var/task

COPY . .
RUN corepack enable yarn
RUN yarn install
RUN yarn build

CMD ["handler.handler"]

handler.js
const next = require('next');
const { IncomingMessage, ServerResponse } = require('http');
const { Duplex } = require('stream');

const app = next({ dev: false });
let _nextJsRequestHandler = null;
async function getNextJsRequestHandler() {
  if (_nextJsRequestHandler) return _nextJsRequestHandler;
  await app.prepare();
  _nextJsRequestHandler = app.getRequestHandler();
  return _nextJsRequestHandler;
}

function convertChunkToBuffer(chunk, encoding) {
  if (typeof chunk === 'string') return Buffer.from(chunk, encoding);
  if (Buffer.isBuffer(chunk)) return chunk;
  if (chunk instanceof Uint8Array) return Buffer.from(chunk);
  if (typeof chunk === 'object') {
    try {
      return Buffer.from(JSON.stringify(chunk), encoding);
    } catch (e) {
      console.warn('Chunk conversion failed:', e);
      return Buffer.alloc(0);
    }
  }
  return null;
}

function createNextJsRequest(event) {
  const req = new IncomingMessage();
  req.method = event.httpMethod || (event.requestContext && event.requestContext.http && event.requestContext.http.method) || "GET";
  req.headers = event.headers;
  req.url = event.rawPath;
  req.body = event.body ? Buffer.from(event.body) : Buffer.alloc(0);
  return req;
}

class DummySocket extends Duplex {
  _read(size) { }
  _write(chunk, encoding, callback) { callback(); }
  setTimeout(msecs, callback) { if (callback) callback(); }
  setNoDelay(noDelay) { }
  setKeepAlive(enable, initialDelay) { }
  address() { return { address: '127.0.0.1', family: 'IPv4', port: 0 }; }
  destroy(error) { this.push(null); this.emit('close', error); }
}

function setupResponseCapture(nextJsRequest) {
  const chunks = [];
  const socket = new DummySocket();
  nextJsRequest.socket = socket;
  const res = new ServerResponse(nextJsRequest);
  res.assignSocket(socket);
  const originalWrite = res.write;
  res.write = (chunk, encoding, callback) => {
    const buffer = convertChunkToBuffer(chunk, encoding);
    if (buffer) {
      chunks.push(buffer);
      return originalWrite.call(res, buffer, encoding, callback);
    }
    return originalWrite.call(res, chunk, encoding, callback);
  };
  const originalEnd = res.end;
  res.end = (chunk, encoding, callback) => {
    if (typeof chunk === 'function') {
      callback = chunk;
      chunk = undefined;
      encoding = undefined;
    } else if (typeof encoding === 'function') {
      callback = encoding;
      encoding = undefined;
    }
    if (chunk) {
      const buffer = convertChunkToBuffer(chunk, encoding);
      if (buffer) {
        chunks.push(buffer);
        return originalEnd.call(res, buffer, encoding, callback);
      }
      return originalEnd.call(res, chunk, encoding, callback);
    }
    return originalEnd.call(res, chunk, encoding, callback);
  };
  return { enhancedResponse: res, chunks };
}

async function handleNextJsRequest(nextJsRequest) {
  const { nextJsResponse, chunks } = setupResponseCapture(nextJsRequest);
  const handler = await getNextJsRequestHandler();
  await new Promise((resolve, reject) => {
    enhancedResponse.on('finish', resolve);
    Promise.resolve(handler(nextJsRequest, nextJsResponse)).catch(reject);
  });
  return [nextJsResponse, chunks];
}

function convertNextJsResponseToLambdaResponse(nextJsResponse, chunks) {
  return {
    statusCode: nextJsResponse.statusCode,
    headers: nextJsResponse.headers,
    body: Buffer.concat(chunks).toString('base64'),
    isBase64Encoded: true,
  };
}

exports.handler = async (event, _context) => {
  try {
    const nextJsRequest = createNextJsRequest(event);
    const [nextJsResponse, chunks] = await handleNextJsRequest(nextJsRequest);
    return convertNextJsResponseToLambdaResponse(nextJsResponse, chunks);
  } catch (error) {
    console.error('Error during SSR processing:', error);
    return {
      statusCode: 500,
      body: `Internal Server Error: ${error.message}\n${error.stack}`,
    };
  }
};

gzipをうまく描画できないため、Lambdaの関数URLでは表示できないが、API Gateway経由だとうまく描画できるはず。

このスクリプトの補足事項だけ書いて終わりにする。

DummySocket

詳しくはわからないけれども、ChatGPTの以下の言葉があったので利用した。

AWS Lambda上でNext.jsを動作させる際、Node.jsのHTTPモジュールを直接利用していると、内部でHTTPコネクション(socket)に依存した処理が行われるため、単に IncomingMessage や ServerResponse を new しても正しく動作しない可能性があります。

特に、ServerResponse は内部で req.socket を参照しているため、ダミーの socket を用意して渡す必要があります。以下の手順とコード例で、ダミーの socket を作成し、レスポンスが正しく返るか試してみてください。

Requestのonをオーバーライド

Next.jsのデフォルトだとレスポンスのbodyを参照しても中身が空になってしまうそうなので、
本来bodyに入ってほしい内容を変数に詰めるために上書きした。

Lambda Dockerイメージのデバッグの仕方

次のようなリクエストを投げればデバッグできる

curl -X POST "http://localhost:8080/2015-03-31/functions/function/invocations" \
  -H "Content-Type: application/json" \
  -d '{
  "rawPath": "/api/hello",
    "httpMethod": "GET",
    "headers": {
    "Content-Type": "application/json"
  }
}'
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?