Nuxt3のフロント/バックエンドでファイル送受信

`File`とか`Blob`とか`Buffer`とか何それ？状態だったので整理

:::note warn
本記事では`File`は画像ファイルを想定しています
:::


# 1. クライアントPCからファイルを添付

```vue
<template>
  <div>
    <input type="file" accept="image/*" @change="fileSet" />
  </div>
</template>

<script setup lang="ts">
const file = ref<File | null>(null);

const fileSet = (e: Event) => {
  if (!(e.target instanceof HTMLInputElement)) return;
  if (!e.target.files?.[0]) return;
  file.value = e.target.files?.[0];
};
</script>
```
ここでは`File`として取得可能

# 2. フロントからバックエンドにファイルを送信

```vue:フロント側
<script setup lang="ts">
const upload = async () => {
  if (!file.value) return;

  const formData = new FormData();
  formData.append("file", file.value);

  await $fetch("/api/upload", {
    method: "POST",
    body: formData
  });
}
</script>
```

```typescript:~/server/api/upload.ts
export default defineEventHandler(async (event) => {
  const formData1: FormData = await readFormData(event);
  const file: FormDataEntryValue | null = formData1.get('file');
  // FormDataEntryValue: File | string
  if (!(file instanceof File)) return;

  const formData2: MultiPartData[] | undefined = await readMultipartFormData(event);
  // MultiPartData: {
  //   data: Buffer;
  //   name?: string;
  //   filename?: string;
  //   type?: string;
  // }
});
```
フロントからの`formData`を受け取るには`readFormData`と`readMultipartFormData`が使えるらしい
違いは未調査

# 3. server/apiから別のserver/apiにファイルを受け渡す

## 3-1. `File`を再度`FormData`にして投げる


<details><summary>うまくいかなかった</summary>


```typescript:~/server/api/upload.ts
  ...
  const newFormData = new FormData();
  newFormData.append("file", file);
  await $fetch("/api/hoge", {
    method: "POST",
    body: newFormData,
  });
```
```typescript:~/server/api/hoge.ts

export default defineEventHandler(async (event) => {
  const formData = await readFormData(event);
  const file = formData.get("file");
  console.log(file);
  // [nuxt] [request error] [unhandled] [500] Cannot read properties of undefined (reading 'get')
  //   at Object.handler (<projectroot>\server\api\s3\fileUpload.ts:7:1)
  //   at process.processTicksAndRejections (node:internal/process/task_queues:95:5)

  const forms = await readMultipartFormData(event);
  console.log(forms);
  // undefined
});
```
`event`の中身を見たところデータがちゃんとセットできていなさそう(原因不明)
```typescript:~/server/api/hoge.ts
console.log(event)
// H3Event {
//   __is_event__: true,
//   node:
//    { req:
//       IncomingMessage {
//         __unenv__: undefined,
//         _events: [Object: null prototype] {},
//         _maxListeners: undefined,
//         readableEncoding: null,
//         readableEnded: true,
//         readableFlowing: false,
//         readableHighWaterMark: 0,
//         readableLength: 0,
//         readableObjectMode: false,
//         readableAborted: false,
//         readableDidRead: false,
//         closed: false,
//         errored: null,
//         readable: false,
//         destroyed: false,
//         aborted: false,
//         httpVersion: '1.1',
//         httpVersionMajor: 1,
//         httpVersionMinor: 1,
//         complete: true,
//         connection: [Socket],
//         socket: [Socket],
//         headers: [Object],
//         trailers: {},
//         method: 'POST',
//         url: '/api/hoge',
//         statusCode: 200,
//         statusMessage: '',
//         body: [FormData],
//         originalUrl: '/api/hoge' },
//      res:
//       ServerResponse {
//         __unenv__: true,
//         _events: [Object: null prototype] {},
//         _maxListeners: undefined,
//         writable: true,
//         writableEnded: false,
//         writableFinished: false,
//         writableHighWaterMark: 0,
//         writableLength: 0,
//         writableObjectMode: false,
//         writableCorked: 0,
//         closed: false,
//         errored: null,
//         writableNeedDrain: false,
//         destroyed: false,
//         _data: undefined,
//         _encoding: 'utf-8',
//         statusCode: 200,
//         statusMessage: '',
//         upgrading: false,
//         chunkedEncoding: false,
//         shouldKeepAlive: false,
//         useChunkedEncodingByDefault: false,
//         sendDate: false,
//         finished: false,
//         headersSent: false,
//         strictContentLength: false,
//         connection: null,
//         socket: null,
//         req: [IncomingMessage],
//         _headers: {} } },
//   web: undefined,
//   context:
//    { _nitro: { routeRules: {} },
//      nitro: { errors: [] },
//      matchedRoute: { path: '/api/hoge', handlers: [Object] },
//      params: {} },
//   _method: 'POST',
//   _path: '/api/hoge',
//   _headers: undefined,
//   _requestBody: undefined,
//   _handled: false,
//   _onBeforeResponseCalled: undefined,
//   _onAfterResponseCalled: undefined,
//   fetch: [Function (anonymous)],
//   '$fetch': [Function (anonymous)],
//   waitUntil: [Function (anonymous)],
//   captureError: [Function (anonymous)] }
```

</details>

## 3-2. `File`を`Buffer`にして投げる
```typescript:~/server/api/upload.ts
  ...
  const form = await readFormData(event);
  const file = form.get("file");
  if (!(file instanceof File)) throw new Error("400 Bad Request");
  const arrBuf = await file.arrayBuffer();
  const buf = Buffer.from(arrBuf);

  const forms = await readMultipartFormData(event);
  if (!forms || forms.length !== 1) throw new Error("400 Bad Request");
  const buf2 = Buffer.from(forms[0].data);
  
  await $fetch("/api/hoge", {
    method: "POST",
    body: { buf }, // { buf: buf2 }
  });
```
```typescript:~/server/api/hoge.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  // {
  //   buf: {
  //     type: 'Buffer',
  //     data: [123, ...]
  //   }
  // }
  const buf = Buffer.from(body.buf.data);
  console.log(buf);
  // <Buffer ...
});
```

## 3-3. `File`を`Base64`エンコードして投げる
`Buffer.toString("base64")`してbodyに入れるだけ
```typescript:~/server/api/upload.ts
  ...
  const base64str = buf.toString("base64");

  await $fetch("/api/hoge", {
    method: "POST",
    body: { base64str },
  });
```
base64は元ファイルを3bitごとに4bitのデータに変換する等の関係で
ファイルサイズが約1.37倍になるらしいので注意

# 4. 受け取ったAPI側で`File`に戻す
## 4-1. `Buffer`を`File`に変換
```typescript
const body = await readBody(event);
const buf = Buffer.from(body.buf.data);
const file = new File([buf], "test.txt", { type: body.buf.type });
```

## 4-2. `base64`を`File`に変換
```typescript
const body = await readBody(event);
const base64str = body.base64;
const buf = Buffer.from(base64str, "base64");
const file = new File([buf], "test.txt", { type: "image/jpg" });
```
※`base64`に変換して渡すと`type`の情報が落ちるので注意

# 5. バックエンドからフロントに`File`を返す
例としてS3から`GetObjectCommand`で取得した画像をフロント側に返すことを考える
`GetObjectCommand`の`res.Body`は以下の型
```typescript
(IncomingMessage | Readable) & SdkStreamMixin

interface SdkStreamMixin {
  transformToByteArray: () => Promise<Uint8Array>;
  transformToString: (encoding?: string) => Promise<string>;
  transformToWebStream: () => ReadableStream;
}
```

## 5-1. `Blob`
```typescript
  ...
  const command = new GetObjectCommand(input);
  const res = await client.send(command);
  if (!res.Body) throw new Error("404 Not Found");

  if (!(res.Body instanceof IncomingMessage)) throw new Error("404 Not Found");
  const type = res.Body.headers["content-type"];
  const uint8arr = await res.Body.transformToByteArray();
  const blob = new Blob([uint8arr], { type });
  return blob;
```

```vue
<template>
  <div>
    <img v-if="imgSrc" :src="imgSrc" />
  </div>
</template>

<script setup lang="ts">
const imgSrc = ref<string | null>(null)

const getImage = async () => {
  const imgBlob = await $fetch("/api/getImage");
  const url = window.URL.createObjectURL(imgBlob);
  imgSrc.value = url;
}
</script>
```

## 5-2. `File`
`Blob`にファイル名を追加するだけ
```typescript
  ...
  const type = res.Body.headers["content-type"];
  const uint8arr = await res.Body.transformToByteArray();
  const file = new File([uint8arr], 'test.png', { type });
  return file;
```

## 5-3. `base64`
```typescript
  ...
  const type = res.Body.headers["content-type"];
  const uint8arr = await res.Body.transformToByteArray();
  const base64 = Buffer.from(uint8arr).toString("base64");
  return { base64str, type };
```

```vue
<template>
  <div>
    <img v-if="imgSrc" :src="imgSrc" />
  </div>
</template>

<script setup lang="ts">
const imgSrc = ref<string | null>(null)

const getImage = async () => {
  const { base64str, type } = await $fetch("/api/getImage");
  const url = `data:${type};base64,${base64str}`;
  imgSrc.value = url;
}
</script>
```


# おまけ　S3にファイルをアップする
AWS SDKの`PutObjectCommand`は、Bodyパラメータに以下を指定可能
`string | Uint8Array | Buffer | Readable`

## Buffer
```typescript
import { S3Client, PutObjectCommand, type PutObjectCommandInput } from "@aws-sdk/client-s3";
import { fromEnv } from "@aws-sdk/credential-providers";

export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const buf = Buffer.from(body.buf.data);

  const client = new S3Client({
    region: process.env["AWS_REGION"] ?? "",
    credentials: fromEnv(),
  });

  const input: PutObjectCommandInput = {
    Bucket: process.env["BUCKET_NAME"] ?? "",
    Key: "test.jpg",
    Body: buf,
  };
  const command = new PutObjectCommand(input);
  await client.send(command);
});
```

## Uint8Array
`Buffer`があるなら
```typescript
const arrBuf = new Uint8Array(buf);
const input: PutObjectCommandInput = {
  Bucket: process.env["BUCKET_NAME"] ?? "",
  Key: "test.jpg",
  Body: arrBuf,
};
```
すれば良いだけ