Salesforce DX + Vue.js + TypeScriptでISVパッケージ開発

# これはなに？

新しいAppExchangeアプリを作る予定なので、そのためにSalesforce DX + Vue.js + TypeScriptを使ったSalesforceのISVパッケージ開発のためのサンプル兼開発テンプレートを作りました。
Webpackで一から設定するのは面倒なので、Vue CLI 3で生成したアプリをベースにして修正を加えています。

コードは[atskimura/sfdx-vue-typescript-isv-template](https://github.com/atskimura/sfdx-vue-typescript-isv-template)で見ることができます。

`npm run setup`を実行するとスクラッチ組織でこれが表示されます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/2f1cb804-a587-6fd4-3d02-a714f2dc7713.png)

シンプルですが、Lightning Design System適用して、Apex呼んでカスタムオブジェクトを表示して、アイコン/画像を表示してと一通りの要素は揃っているかと思います。

# 構成は？

構成としては一枚のVisualforceページでVue.jsアプリを動かすといういわゆるSPAです。ApexはJavaScript Remotingで呼んでます。

Lightning Component Frameworkを使わず、Lightning Containerでもなくこの構成にしています。Lightning Containerは悩んだのですが、この構成にしました。後々Lightningコンポーネントを作ることになったら、Lightning Containerを使ってVueのコンポーネントをラップして提供することになると思います。

VueとTypeScriptを選んだ理由はなんとなくです。

# Salesforce DXはどう組み込んでいるの？

Salesforce DXの使い方としては、前に[SFDX-Falconを使ったISVパッケージの開発](https://qiita.com/atskimura/items/37c6f58d000189dc7a9d)の記事で紹介したこの図の通りの流れです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/6cc00774-0716-dee9-4b34-91f8979ac2d7.png)
（[AppExchange 開発者向けウェビナー 〜アプリ構築編〜 - Speaker Deck](https://speakerdeck.com/tak4hir0/appexchange-kai-fa-zhe-xiang-keuehina-apurigou-zhu-bian) P.29）

ただ、今回はパッケージ作成までしか作っていません。CI周りもまだです。

# 参考にしたもの

同じようなことをしようとしている人はそれなりにいるようで、参考になるものはいくつかありました。
ただ、ISVパッケージ開発を目的としているのはSFDX-Falconくらいで、パッケージング周りはSFDX-Falconが参考になりました。

* [SFDX-Falconを使ったISVパッケージの開発](https://qiita.com/atskimura/items/37c6f58d000189dc7a9d)
* [Salesforce DX with LDS + TypeScript + Vue.js - My note](https://www.kenichi-odo.com/articles/2018_01_06_sfdx-with-lds-and-typescript-and-vue/)
* [mattsimonis/vue-sfdx](https://github.com/mattsimonis/vue-sfdx)
* [ChuckJonas/bad-ass-salesforce-stack: B.A.S.S. Starter: react / redux / typescript / antd / ts-force / sfdx / webpack / salesforce](https://github.com/ChuckJonas/bad-ass-salesforce-stack)
* [tq-jappy/sfdx-template: Modern Salesforce Development Template and Samples](https://github.com/tq-jappy/sfdx-template)

Lightning Containerに書き換えたい場合は３番目のが参考になると思います。

# 使い方は？

使い方は[sfdx-vue-typescript-isv-template/README.md](https://github.com/atskimura/sfdx-vue-typescript-isv-template/blob/master/README.md)にけっこう書いたので、詳しくはそちらを読んでください。

簡単にだけ書いておくと、

```
npm run setup
```

で、スクラッチ組織が作成されてアプリが動きます。

```
npm run package
```

で、パッケージ開発組織にベータパッケージが作成されます。

# 開発方法

```
npm run serve
```

で、ローカルのWebサーバーで動かして開発します。
このときのApexへのアクセスはモックにしてしまうと割り切りました。どこかで開発に行き詰まる可能性はあります。
弊社製品の[Calsket](https://www.co-meeting.co.jp/products/calsket/)の開発環境ではローカルだとREST APIを呼ぶようにしてるんですけどね。

Salesforce側の開発はサーバーで修正して`npm run pull`したり、ローカルで修正して`npm run push`したりしてください。

# 注意点

自分用なので、Windowsのことは考えていません。
npm scriptsの環境変数のところと`rm`を呼んでいるところあたりを直せば、たぶんWindowsでも動くんじゃないかと思います。

# ディレクトリ構成

ディレクトリ構成は雑です:sweat_smile:

## クラインアントサイド

* `src`: クライアントサイドのコード
* `tests`: クライアントテストコード
* `public`: index.html, faviconなどルートに置かれるもの。

ビルド結果が`sfdx-src/managed/staticresources/app`に出力され、Salesforceに静的リソースとしてアップされる。

## Salesforce関係

* `sfdx-src`: SFDX形式のSalesforceのソースコード
  * `managed`: 管理パッケージに含むリソースを入れる。
  * `unmanaged`: 管理パッケージに含まないが、開発環境として共有したいリソースを入れる。
  * `untracked`: gitで管理しないリソースを入れる。pullするとデフォルトでここに入る。
* `config`: スクラッチ組織の設定。サンプルデータ。
* `mdapi-src`: Metadata API形式のソースコード。パッケージ作成のために一時的に作成される。

# 作り方は？

Vue CLI 3で生成されたアプリをどう修正してこのテンプレートを作ったかを書いておこうと思います。
このまま使う人はあまりいないと思うので、その方が参考になるかなと思いました。

Vue CLIで生成したアプリからの変更点はcompareで見れます。
[Comparing a27a5bd...master · atskimura/sfdx-vue-typescript-isv-template](https://github.com/atskimura/sfdx-vue-typescript-isv-template/compare/a27a5bd...master)

Vue CLIで生成したアプリをそのままVisualforceで動かそうとしたときに問題になるのは以下のような点です。

* SFDXの設定はどうするの？
* VueアプリをどうSalesforceにアップするか
* アセットのURLが相対パスでは辿れずVfpページを実行しないと決まらない
* Lightning Design System（LDS）をどう組み込むか
* LDSアイコンは基本SVG Spriteで表示するが、これも動的にURLが決まる
* JavaScript Remotingをどう呼ぶか
* ネームスペースの対応は？
* パッケージはどう作るの？

それぞれどう対応したかをまとめておきます。

## SFDXの設定はどうするの？

これは`sfdx-project.json`と`config/project-scratch-def.json`を用意するだけでOKです。
ディレクトリ構成は上に書いたようにSFDX-Falconを参考にしていますが、`untracked`をデフォルトにしています。こうするとpullしたときに`untracked`に落ちてくるので間違えてgitにpushしてしまうという事故が防げるかなと思ってます。

```sfdx-project.json
{
  "packageDirectories": [
    { "path": "sfdx-src/managed" },
    { "path": "sfdx-src/unmanaged" },
    { "path": "sfdx-src/untracked", "default": true }
  ],
  "namespace": "mynamespace",
  "sfdcLoginUrl": "https://login.salesforce.com",
  "sourceApiVersion": "44.0"
}
```

ただ、pullしたリソースのディレクトリを移動すると、pushするとエラーになる問題が起きました。
`.sfdx/orgs`にリソースごとにパスの情報も持ってしまっているのが原因のようです。いまいち手順は確立していないのですが、`.sfdx/org`を削除して`force:source:push -f`するとpushできました。
まあもう困ったらスクラッチ組織作り直したらいいと思います。

`config/project-scratch-def.json`はたぶんどこに置いてもいいのですが、SFDXプロジェクトだとここに置かれることが多いのでなんとなくそうしました。ディレクトリ名を変えた方がわかりやすいと思います:sweat_smile:

## VueアプリをどうSalesforceにアップするか

ビルド結果を直接、SFDXのパッケージディレクトリの静的リソースとして直接出力するようにします。
今回は`sfdx-src/managed/staticresources/app`に出力するので、`vue.config.js`に以下のように設定します。

```vue.config.js
  outputDir: 'sfdx-src/managed/main/default/staticresources/app',
```

デフォルトだとビルド時に`app.8fy9a8.js`のようにJavaScriptやCSSに乱数をつけてしまうので、これを無効にします。
（将来キャッシュで問題になるかもしれないので、何か修正は必要な気がします。）

```vue.config.js
  filenameHashing: false,
```

ビルドされたindex.htmlを参考にVisualforceページを作成します。

```html:index.page
<apex:page
  sidebar="false"
  showHeader="false"
  standardStylesheets="false"
  applyBodyTag="false"
  applyHtmlTag="false"
  docType="html-5.0">

  <html lang="ja">
    <head>
      <meta charset="utf-8" />
      <meta http-equiv="X-UA-Compatible" content="IE=edge" />
      <meta name="viewport" content="width=device-width,initial-scale=1.0" />
      <link rel="icon" href="{!URLFOR($Resource.app, '/favicon.ico')}"></link>
      <title>sfdx-vue-typescript-sample</title>
      <link href="{!URLFOR($Resource.app, '/css/app.css')}" rel="stylesheet"></link>
    </head>
    <body>
      <div id="app"></div>

      <script src="{!URLFOR($Resource.app, '/js/chunk-vendors.js')}"></script>
      <script src="{!URLFOR($Resource.app, '/js/app.js')}"></script>
    </body>
  </html>
</apex:page>
```

これで`force:source:push`すると、デフォルトのサンプルくらいは動くはずです。

## アセットのURLが相対パスでは辿れずVfpページを実行しないと決まらない

たぶんこの状態だとVue.jsのロゴが表示されません。

デフォルトだと4KB以上の画像はfile-loaderで処理され、HTMLからの相対パスで取得しようとしますが、静的リソースのURLは相対パスでは辿れないからです。
かといって、静的リソースのパスをfile-loaderに渡そうにもVfpを実行しないとわからないので、ビルド時に渡すのは無理です。

しょうがないのでここはurl-loaderのみで処理するようにします。これで画像は全てData URI形式でHTML/CSSの中に埋め込まれます。
ただし、ファイルサイズが大きい画像を使いたくなったら別の作戦がいるかもしれません。

```vue.config.js
  chainWebpack: config => {
    // 画像はurl-loaderでData URI形式で埋め込む
    const imagesRule = config.module.rule('images')
    imagesRule.uses.clear();
    imagesRule
      .use('url-loader')
        .loader('url-loader')
  }
```

この辺りの設定は詳しくは[Working with Webpack | Vue CLI 3](https://cli.vuejs.org/guide/webpack.html#chaining-advanced)を見てください。

## Lightning Design System（LDS）をどう組み込むか

LDSのCSSもstyleタグで展開してしまうという方法もあるのですが、それは無駄なので、Vfpでは`apex:slds`タグを使い、ローカルではCDNから読み込むという方針にしました。

vfpには`apex:slds`タグとbodyタグに`slds-scope`クラスを追加します。

```html:index.page
<apex:page
  {中略}
  docType="html-5.0">
  <apex:slds />

  <html lang="ja">
    <head>
      {中略}
    </head>
    <body class="slds-scope">
      <div id="app"></div>
      {中略}
    </body>
  </html>
</apex:page>
```

`public/index.html`にはCDNからLDSを読み込むコードを追加します。

```public/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    {中略}
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/design-system/2.7.5/styles/salesforce-lightning-design-system.css">
  </head>
  <body>
    {中略}
  </body>
</html>
```

## LDSアイコンは基本SVG Spriteで表示するが、これも動的にURLが決まる

これでLDSのクラスは使えるのですが、LDSではアイコンは基本的にSVG Spriteで使うことになっているので、`<use xhtml:href='ここにパス'>`とパスを書く必要があります。
しかし、`apex:slds`で読み込むとアイコンのパスはvfpの`$Asset.SLDS`で提供されます。

```html
<svg aria-hidden="true" class="slds-icon">
    <use xlink:href="{!URLFOR($Asset.SLDS, 'assets/icons/standard-sprite/svg/symbols.svg#account')}"></use>
</svg>
```

その上、useはクロスドメインが許されていないので、ローカルで動かしたときにCDNから読み込むことができません。

よい方法が思いつかず、仕方なく[@salesforce-ux/design-system](https://www.npmjs.com/package/@salesforce-ux/design-system) npmモジュールを読み込んで、url-loaderでimgタグに埋め込むことにしました。

npmパッケージをインストールして、

```
npm i @salesforce-ux/design-system --save
```

svgファイルはurl-loader使うように設定

```vue.config.js
  chainWebpack: config => {
    {中略}

    // svgはurl-loaderでData URI形式で埋め込む
    const svgRule = config.module.rule('svg')
    svgRule.uses.clear();
    svgRule
      .use('url-loader')
        .loader('url-loader')
  }
```

使う時はsrc属性にこう指定します。

```App.vue
  <span class="slds-icon_container slds-icon-custom-custom33" title="会議室">
    <img class="slds-icon slds-page-header__icon" src="~@salesforce-ux/design-system/assets/icons/custom/custom33.svg">
    <span class="slds-assistive-text">会議室</span>
  </span>
```

## JavaScript Remotingをどう呼ぶか

まずは普通にSalesforce側でJS Remotingの設定をします。

```java:RemotingService.cls
public class RemotingService {
  @RemoteAction
  public static Room__c[] getAllRooms(){
    return [SELECT Id, Name, Capacity__c FROM Room__c];
  }
}
```

```html:index.page
<apex:page
  {中略}
  controller="RemotingService">
```

クライアントサイドはproductionのとき（実質vfpで動くとき）のみJSRemoting呼んで、ローカルではダミーデータを返すという方針にしました。

```api/Rooms.ts
export default {
  getAllRooms(): Promise<any> {
    if (process.env.NODE_ENV === 'production') {
      return new Promise<any>((resolve) => {
        Visualforce.remoting.Manager.invokeAction(
          `RemotingService.getAllRooms`,
          (result: any[], event: any) => {
            resolve(result);
          },
        );
      });
    } else {
      return Promise.resolve([
        { Id: '1', Name: 'ローカル会議室A', Capacity__c: 4 },
        { Id: '2', Name: 'ローカル会議室B', Capacity__c: 5 },
        { Id: '3', Name: 'ローカル会議室C', Capacity__c: 6 },
      ]);
    }
  },
};
```

これで困らなければローカルはREST APIを呼ぶなんてするより楽だなあと。

それとTypeScriptから使うために以下の型定義も追加しています。

```types/visualforce.d.ts
declare namespace Visualforce.remoting {
  export class Manager {
    static invokeAction(actionName:string, ...args:any): void;
  }
}
```

## ネームスペースの対応は？

二箇所で設定しています。

* `sfdx-project.json`
* `src/config.ts`

`sfdx-project.json`はスクラッチ組織をネームスペース付きで作るのに必須です。

`src/config.ts`はJS内でネームスペースが必要になることがあるので、その読み込み用です。

```src/config.ts
export default {
  $namespace: 'mynamespace',
};
```

今のところさきほどのJS Remotingを呼ぶところだけ修正すれば大丈夫でした。
呼び出し時に`${config.$namespace}.RemotingService.getAllRooms`として、レスポンスにもネームスペースがついてくるのでそれをただ除去しています。

```api/Rooms.ts
import config from '@/config';

export default {
  getAllRooms(): Promise<any> {
    if (process.env.NODE_ENV === 'production') {
      return new Promise<any>((resolve) => {
        window.Visualforce.remoting.Manager.invokeAction(
          `${config.$namespace}.RemotingService.getAllRooms`,
          (result: any[], event: any) => {
            resolve(result.map((record) => this.stripNamespace(record)));
          },
        );
      });
    } else {
      return Promise.resolve([
        { Id: '1', Name: 'ローカル会議室A', Capacity__c: 4 },
        { Id: '2', Name: 'ローカル会議室B', Capacity__c: 5 },
        { Id: '3', Name: 'ローカル会議室C', Capacity__c: 6 },
      ]);
    }
  },
  stripNamespace(record: any): any {
    const newRecord: {[s: string]: any} = {};
    for (const [name, value] of Object.entries(record)) {
      const match = name.match(/^[\w\d]+__([\w\d_]+__c)/);
      newRecord[match ? match[1] : name] = value;
    }
    return newRecord;
  },
};
```

## パッケージはどう作るの？

基本的には[package.json](https://github.com/atskimura/sfdx-vue-typescript-isv-template/blob/master/package.json)の`scripts`を見てください。

パッケージ作成については`npm run package`でできるので、以下のコマンドを追ってもらえればわかると思います。
Metadata API形式にソースを変換し、パッケージ開発組織にデプロイし、ベータパッケージ作成をしています。

```json
    "package": "npm run source:convert && npm run mdapi:deploy && npm run package1:create",
```

ついでに他の`npm scripts`も軽く説明しておきましょう。

`npm run setup`はスクラッチ組織作って、ソースをpushして、権限セットをつけて、サンプルデータを入れます。

```json
    "setup": "npm run org:create && npm run push && npm run permset:assign && npm run data:import",
```

あと開発用によく使いそうなのは、`serve`、`deploy`、`push`、`pull`、`test`あたりですね。
他のスクリプトは単独では使わないのがほとんどかと思います。

# 終わりに

Vue CLIで他の設定にしても、Reactアプリでも必要な修正はだいたい同じかと思います。
あえてLightningではないので需要があるかわかりませんが、どなたかの参考になれば幸いです。

最後に軽く宣伝を。

株式会社co-meetingはエンジニアを募集しています。Salesforceエンジニアと言いつつ、このようにVue.jsを使ったりもしますので、興味のある方はぜひお気軽にご連絡ください！

### [【在宅/時短勤務可】一緒に働く「ITエンジニア」を募集しています。 | 株式会社co-meeting](https://www.co-meeting.co.jp/recruit-engineer-2018/)