背景
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にわたす。
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"]
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"
}
}'