1.目的

今回 LWC 中に AWS の SDK for javascript で S3 と連携する方法を共有します。

2.前提

  • 2.1.AWS S3 バケット Cross-Origin Resource Sharing (CORS)の設定
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["HEAD", "GET", "PUT", "POST", "DELETE"],
    "AllowedOrigins": ["*"],
    "ExposeHeaders": ["ETag"]
  }
]
  • 2.2.Salesforce 側静的リソースに AWS の SDK をアップロードする
    image.png

  • 2.3.Salesforce 側 CSP 信頼済みサイトの設定
    image.png

3.ソース構成図

lwc
 ├─fileuploadMock
 ├─fileuploadModal
 ├─progressbar
 └─utils
  • fileuploadMock
    image.png
.container {
  background-color: #fff;
  min-height: 100%;
}

.wrapper {
  background-color: #cecece;
  overflow: scroll;
  width: 100%;
}
<template>
  <template if:true="{loading}">
    <lightning-spinner
      alternative-text="Loading"
      size="medium"
    ></lightning-spinner>
  </template>
  <c-progressbar
    upload-file-name="{uploadFileName}"
    progress="{progress}"
    onabort="{Abort}"
  >
  </c-progressbar>
  <c-fileupload-modal
    title="ファイル追加"
    onselect="{uploadHandler}"
    onfolderchange="{folderchangeHandler}"
  ></c-fileupload-modal>
  <lightning-card>
    <div class="slds-p-horizontal_small">
      <div class="slds-form">
        <div class="slds-form__row">
          <div class="slds-form__item">
            <button
              class="slds-button slds-button_brand"
              onclick="{fileSelectorHandler}"
            >
              ファイル追加
            </button>
          </div>
        </div>
      </div>
    </div>
    <div slot="footer">
      <lightning-datatable
        hide-checkbox-column
        key-field="key"
        columns="{columns}"
        data="{objectlist}"
        onrowaction="{handleRowAction}"
      >
      </lightning-datatable>
    </div>
  </lightning-card>
</template>
import { LightningElement, track } from "lwc";
import { NavigationMixin } from "lightning/navigation";
import { loadScript } from "lightning/platformResourceLoader";
import AWS_SDK from "@salesforce/resourceUrl/aws_sdk";
import { showToast, dateFormat, fileSizeUnit } from "c/utils";

const bucketName = "bucket-name"; //バケット名
const region = "ap-northeast-1"; //地域
const accessKeyId = "accessKeyId "; //アクセスID
const secretAccessKey = "secretAccessKey"; //アクセスキー

export default class FileuploadMock extends NavigationMixin(LightningElement) {
  //ファイル非活性
  @track fileDisable;
  @track datas;
  @track objectlist = [];
  @track folder;
  @track loading;
  @track uploadFileName;
  @track progress;

  /**
   * 初期化AWS
   */
  async initAWS() {
    AWS.config.update({
      region: region,
      accessKeyId: accessKeyId,
      secretAccessKey: secretAccessKey,
    });

    this.s3 = new AWS.S3({
      apiVersion: "2006-03-01",
      params: { Bucket: bucketName },
    });
    await this.listObjects();
  }

  /**
   * ファイルアップロード
   * @param {*} event
   */
  async uploadHandler(event) {
    let input = event.detail;
    let files = input.files;
    if (files.length > 0) {
      try {
        // let result = await this.upload(files[0]);
        let result = await this.managedUpload(files[0], (progress) => {
          this.uploadFileName = files[0].name;
          this.progress = Math.floor((progress.loaded / progress.total) * 100);
          this.template.querySelector("c-progressbar").open();
        });

        // console.log(result);
        this.template.querySelector("c-progressbar").close();
        await this.listObjects();
        showToast(this, "", "成功にアップロードしました", "success");
      } catch (err) {
        showToast(this, "", err.message, "error");
        console.error("Error:", err);
      }
    }
  }

  /**
   * ファイルアップロードキャンセル
   */
  async Abort() {
    await this.request.abort();
  }

  /**
   * ファイルダウンロード
   * @param {*} event
   */
  async fileDownload(fileKey) {
    try {
      this.loading = true;
      await this.downloadFile(fileKey);
    } catch (err) {
      showToast(this, "", err.message, "error");
      console.error("Error:", err);
    } finally {
      this.loading = false;
    }
  }

  /**
   * ファイル削除
   * @param {string} fileKey
   */
  async deleteFile(fileKey) {
    try {
      this.loading = true;
      await this.deleteObject(fileKey);
      await this.listObjects();
      showToast(this, "", "成功に削除しました", "success");
    } catch (err) {
      showToast(this, "", err.message, "error");
      console.error("Error:", err);
    } finally {
      this.loading = false;
    }
  }

  /**
   * ファイルリスト取得
   */
  async listObjects() {
    let data = await this.s3.listObjects().promise();
    console.log(data);
    this.objectlist = [];
    data.Contents.forEach((e) => {
      let key = e.Key;
      let folder;
      let fileName;
      if (e.Size === 0) return;
      if (key.lastIndexOf("/") > -1) {
        fileName = key.substring(key.lastIndexOf("/") + 1, key.length);
        folder = "./" + key.replace(fileName, "");
      } else {
        folder = "./";
        fileName = key;
      }

      let fileType = fileName.split(".")[1];
      this.objectlist.push({
        key: key,
        folder: folder,
        fileName: fileName,
        fileType: fileType,
        LastModified: dateFormat(e.LastModified, "YYYY/mm/dd HH:MM:SS"),
        Owner: e.Owner.DisplayName,
        Size: fileSizeUnit(e.Size),
        StorageClass: e.StorageClass,
      });
    });
  }

  /**
   * ファイル取得処理
   * @param {string} fileKey キー
   * @param {string} fileName ファイル名
   */
  async downloadFile(fileKey) {
    let url = await this.getSignedUrlPromise("getObject", {
      Bucket: bucketName,
      Key: fileKey,
      Expires: 1,
    });
    console.log(url);
    window.location.href = url;
  }

  /**
   * オブジェクト取得
   * @param {*} fileKey キー
   */
  getObject(fileKey) {
    return this.s3.getObject({ Key: fileKey }).promise();
  }

  /**
   * ファイル保存処理
   * @param {File(blob)} file ファイル
   */
  putObject(file) {
    const { folder } = this;
    let fileName = file.name;
    let fileKey;
    if (folder || folder === 0) fileKey = `${folder}/${fileName}`;
    else fileKey = fileName;
    return this.s3.putObject({ Key: fileKey, Body: file }).promise();
  }

  /**
   * ファイル削除処理
   * @param {string} fileKey ファイルキー
   */
  deleteObject(fileKey) {
    return this.s3.deleteObject({ Key: fileKey }).promise();
  }

  /**
   * ファイル保存処理(ビッグサイズ用)
   * @param {File(blob)} file ファイル
   */
  upload(file) {
    const { folder } = this;
    let fileName = file.name;
    let fileKey;
    if (folder || folder === 0) fileKey = `${folder}/${fileName}`;
    else fileKey = fileName;
    return this.s3.upload({ Key: fileKey, Body: file }).promise();
  }

  /**
   * URL発行
   * @param {*} action アクション:getObject,putObject,deleteObject
   * @param {*} fileKey ファイルキー
   */
  getSignedUrlPromise(action, params) {
    return this.s3.getSignedUrlPromise(action, params);
  }

  /**
   * マルチファイルアップロード管理
   * @param {*} file
   */
  managedUpload(file, progressCallBack) {
    const { folder } = this;
    let fileName = file.name;
    let fileKey;
    if (folder || folder === 0) fileKey = `${folder}/${fileName}`;
    else fileKey = fileName;

    this.request = new AWS.S3.ManagedUpload({
      partSize: 100 * 1024 * 1024,
      queueSize: 1,
      params: { Bucket: bucketName, Key: fileKey, Body: file },
    });
    this.request.on("httpUploadProgress", (progress) => {
      if (progressCallBack) progressCallBack(progress);
      else
        console.log(
          "progress:",
          Math.floor((progress.loaded / progress.total) * 100)
        );
    });

    this.request.send((err, data) => {
      if (err) console.error(err);
      console.info(data);
    });
    return this.request.promise();
  }

  /**
   *  ファイル選択
   * @param {*} event
   */
  fileSelectorHandler(event) {
    event.preventDefault();
    this.template.querySelector("c-fileupload-modal").open();
  }

  /**
   * フォルダ選択
   * @param {*} event
   */
  folderchangeHandler(event) {
    this.folder = event.detail;
  }

  /**
   * RowAction
   * @param {*} event
   */
  async handleRowAction(event) {
    const action = event.detail.action;
    const row = event.detail.row;
    switch (action.name) {
      case "download":
        await this.fileDownload(row.key);
        break;
      case "delete":
        await this.deleteFile(row.key);
        break;
    }
  }

  /**
   * 初期化
   */
  connectedCallback() {
    this.columns = [
      { label: "フォルダー名", fieldName: "folder" },
      { label: "ファイル名", fieldName: "fileName" },
      { label: "タイプ", fieldName: "fileType" },
      { label: "最新更新日", fieldName: "LastModified" },
      { label: "所有者", fieldName: "Owner" },
      { label: "サイズ", fieldName: "Size" },
      { label: "ストレージクラス", fieldName: "StorageClass" },
      {
        type: "action",
        typeAttributes: {
          rowActions: [
            { label: "ダウンロード", name: "download" },
            { label: "削除", name: "delete" },
          ],
          menuAlignment: "auto",
        },
      },
    ];
  }

  /**
   * aws-sdkロード
   */
  renderedCallback() {
    if (this.jsinit) return;
    this.jsinit = true;
    Promise.all([loadScript(this, AWS_SDK)])
      .then(async () => {
        await this.initAWS();
      })
      .catch((error) => {
        showToast(
          this,
          "JSライブラリロードに失敗しました",
          error.message,
          "error"
        );
      });
  }
}
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>52.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__Tab</target>
    </targets>
</LightningComponentBundle>
  • fileuploadModal
    image.png
<template>
  <!--Use template if:true to display/hide popup based on isModalOpen value-->
  <template if:true="{_isModalOpen}">
    <!-- Modal/Popup Box LWC starts here -->
    <section
      role="dialog"
      tabindex="-1"
      aria-modal="true"
      class="slds-modal slds-fade-in-open"
      style="z-index:9001"
    >
      <div
        class="slds-modal__container"
        style="width: auto;max-width: fit-content;"
      >
        <!-- Modal/Popup Box LWC header here -->
        <header class="slds-modal__header">
          <button
            class="slds-button slds-button_icon slds-modal__close slds-button_icon-inverse"
            title="Close"
            onclick="{close}"
          >
            <lightning-icon
              icon-name="utility:close"
              alternative-text="close"
              variant="inverse"
              size="small"
            >
            </lightning-icon>
            <span class="slds-assistive-text">Close</span>
          </button>
          <h2 class="slds-text-heading_medium slds-hyphenate">{title}</h2>
        </header>
        <!-- Modal/Popup Box LWC body starts here -->
        <div
          class="slds-modal__content slds-p-around_medium"
          style="height:50%"
        >
          <lightning-input
            label="パス"
            name="path"
            onchange="{commonChange}"
          ></lightning-input>
          <lightning-input
            type="file"
            label="ファイルアップロード"
            onchange="{uploadHandler}"
          >
          </lightning-input>
        </div>
        <!-- Modal/Popup Box LWC footer starts here -->
        <footer class="slds-modal__footer">
          <button class="slds-button slds-button_neutral" onclick="{close}">
            キャンセル
          </button>
          <!-- <button class="slds-button slds-button_brand" onclick={confirmHandle}>ファイル追加</button> -->
        </footer>
      </div>
    </section>
    <div class="slds-backdrop slds-backdrop_open"></div>
  </template>
</template>
import { LightningElement, track, api } from "lwc";
export default class FileUploadModal extends LightningElement {
  @api name;
  @track path;
  //表示フラグ
  @track _isModalOpen;

  /**
   * 共通Change処理
   * @param {*} event
   */
  commonChange(event) {
    let name = event.target.name;
    let value = event.target.value;
    this[name] = value;
    this.dispatchEvent(
      new CustomEvent("folderchange", {
        detail: value,
        composed: true,
        bubbles: true,
        cancelable: true,
      })
    );
  }

  /**
   * ファイルアップロード
   * @param {*} event
   */
  uploadHandler(event) {
    let changenEvent = new CustomEvent("select", {
      detail: event.target,
      composed: true,
      bubbles: true,
      cancelable: true,
    });
    this.dispatchEvent(changenEvent);
  }

  /**
   * モーダル開く
   */
  @api
  open() {
    this._isModalOpen = true;
  }

  /**
   * モーダル閉じる
   */
  close(e) {
    e.preventDefault();
    this._isModalOpen = false;
  }
}
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>52.0</apiVersion>
    <isExposed>false</isExposed>
</LightningComponentBundle>
  • progressbar
    image.png
<template>
  <template if:true="{isShow}">
    <section
      role="dialog"
      tabindex="-1"
      class="slds-modal slds-fade-in-open"
      aria-modal="true"
      style="z-index:9002"
    >
      <div class="slds-modal__container" style="width: auto;max-width: 50rem;">
        <header class="slds-modal__header">
          <button
            class="slds-button slds-button_icon slds-modal__close slds-button_icon-inverse"
            title="Close"
            onclick="{cancel}"
          >
            <lightning-icon
              icon-name="utility:close"
              alternative-text="close"
              variant="inverse"
              size="small"
            >
            </lightning-icon>
            <span class="slds-assistive-text">Close</span>
          </button>
          <h2 class="slds-modal__title slds-hyphenate">
            ファイルをアップロード
          </h2>
        </header>
        <div
          class="slds-modal__content slds-p-around_medium"
          style="display: grid;grid-template-columns: 0.5fr 3fr 3fr 0.5fr; height: 5rem;overflow-y: hidden;"
        >
          <div style="align-self: center;">
            <span class="slds-icon_container slds-icon-doctype-xml"> </span>
          </div>
          <div style="align-self: center;">
            <span>{uploadFileName} <br /><b>{progress}%</b></span>
          </div>
          <div
            class="slds-progress-bar slds-progress-bar_circular"
            style="align-self: center;height: 0.6rem;width: 20rem;"
          >
            <span class="slds-progress-bar__value" style="{barStyle}"></span>
          </div>
          <div style="align-self: center;margin-left: 0.2rem;">
            <span class="{barClass}"> </span>
          </div>
        </div>
        <footer class="slds-modal__footer">
          <span style="float: left;"></span>
          <button class="slds-button slds-button_brand" onclick="{cancel}">
            キャンセル
          </button>
        </footer>
      </div>
    </section>
    <div class="slds-backdrop slds-backdrop_open"></div>
  </template>
</template>
import { LightningElement, track, api } from "lwc";

const bar_cancel_class =
  "slds-icon_container slds-icon_container_circle slds-icon-action-description slds-icon-standard-password";
const bar_success_class =
  "slds-icon_container slds-icon_container_circle slds-icon-action-description slds-icon-text-success";

export default class Fileupload extends LightningElement {
  @api uploadFileName;
  @api progress = 0;
  @track isShow;

  /**
   * ProgressBar
   */
  get barStyle() {
    return `width:${this.progress}%`;
  }

  get barClass() {
    return this.progress >= 100 ? bar_success_class : bar_cancel_class;
  }

  /**
   * キャンセル
   */
  cancel(e) {
    e.preventDefault();
    this.dispatchEvent(
      new CustomEvent("abort", {
        detail: true,
      })
    );
    this.close();
  }

  @api
  close() {
    this.isShow = false;
  }

  @api
  open() {
    this.isShow = true;
  }
}
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>52.0</apiVersion>
    <isExposed>false</isExposed>
</LightningComponentBundle>
  • utils
    image.png
import { ShowToastEvent } from "lightning/platformShowToastEvent";

/**
 * メッセージ表示
 * @param {window} that
 * @param {string} title タイトール
 * @param {string} message メッセージ
 * @param {string} variant タイプ info、success、warning、error
 */
export const showToast = (that, title, message, variant) => {
  const event = new ShowToastEvent({
    title: title,
    message: message,
    variant: variant,
  });
  that.dispatchEvent(event);
};

/**
 * デートフォマート
 * @param {Date} date date
 * @param {string} fmt format
 * @returns {string} StringDate
 */
export const dateFormat = (date, fmt = "YYYY/mm/dd") => {
  let ret;
  const opt = {
    "Y+": date.getFullYear().toString(), // 年
    "m+": (date.getMonth() + 1).toString(), // 月
    "d+": date.getDate().toString(), // 日
    "H+": date.getHours().toString(), // 時
    "M+": date.getMinutes().toString(), // 分
    "S+": date.getSeconds().toString(), // 秒
  };
  for (let k in opt) {
    ret = new RegExp("(" + k + ")").exec(fmt);
    if (ret) {
      fmt = fmt.replace(
        ret[1],
        ret[1].length == 1 ? opt[k] : opt[k].padStart(ret[1].length, "0")
      );
    }
  }
  return fmt;
};

/**
 * YYYY/MM/DD ⇒ Mon Nov 27 2017 20:30:00 GMT+0900 (JST)に変換
 * @param {string} dataStr stringDate
 * @returns {Date} Date
 */
export const datePrase = (dataStr) => {
  return new Date(dataStr);
};

/**
 * デートフォマート
 * @param {string} date strData
 * @param {string} fmt format
 * @returns {string} StringDate
 */
export const strDateFormat = (strData, fmt = "YYYY/mm/dd HH:MM:SS") => {
  return dateFormat(datePrase(strData), fmt);
};

/**
 * ファイルサイズ変換
 * @param {*} size バイト
 * @returns 変換後のサイズ
 */
export const fileSizeUnit = (size) => {
  // 1 KB = 1024 Byte
  const kb = 1024;
  const mb = Math.pow(kb, 2);
  const gb = Math.pow(kb, 3);
  const tb = Math.pow(kb, 4);
  const pb = Math.pow(kb, 5);
  const round = (size, unit) => {
    return Math.round((size / unit) * 100.0) / 100.0;
  };

  if (size >= pb) {
    return round(size, pb) + "PB";
  } else if (size >= tb) {
    return round(size, tb) + "TB";
  } else if (size >= gb) {
    return round(size, gb) + "GB";
  } else if (size >= mb) {
    return round(size, mb) + "MB";
  } else if (size >= kb) {
    return round(size, kb) + "KB";
  }
  return size + "バイト";
};
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>52.0</apiVersion>
    <isExposed>false</isExposed>
</LightningComponentBundle>

4.Salesforce 側動作確認

  • 4.1.Salesforce 側 Lightning コンポーネントタブを作成
    image.png

  • 4.2.タブを開く
    image.png

  • 4.3.ファイル追加
    image.png

  • 4.4.ファイルダウンロード
    image.png

  • 4.5.ファイル削除
    image.png

5.参考