Electron の contextBridge に勝利するための API 設計方法 #TypeScript

この記事はあくまで個人の見解です。もうちょっといい方法もあると思うので、あくまで方法の1つと思ってください。
また用語等の誤用もあるかもしれませんが、温かい目で見逃していただけると幸いです。

※ contextBridge への日頃の恨みつらみなので飛ばしてもらって構わないです。

みなさん Electron つかっていますか。
Chromium ベースでフロントは Node.js に対応しているので、シンプルなアプリケーションであればすぐにマルチプラットフォームにソフトウェアが作れるのでとても便利ですね。

しかし、 Electron には最凶の仕様をもつ輩がいます。

ipc 通信

こいつです。
憎いです。

ソフトウェアの内部システムに干渉するためには、 ipc 通信でフロントとの通信をすることで様々な機能を実装できます。
しかし、この ipc 通信には邪悪な点がたくさんあります。

  1. ipcMain, ipcRenderer の使い分け
    Electron には、 preloadmain の2つの区間が存在し、フロントと通信するためには一度 preload を介さないといけません。しかし、preload で使える機能と main で使える機能には制限があり、 main で使う機能のための preload の設定をしたいけど、その設定のためには main で使う関数を使わないといけない…
    等々、かなり複雑に行ったり来たりしていまうことも多々あるでしょう。
  2. コードのスパゲティ
    Electron で内部の機能をもった API を作成するためには、
- `main` プロセスで実際の機能を実装
- `preload` プロセスで `main` とフロントをつなぐために contextBridge を使う
- フロントで使うための関数の型定義

なんと、同じ1つの関数を使うために 3 個所 も同じ名前であったり関数を記述しないといけないのです。ひどいですね。そうですわかります。

これらを解決するために、いろいろ調べて自分の中での今出せる最適解を見つけましたので、メモも兼ねて共有します。

解説は後に回すとして、まずは実際のコードを見てもらいましょう。
再帰的かつエラーハンドリングや型定義も兼ねているのでファイルの数も多いです。

/tsconfig.json

/tsconfig.json

{
  "files": [],
  "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }, { "path": "./tsconfig.preload.json" }],
  "compilerOptions": {
    "forceConsistentCasingInFileNames": true,
    "strict": true
  }
}
/tsconfig.node.json

/tsconfig.node.json

{
  "extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
  "include": [
    "electron.vite.config.*",
    "src/main/**/*",
    "src/preload/**/*",
    "src/renderer/**/*",
    "src/**/*.(d.ts|ts|js)",
    "src/global.ts",
    "src/renderer/renderer.d.ts",
  ],
  "compilerOptions": {
    "strict": true,
    "skipLibCheck": false,
    "composite": true,
    "moduleResolution": "node16",
    "module": "Node16",
    "typeRoots": [
      "./node_modules/@types",
      "./src/types/global",
    ],
  }
}
/tsconfig.preload.json

/tsconfig.preload.json

{
    "extends": "./tsconfig.node.json",
    "compilerOptions": {
        "module": "CommonJS",
        "outDir": "./out/preload",
        "moduleResolution": "node",
    },
    "include": [
        "src/preload/**/*",
    ],
}
/tsconfig.web.json

/tsconfig.web.json

{
  "extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
  "include": [
    "src/renderer/src/env.d.ts",
    "src/renderer/src/**/*",
    "src/renderer/src/**/*.ts",
    "src/renderer/*.d.ts",
    "src/renderer/renderer.d.ts",
  ],
  "compilerOptions": {
    "composite": true,
    "verbatimModuleSyntax": true,
    "useDefineForClassFields": true,
    "resolveJsonModule": true,
    "strict": true,
    "allowJs": true,
    "checkJs": true,
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
  }
}
/src/main/index.ts

/src/main/index.ts

import { apiHandlers, registerAPIHandlers } from '../preload/utils/api/handler.js' // ts.node.json にて module を node16 に設定しているため、 .ts ではなく .js でインポート

app.whenReady().then(() => {
  electronApp.setAppUserModelId('com.electron')

  app.on('browser-window-created', (_, window) => {
    optimizer.watchWindowShortcuts(window)
  })

  createWindow()

  // ハンドラーを API として登録
  registerAPIHandlers(apiHandlers)

  app.on('activate', function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})
/src/preload/index.ts

/src/preload/index.ts

import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { createAPIInvoker, apiHandlers, type APIHandler } from './utils/api/handler.js'
import { apiListeners, type APIListeners } from './utils/api/listener.js'
import { logStatus } from './utils/logStatus.js'

// 開発環境用に一応分けておく
if (process.contextIsolated) {
  try {
    contextBridge.exposeInMainWorld('electron', electronAPI)
    const APIRenderer = createAPIInvoker(apiHandlers)
    contextBridge.exposeInMainWorld('api', {
      invoke: APIRenderer,
      listeners: apiListeners
    })
  } catch (error) {
    logStatus(
      { code: 500, message: 'contextBridge による API のエクスポートに失敗しました' },
      null,
      error
    )
  }
} else {
  window.electron = electronAPI
  window.api = {
    invoke: createAPIInvoker(apiHandlers) as unknown as APIHandler,
    listeners: apiListeners as APIListeners
  }
  logStatus({ code: 200, message: 'window による API のエクスポートに成功しました' })
}
/src/preload/index.d.ts

/src/preload/index.d.ts

/**
    /src/renderer.d.ts も同じ。インポートは適宜変更
    */
import { ElectronAPI } from '@electron-toolkit/preload'
import type { APIHandler } from './utils/api/handler.js'
import type { APIListeners } from './utils/api/listener.js'

declare global {
  interface Window {
    electron: ElectronAPI
    api: {
      invoke: APIHandler
      listeners: APIListeners
    }
  }
}
/src/preload/types/index.ts

/src/preload/types/index.ts

/**
 * API のレスポンスのスキーマ
 * @template T API のレスポンスのデータの型
 */
type APIRecordT> = {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  [key: string]: ((...args: any[]) => PromiseT>) | APIRecordT>
}

type Status = {
  code: number
  message?: string
}

/**
 * API のレスポンスのスキーマ
 */
type APISchemaT = object | string | Buffer | null> = {
  status: Status
  data?: T
  error?: string | Error | unknown
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AsyncFunction = (...args: any[]) => Promiseany>

type RecursiveAPIT> = {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  [K in keyof T]: T[K] extends (...args: any[]) => any
    ? (...args: ParametersT[K]>) => ReturnTypeT[K]>
    : RecursiveAPIT[K]>
}

export type { APIRecord, Status, APISchema, AsyncFunction, RecursiveAPI }
/src/preload/utils/logStatus.ts

/src/preload/utils/logStatus.ts

import type { Status, APISchema } from '../types/index.js'

const statusSchema: { [key: string]: { [key: number]: { message: string } } } = {
  SUCCESS: {
    200: {
      message: 'Success'
    }
  },
  WARN: {
    300: {
      message: 'Warning'
    },
    301: {
      message: 'Redirect'
    },
    302: {
      message: 'Found'
    },
    304: {
      message: 'Not Modified'
    }
  },
  ERROR: {
    400: {
      message: 'Bad Request'
    },
    401: {
      message: 'Unauthorized'
    },
    403: {
      message: 'Forbidden'
    },
    404: {
      message: 'Not Found'
    },
    500: {
      message: 'Internal Server Error'
    }
  }
}

/**
 * ステータスをログに出力する
 * @param message ログに出力するメッセージ
 * @param status ログに出力するステータス ('SUCCESS' または 'ERROR')
 * @param error エラーが発生した場合のエラーオブジェクトまたはエラーメッセージ (オプション)
 */
const logStatus = T extends object | unknown>(
  status: Status,
  message: T = {} as T,
  error?: unknown
): APISchemaT> => {
  const statusType = Object.keys(statusSchema).find((key) => statusSchema[key][status.code])

  let logMessage = status.message

  if (!logMessage) {
    if (statusType) {
      logMessage = statusSchema[statusType][status.code].message
    } else {
      logMessage = statusType === 'SUCCESS' ? 'Success' : 'Internal Server Error'
    }
  }

  if (statusType === 'SUCCESS') {
    console.log(`[SUCCESS] ${logMessage}`)
    return { status: { code: status.code, message: logMessage }, data: message }
  } else if (statusType === 'ERROR') {
    console.error(`[ERROR] ${logMessage}`)
    if (error) {
      const errorMessage = error instanceof Error ? error.message : error
      console.error(`  └─ ${errorMessage}`)
      return { status, error: errorMessage }
    }
    return { status, error: logMessage }
  } else if (statusType === 'WARN') {
    console.warn(`[WARN] ${logMessage}`)
    return { status, data: message }
  } else {
    console.error(`[ERROR] Invalid status code: ${status.code}`)
    return { status, error: 'Internal Server Error' }
  }
}

export { logStatus }
/src/preload/utils/api/handler.ts

/src/preload/utils/api/handler.ts

import { app, BrowserWindow, ipcMain, ipcRenderer } from 'electron'
import { APIRecord, APISchema, RecursiveAPI } from '../../types/index.js'
import { logStatus } from '../logStatus.js'
import { v4 as uuid } from 'uuid'
import { registerAPIListeners } from './listener.js'
import type { FileStructure, Project } from '../../../global.js'
import path from 'path'
import fs from 'fs'

/**
 * API のハンドラ郡
 */
const apiHandlers = {
  project: {
    create: async (project: Project): PromiseAPISchemaProject | null>> => {
      const userDirPath = app.getPath('userData')
      const projectsPath = path.join(userDirPath, 'project/projects')

      try {
        const projects = fs.readdirSync(projectsPath)
        let projectId = uuid()
        while (projects.includes(projectId)) {
          projectId = uuid()
        }
        console.log('projectId', projectId)
        project.id = projectId
        const projectPath = path.join(projectsPath, projectId)
        fs.mkdirSync(projectPath, { recursive: true })
        const projectFilePath = path.join(projectPath, 'project.json')
        fs.writeFileSync(projectFilePath, JSON.stringify(project, null, 2))

        return logStatus(
          { code: 200, message: 'プロジェクトを作成しました' },
          { id: projectId, name: project.name, description: project.description, icon: project.icon, tags: project.tags, status: project.status } as Project,
        )
      } catch (err) {
        return logStatus({ code: 500, message: 'プロジェクトの作成に失敗しました' }, null, err)
      }
    },
    delete: async (projectId: string): PromiseAPISchemanull>> => {
      const userDirPath = app.getPath('userData')
      const projectsPath = path.join(userDirPath, 'project/projects')

      try {
        const projectPath = path.join(projectsPath, projectId)
        fs.rmSync(projectPath, { recursive: true, force: true })
        return logStatus({ code: 200, message: 'プロジェクトを削除しました' }, null)
      } catch (err) {
        return logStatus({ code: 404, message: 'プロジェクトが見つかりません' }, null, err)
      }
    },
 }
 } satisfies APIRecordAPISchema>

 /**
 * API のハンドラを登録する
 * @param apiObj API のハンドラ郡
 * @param parentKey 親のキー
 */
const registerAPIHandlers = T>(apiObj: APIRecordT>, parentKey = ''): void => {
  for (const key in apiObj) {
    const fullKey = parentKey ? `${parentKey}.${key}` : key
    if (typeof apiObj[key] === 'function') {
      ipcMain.handle(`invoke-api:${fullKey}`, async (_event, ...args) => {
        try {
          return await (apiObj[key] as (...args: unknown[]) => PromiseT>)(...args)
        } catch (err) {
          return logStatus({ code: 500, message: 'API の呼び出しに失敗しました' }, null, err)
        }
      })
    } else {
      registerAPIHandlers(apiObj[key] as APIRecordT>, fullKey)
    }
  }
  registerAPIListeners(apiObj, parentKey)
}

/**
 * API のインボーカを作成する
 * @param apiObj API のハンドラ郡
 * @param parentKey 親のキー
 * @returns API のインボーカ
 */
const createAPIInvoker = T>(apiObj: APIRecordT>, parentKey = ''): RecursiveAPIT> => {
  const apiRenderer: PartialRecursiveAPIT>> = {}

  for (const key in apiObj) {
    const fullKey = parentKey ? `${parentKey}.${key}` : key
    if (typeof apiObj[key] === 'function') {
      apiRenderer[key] = async (...args: unknown[]): PromiseAPISchema> => {
        return ipcRenderer.invoke(`invoke-api:${fullKey}`, ...args)
      }
    } else {
      apiRenderer[key] = createAPIInvoker(apiObj[key] as APIRecordT>, fullKey)
    }
  }

  return apiRenderer as RecursiveAPIT>
}

type APIHandler = RecursiveAPItypeof apiHandlers>

export { apiHandlers, registerAPIHandlers, createAPIInvoker, type APIHandler }
/src/preload/utils/api/ipcManager.ts

/src/preload/utils/api/ipcManager.ts

import { ipcRenderer } from 'electron'
import { logStatus } from '../logStatus.js'

type ListenerT = unknown> = (...args: T[]) => void

const ipcManager = {
  listeners: new Mapstring, Listener[]>(),

  onT = unknown[]>(channel: string, listener: ListenerT>): () => void {
    if (!this.listeners.has(channel)) {
      this.listeners.set(channel, [])
    }

    const channelListeners = this.listeners.get(channel)!
    channelListeners.push(listener as Listener)

    const wrappedListener = ((_: Electron.IpcRendererEvent, ...args: T[]): void => {
      listener(...args)
    }) as Listenerunknown>

    ipcRenderer.on(channel, wrappedListener)

    logStatus(
      { code: 200, message: `リスナーを登録しました: ${channel}` }
    )

    return () => {
      this.off(channel, wrappedListener)
    }
  },

  off(channel: string, listener: Listener): void {
    const channelListeners = this.listeners.get(channel)
    if (channelListeners) {
      const index = channelListeners.indexOf(listener)
      if (index !== -1) {
        channelListeners.splice(index, 1)
        ipcRenderer.removeListener(channel, listener)
      }

      if (channelListeners.length === 0) {
        this.listeners.delete(channel)
      }

      logStatus(
        { code: 200, message: `リスナーを削除しました: ${channel}` }
      )
    } else {
      logStatus(
        { code: 404, message: `リスナーが見つかりません: ${channel}` },
        null,
        new Error(`リスナーが見つかりません: ${channel}`)
      )
    }
  },

  onceT = unknown[]>(channel: string, listener: ListenerT>): void {
    const wrappedListener = ((...args: T[]): void => {
      listener(...args)
      this.off(channel, wrappedListener)
    }) as Listenerunknown>
    this.on(channel, wrappedListener)

    logStatus(
      { code: 200, message: `リスナーを登録しました: ${channel}` }
    )
  }
}

export { ipcManager }
/src/preload/utils/api/listener.ts

/src/preload/utils/api/listener.ts

import { ipcMain, ipcRenderer } from 'electron'
import { ipcManager } from './ipcManager.js'
import { logStatus } from '../logStatus.js'
import type { APISchema, APIRecord, RecursiveAPI } from '../../types/index.js'

const apiListeners = {
  stream: {
    onResponse: T extends unknown[]>(
      callback: (args: T) => void
    ): PromiseAPISchema() => void>> => {
      const safeCallback = (...args: unknown[]): void => {
        callback(args as T)
      }
      ipcManager.on('stream:response', safeCallback)
      return new Promise((resolve) => {
        const removeListener = (): void => {
          ipcManager.off('stream:response', safeCallback)
        }
        return resolve(
          logStatus(
            { code: 200, message: 'stream:response リスナーを登録しました' },
            removeListener
          )
        )
      })
    }
  }
} satisfies APIRecordAPISchema>

const registerAPIListeners = T>(apiObj: APIRecordT>, parentKey = ''): void => {
  console.log(`[IPC] Registering listeners for path: ${parentKey}`)
  for (const key in apiObj) {
    const fullKey = parentKey ? `${parentKey}.${key}` : key
    if (typeof apiObj[key] === 'function') {
      ipcMain.on(`on-api:${fullKey}`, (_event, ...args) => {
        try {
          ;(apiObj[key] as (...args: unknown[]) => PromiseT>)(...args)
        } catch (err) {
          console.error(`[ERROR] IPC Listener error: ${fullKey}`, err)
        }
      })
    } else {
      registerAPIListeners(apiObj[key] as APIRecordT>, fullKey)
    }
  }
}

/**
 * API のエミッターを作成する
 * @param apiObj API のリスナー郡
 * @param parentKey 親のキー
 * @returns API のエミッター
 */
const createAPIEmitter = T>(apiObj: APIRecordT>, parentKey = ''): RecursiveAPIT> => {
  const apiEmitter: PartialRecursiveAPIT>> = {}

  for (const key in apiObj) {
    const fullKey = parentKey ? `${parentKey}.${key}` : key
    if (typeof apiObj[key] === 'function') {
      apiEmitter[key] = (...args: unknown[]): void => {
        ipcRenderer.send(`on-api:${fullKey}`, ...args)
      }
    } else {
      apiEmitter[key] = createAPIEmitter(apiObj[key] as APIRecordT>, fullKey)
    }
  }

  return apiEmitter as RecursiveAPIT>
}

type APIListeners = RecursiveAPItypeof apiListeners>

export { apiListeners, registerAPIListeners, createAPIEmitter, type APIListeners }

tsconfig 系

API の作成方法とか言っておきながら、一緒にビルドのための諸設定も書いておきました。
ただ、 tsconfig.preload.jsonnode16 の設定等はしないとインポートにエラーが出るので注意が必要です。

それ以外は各々必要な設定をしていただいても構いませんが、ビルドまで含めたら最低限の設定にはなってるかなと。

preload

logStatus

const statusSchema: { [key: string]: { [key: number]: { message: string } } } = {
  SUCCESS: {
    200: {
      message: 'Success'
    }
  },
  ERROR: {
    400: {
      message: 'Bad Request'
    },
    404: {
      message: 'Not Found'
    },
    500: {
      message: 'Internal Server Error'
    }
  }
}

const logStatus = T extends object | unknown>(
  status: Status,
  message: T = {} as T,
  error?: unknown
): APISchemaT> => {
  const statusType = Object.keys(statusSchema).find((key) => statusSchema[key][status.code])

  let logMessage = status.message

  if (!logMessage) {
    if (statusType) {
      logMessage = statusSchema[statusType][status.code].message
    } else {
      logMessage = statusType === 'SUCCESS' ? 'Success' : 'Internal Server Error'
    }
  }

  if (statusType === 'SUCCESS') {
    console.log(`[SUCCESS] ${logMessage}`)
    return { status: { code: status.code, message: logMessage }, data: message }
  } else if (statusType === 'ERROR') {
    console.error(`[ERROR] ${logMessage}`)
    if (error) {
      const errorMessage = error instanceof Error ? error.message : error
      console.error(`  └─ ${errorMessage}`)
      return { status, error: errorMessage }
    }
    return { status, error: logMessage }
  } else if (statusType === 'WARN') {
    console.warn(`[WARN] ${logMessage}`)
    return { status, data: message }
  } else {
    console.error(`[ERROR] Invalid status code: ${status.code}`)
    return { status, error: 'Internal Server Error' }
  }
}

こちらは、 API と直接関係ありませんが、完結で安全な開発のために追加しました。
各種ログを、ステータスコードや実際の返り値、あるいはエラーを各所でログとして出力してくれます。
フロントだけでなくメインプロセスでもログを同時に出してくれるので、どの段階でデータがどのような状態を保持しているかが確認できます。

Handler

Handler と書きましたが、 Listener もほとんど同じ仕組みです。割愛します。

APIRecord

type APIRecordT> = {
  [key: string]: ((...args: any[]) => PromiseT>) | APIRecordT>
}

API のレスポンスのレコードです。
不特定のキーを持つオブジェクトに、 Promise である API のレスポンスあるいは、その中に更にオブジェクトとしてキーがあることも許容されています。

APISchema

type Status = {
  code: number
  message?: string
}

type APISchemaT = object | string | Buffer | null> = {
  status: Status
  data?: T
  error?: string | Error | unknown
}

API のレスポンスのスキーマです。
ステータスコードを基として、レスポンスのデータやエラーの内容が定義されています。
T = object | string | Buffer | null となっているのは、画像等のレスポンスがあることを考慮しているため、各々の API の返り値に合わせて変更してください。

RecursiveAPI

type AsyncFunction = (...args: any[]) => Promiseany>

type RecursiveAPIT> = {
  [K in keyof T]: T[K] extends (...args: any[]) => any
    ? (...args: ParametersT[K]>) => ReturnTypeT[K]>
    : RecursiveAPIT[K]>
}

API のレスポンスを再帰的に取得するための型です。
T[K] が関数であれば、引数の型を取得して、戻り値の型を取得します。
そうでなければ、再帰的に RecursiveAPI を取得します。

上記の型を使用して、下記のように実際に Handler を定義します。
コードは、私が別途開発しているものなので、内容は気にしなくても構いません。

const apiHandlers = {
  project: {
    create: async (project: Project): PromiseAPISchemaProject | null>> => {
      const userDirPath = app.getPath('userData')
      const projectsPath = path.join(userDirPath, 'project/projects')

      try {
        const projects = fs.readdirSync(projectsPath)
        let projectId = uuid()
        while (projects.includes(projectId)) {
          projectId = uuid()
        }
        console.log('projectId', projectId)
        project.id = projectId
        const projectPath = path.join(projectsPath, projectId)
        fs.mkdirSync(projectPath, { recursive: true })
        const projectFilePath = path.join(projectPath, 'project.json')
        fs.writeFileSync(projectFilePath, JSON.stringify(project, null, 2))

        return logStatus(
          { code: 200, message: 'プロジェクトを作成しました' },
          { id: projectId, name: project.name, description: project.description, icon: project.icon, tags: project.tags, status: project.status } as Project,
        )
      } catch (err) {
        return logStatus({ code: 500, message: 'プロジェクトの作成に失敗しました' }, null, err)
      }
    },
    delete: async (projectId: string): PromiseAPISchemanull>> => {
      const userDirPath = app.getPath('userData')
      const projectsPath = path.join(userDirPath, 'project/projects')

      try {
        const projectPath = path.join(projectsPath, projectId)
        fs.rmSync(projectPath, { recursive: true, force: true })
        return logStatus({ code: 200, message: 'プロジェクトを削除しました' }, null)
      } catch (err) {
        return logStatus({ code: 404, message: 'プロジェクトが見つかりません' }, null, err)
      }
    },
 }
} satisfies APIRecordAPISchema>

このように satisfies APIRecord と型を定義させることで、

const apiHandlers: {
    project: {
        create: () => PromiseAPISchema{
            project: Project;
        } | null>>;
        delete: (projectId: string) => PromiseAPISchemanull>>;
    };
}

と、一つ一つの関数を型定義しなくとも、自動ですべての型を再帰的に定義してくれます。

ipcManager

こちらはコードの引用はしません。基本的に書いてあるとおりです。
ipcManager では、メインプロセスからフロントに対して信号やイベントを送りたいときに使用します。フロントでは、リスナーを起動しておけばその間はイベントを受け取ることができます。適切にリスナーを削除することで、双方通行の通信が実現されます。

メインプロセスでのリスナーの送信

const apiListeners = {
  stream: {
    onResponse: T extends unknown[]>(
      callback: (args: T) => void
    ): PromiseAPISchema() => void>> => {
      const safeCallback = (...args: unknown[]): void => {
        callback(args as T)
      }
      ipcManager.on('stream:response', safeCallback)
      return new Promise((resolve) => {
        const removeListener = (): void => {
          ipcManager.off('stream:response', safeCallback)
        }
        return resolve(
          logStatus(
            { code: 200, message: 'stream:response リスナーを登録しました' },
            removeListener
          )
        )
      })
    },
  }
}

フロントでのリスナー起動の例

// onResponse は APISchema が適応されているので、 data の中にはリスナーを削除する関数が組み込まれています。
const stream = await window.api.listeners.stream.onResponse((chunks) => {
  const chunk = chunks[0]

  if (chunk) {
    return chunk
  } else {
    return new Error('no chunks')
  }
})

// リスナーの削除
stream.data()

返り値は logStatus により管理されているため、 removeListenerlogStatusdata に格納されています。

いかがでしたでしょうか。応用の全く効かない記事が完成してしまいましたが、組み込みとして確立してしまえばかなり便利になります。
みなさんもぜひ contextBridge に勝利してみてはいかがでしょうか。

今後はこれらを npm パッケージとしてまとめて、もっと簡潔に公開したいです。
また進捗がありましたら記事を投稿したいと思います。



フラッグシティパートナーズ海外不動産投資セミナー 【DMM FX】入金

Source link