@midra/webext 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +72 -2
- package/dist/index.d.ts +58 -12
- package/dist/index.js +246 -19
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Midra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Chrome / Firefox の WebExtensions API を共通のインターフェースで
|
|
|
9
9
|
- **サイドパネル**:`webext.side` で Chrome / Firefox の操作を共通化。
|
|
10
10
|
- **タブ取得・ポップアウト**:コンテンツスクリプト自身のタブ取得と、タブに関連付けた別ウィンドウの作成。
|
|
11
11
|
- **ストレージ**:不足メソッドの補完、値の取得・保存、変更監視。
|
|
12
|
-
- **型付きメッセージング**:チャンネルごとのリクエスト・レスポンスの型定義。
|
|
12
|
+
- **型付きメッセージング**:チャンネルごとのリクエスト・レスポンスの型定義。MAIN worldとの双方向通信にも対応。
|
|
13
13
|
- **メニュー**:`menus` で Firefox の `menus` / Chrome の `contextMenus` の差異を吸収。
|
|
14
14
|
|
|
15
15
|
グローバルの `chrome` / `browser` は変更しません。WXT、ビルド時のブラウザ指定、追加のランタイム依存は不要です。
|
|
@@ -218,6 +218,76 @@ const result = await channel.send('greet', { name: 'Midra' })
|
|
|
218
218
|
- 型は実行時検証ではありません。受信値やsenderの追加検証は必要に応じてhandler内で行ってください。
|
|
219
219
|
- `stop()` / `channel.dispose()` / `webext.dispose()` でリスナーを解除。
|
|
220
220
|
|
|
221
|
+
### MAIN worldとの通信
|
|
222
|
+
|
|
223
|
+
MAIN worldでは拡張APIを使えないため、`createMainWorldMessaging()` を使用します。同じフレームのISOLATED worldコンテンツスクリプトで、中継を登録してください。既存の `channel().send()/handle()` とスキーマを共有できます。
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
// 共通の型
|
|
227
|
+
interface WorldMessages {
|
|
228
|
+
greet: { request: string; response: string }
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
// ISOLATED worldのコンテンツスクリプト
|
|
234
|
+
import { webext } from '@midra/webext'
|
|
235
|
+
|
|
236
|
+
const stopBridge = webext.messaging.bridgeMainWorld({
|
|
237
|
+
namespace: 'my-extension/world',
|
|
238
|
+
channels: ['app/world'], // 中継を許可するチャンネルだけ指定
|
|
239
|
+
})
|
|
240
|
+
const channel = webext.messaging.channel<WorldMessages>('app/world')
|
|
241
|
+
channel.handle('greet', (name) => `Content: ${name}`)
|
|
242
|
+
|
|
243
|
+
const reply = await channel.send('greet', 'Midra', { target: 'main-world' })
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
// MAIN worldのスクリプト
|
|
248
|
+
import { createMainWorldMessaging } from '@midra/webext'
|
|
249
|
+
|
|
250
|
+
const messaging = createMainWorldMessaging({ namespace: 'my-extension/world' })
|
|
251
|
+
const channel = messaging.channel<WorldMessages>('app/world')
|
|
252
|
+
channel.handle('greet', (name) => `MAIN: ${name}`)
|
|
253
|
+
|
|
254
|
+
const backgroundReply = await channel.send('greet', 'Midra')
|
|
255
|
+
const contentReply = await channel.send('greet', 'Midra', {
|
|
256
|
+
target: 'content-script',
|
|
257
|
+
})
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
// background(トップレベルで登録)
|
|
262
|
+
import { webext } from '@midra/webext'
|
|
263
|
+
|
|
264
|
+
const channel = webext.messaging.channel<WorldMessages>('app/world')
|
|
265
|
+
channel.handle('greet', (name, sender) => {
|
|
266
|
+
console.log(sender.world, sender.tab?.id) // MAIN由来ならworldは 'MAIN'
|
|
267
|
+
return `Background: ${name}`
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// 任意のタイミングで、対象タブ・フレームのMAIN worldへ送る
|
|
271
|
+
const reply = await channel.send('greet', 'Midra', {
|
|
272
|
+
target: 'main-world', tabId: 123, frameId: 0,
|
|
273
|
+
})
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
| 送信元 → 送信先 | `send()` の指定 |
|
|
277
|
+
| --- | --- |
|
|
278
|
+
| MAIN → background | 指定なし(または `target: 'background'`) |
|
|
279
|
+
| MAIN → 同じフレームのcontent script | `target: 'content-script'` |
|
|
280
|
+
| content script → 同じフレームのMAIN | `target: 'main-world'` |
|
|
281
|
+
| background / 拡張ページ → MAIN | `target: 'main-world', tabId`。必要なら `frameId` / `documentId` |
|
|
282
|
+
|
|
283
|
+
- 中継はコンテンツスクリプトのインスタンスにつき1つ。送信前に両worldのスクリプト・受信ハンドラー・中継を登録してください。MAIN worldのスクリプトの注入は利用側で行います。
|
|
284
|
+
- 中継に指定していないチャンネルは公開しません。通常のruntime/tabs通信は従来どおり動作します。`target: 'background'` は `runtime.sendMessage()` を使うため、該当する拡張内ハンドラーは1つにしてください。
|
|
285
|
+
- MAIN由来の要求には `sender.world: 'MAIN'` を付けます。backgroundにはネイティブの実際のタブ・フレーム情報を渡します。content script内のハンドラーにはページURLを渡し、拡張ID・タブIDは付けません。
|
|
286
|
+
- 通信は同じWindow・origin・namespaceに限定しますが、**ページのスクリプトも内容を読み書きできます**。namespace・`sender.world` は認証に使えません。公開する処理と要求値を検証し、機密情報をこの通信に載せないでください。
|
|
287
|
+
- JSON制約・`RemoteError`・タイムアウト・`AbortSignal` は既存の通信と同じです。タイムアウト/中止時にはDOM応答の待機も解除します。
|
|
288
|
+
- `stopBridge()` / `webext.dispose()` は中継を解除します。`messaging.dispose()` はMAIN側を解除します。これらの破棄は未完了のDOM応答待機を拒否します。受信済みの処理は継続します。
|
|
289
|
+
- 通常のHTTP(S)ページが対象です。`data:` やsandbox iframeなど、originが `null` のドキュメントは非対応です。
|
|
290
|
+
|
|
221
291
|
## ネイティブ API と移行
|
|
222
292
|
|
|
223
293
|
ブラウザ固有の操作には `webext.native` を使用できます。`webext.sidePanel` / `webext.sidebarAction` もネイティブのままです。
|
|
@@ -273,7 +343,7 @@ bun run build
|
|
|
273
343
|
- `src/disposables.ts` / `src/facade.ts`:解除処理の管理、ネイティブAPIを変更しないラッパー。
|
|
274
344
|
- `demo/src/operations.ts`:ユーザー操作を維持した実行と、ボタンごとの実行中状態の管理。
|
|
275
345
|
|
|
276
|
-
|
|
346
|
+
ネイティブメッセージの受信リスナーは `createWebExt()` のインスタンスごとに1つを共有し、handlerまたはMAIN world中継が存在する間だけ登録します。ストレージの監視とactionクリックの解除関数は、繰り返し呼んでも解除処理を重複実行しません。
|
|
277
347
|
|
|
278
348
|
### 検証範囲
|
|
279
349
|
|
package/dist/index.d.ts
CHANGED
|
@@ -78,12 +78,19 @@ type MessageSchema = Record<string, MessageDefinition>;
|
|
|
78
78
|
*
|
|
79
79
|
* @remarks
|
|
80
80
|
* `tabId` がなければ自拡張への `runtime.sendMessage()`、あれば `tabs.sendMessage()` を使用します。
|
|
81
|
-
*
|
|
81
|
+
* MAIN world宛ては明示登録したコンテンツスクリプト中継を使います。外部拡張への送信は提供しません。
|
|
82
|
+
* 対象フレーム・ドキュメントの存在と各オプションへの
|
|
82
83
|
* 対応はネイティブAPIに従い、未対応の指定を代替しません。
|
|
83
84
|
* @see https://developer.chrome.com/docs/extensions/reference/api/runtime#method-sendMessage
|
|
84
85
|
* @see https://developer.chrome.com/docs/extensions/reference/api/tabs#method-sendMessage
|
|
85
86
|
*/
|
|
86
87
|
interface SendOptions {
|
|
88
|
+
/**
|
|
89
|
+
* 送信先のworld。省略時は従来のruntime/tabs送信、MAIN world側ではbackground宛て。
|
|
90
|
+
* `content-script` はMAIN worldから同じフレームへの送信で使用します。
|
|
91
|
+
* `main-world` はコンテンツスクリプトから同じフレーム、background等からは `tabId` と併用します。
|
|
92
|
+
*/
|
|
93
|
+
target?: 'background' | 'content-script' | 'main-world';
|
|
87
94
|
/**
|
|
88
95
|
* 送信先コンテンツスクリプトを含むタブID。非負の安全な整数が必要です。
|
|
89
96
|
* @remarks ネイティブの `tabs.sendMessage()` が使えるコンテキストと、対象タブの受信ハンドラーが必要です。
|
|
@@ -120,8 +127,10 @@ interface SendOptions {
|
|
|
120
127
|
*
|
|
121
128
|
* @typeParam Schema - メッセージ名ごとの要求型・応答型。実行時スキーマ検証は行いません。
|
|
122
129
|
* @remarks
|
|
123
|
-
*
|
|
124
|
-
*
|
|
130
|
+
* 拡張側は単一の `runtime.onMessage` ルーターを共有し、ハンドラーまたは中継がある間だけ登録します。
|
|
131
|
+
* ネイティブ受信は `sender.id === runtime.id` の内部メッセージに限定します。
|
|
132
|
+
* MAIN worldとのDOM通信はページも読み書きできます。中継の許可チャンネルは明示登録してください。
|
|
133
|
+
* 未登録のチャンネル・メッセージは扱いません。
|
|
125
134
|
* この確認は要求内容や送信元URLの信頼性を保証しません。必要な検証はハンドラーで行ってください。
|
|
126
135
|
* 1つの要求に応答するコンテキストは1つにしてください。複数の受信先が応答する場合の順序は保証しません。
|
|
127
136
|
* @see https://developer.chrome.com/docs/extensions/reference/api/runtime#type-MessageSender
|
|
@@ -143,8 +152,10 @@ interface MessageChannel<Schema extends { [K in keyof Schema]: MessageDefinition
|
|
|
143
152
|
* @throws 破棄済みチャンネル・非互換の応答・ネイティブ通信エラー・シグナル中止理由。
|
|
144
153
|
* @remarks
|
|
145
154
|
* 上記の失敗はすべて戻り値のPromiseの拒否です(同期例外ではありません)。受信先がない場合も成功扱いにしません。
|
|
146
|
-
* `tabId`
|
|
147
|
-
*
|
|
155
|
+
* 通常の `tabId` なしの送信は送信元自身を除く拡張コンテキスト宛てです。
|
|
156
|
+
* 拡張側からコンテンツスクリプトへは `tabId` を指定してください。
|
|
157
|
+
* MAIN world側の `target: 'content-script'` は同じフレームへ送り、タブ指定はできません。
|
|
158
|
+
* `target: 'main-world'` は明示登録した中継を使います。待機の中止は受信処理を停止しません。
|
|
148
159
|
* @see https://developer.chrome.com/docs/extensions/reference/api/runtime#method-sendMessage
|
|
149
160
|
* @see https://developer.chrome.com/docs/extensions/reference/api/tabs#method-sendMessage
|
|
150
161
|
* @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/sendMessage
|
|
@@ -155,9 +166,9 @@ interface MessageChannel<Schema extends { [K in keyof Schema]: MessageDefinition
|
|
|
155
166
|
*
|
|
156
167
|
* @typeParam K - スキーマ内のメッセージ名。
|
|
157
168
|
* @param type - 処理するメッセージ名。同じチャンネル内で重複登録はできません。
|
|
158
|
-
* @param handler -
|
|
169
|
+
* @param handler - 要求値とMessageSenderを受け取り、JSON互換の応答値またはそのPromiseを返す関数。
|
|
159
170
|
* @returns この登録だけを解除する関数。繰り返し呼んでも一度だけ解除し、後続の再登録は解除しません。
|
|
160
|
-
* @throws {UnsupportedOperationError} `runtime.onMessage` が利用できない場合に同期的に送出します。
|
|
171
|
+
* @throws {UnsupportedOperationError} 拡張側で `runtime.onMessage` が利用できない場合に同期的に送出します。
|
|
161
172
|
* @throws 破棄済みチャンネル・同じメッセージ名の重複登録は同期例外です。
|
|
162
173
|
* @remarks
|
|
163
174
|
* 受信イベント内でハンドラーを同期的に呼び、その結果をawaitしてcallbackで応答します。
|
|
@@ -175,7 +186,7 @@ interface MessageChannel<Schema extends { [K in keyof Schema]: MessageDefinition
|
|
|
175
186
|
* @see https://developer.chrome.com/docs/extensions/reference/api/runtime#type-MessageSender
|
|
176
187
|
* @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onMessage
|
|
177
188
|
*/
|
|
178
|
-
handle<K extends keyof Schema & string>(type: K, handler: (request: Schema[K]['request'], sender:
|
|
189
|
+
handle<K extends keyof Schema & string>(type: K, handler: (request: Schema[K]['request'], sender: MessageSender) => Schema[K]['response'] | Promise<Schema[K]['response']>): () => void;
|
|
179
190
|
/**
|
|
180
191
|
* このチャンネルの全ハンドラーを解除し、このオブジェクトを終端的に破棄します。
|
|
181
192
|
*
|
|
@@ -183,12 +194,20 @@ interface MessageChannel<Schema extends { [K in keyof Schema]: MessageDefinition
|
|
|
183
194
|
* @remarks
|
|
184
195
|
* 以後の `send()` はPromiseの拒否、`handle()` は同期例外になります。
|
|
185
196
|
* ファクトリーが未破棄なら `messaging.channel(name)` で同名の新しいチャンネルを作れます。
|
|
186
|
-
* 既に実行中のハンドラーや送信側の待機はキャンセルしません。
|
|
197
|
+
* 既に実行中のハンドラーや送信側の待機はキャンセルしません。DOMの待機はファクトリー/中継の破棄で拒否します。
|
|
187
198
|
*/
|
|
188
199
|
dispose(): void;
|
|
189
200
|
}
|
|
190
201
|
/** 名前付きチャンネルを管理する、WebExtインスタンスごとのメッセージングファクトリー。 */
|
|
191
202
|
interface Messaging {
|
|
203
|
+
/**
|
|
204
|
+
* 同じフレームのMAIN worldとの中継を登録し、解除関数を返します。
|
|
205
|
+
* コンテンツスクリプトで呼び、両worldで同じnamespaceを指定してください。
|
|
206
|
+
* 許可したチャンネルだけを中継します。ページも通信を読み書きできるため、
|
|
207
|
+
* namespaceは認証情報ではありません。入力・送信元はhandlerで検証してください。
|
|
208
|
+
* 1インスタンスにつき1つ登録でき、disposeでも解除します。
|
|
209
|
+
*/
|
|
210
|
+
bridgeMainWorld(options: MainWorldBridgeOptions): () => void;
|
|
192
211
|
/**
|
|
193
212
|
* 名前に対応するチャンネルを取得・作成します。
|
|
194
213
|
*
|
|
@@ -212,10 +231,37 @@ interface Messaging {
|
|
|
212
231
|
* @remarks
|
|
213
232
|
* `webext.dispose()` でも呼ばれます。破棄後は `channel()` を呼べません。
|
|
214
233
|
* 再利用には `createWebExt()` で新しいインスタンスを作成してください。
|
|
215
|
-
*
|
|
234
|
+
* DOMの応答待機は拒否します。ネイティブ送信の待機や実行中の受信処理は継続します。
|
|
216
235
|
*/
|
|
217
236
|
dispose(): void;
|
|
218
237
|
}
|
|
238
|
+
/** ネイティブの送信元情報。MAIN world由来の要求には `world: 'MAIN'` を付けます。 */
|
|
239
|
+
type MessageSender = Browser.Runtime.MessageSender & {
|
|
240
|
+
world?: 'MAIN';
|
|
241
|
+
};
|
|
242
|
+
/** MAIN worldとコンテンツスクリプトで共有するDOM通信の設定。 */
|
|
243
|
+
interface MainWorldOptions {
|
|
244
|
+
/** 拡張ごとに固有の名前。同じフレームで両worldが同じ値を使います。認証には使えません。 */
|
|
245
|
+
namespace: string;
|
|
246
|
+
/** 通信に使う同じフレームのWindow。省略時はglobalThis.window。テスト用に注入できます。 */
|
|
247
|
+
window?: Window;
|
|
248
|
+
}
|
|
249
|
+
/** コンテンツスクリプト側でMAIN worldに公開するチャンネル。 */
|
|
250
|
+
interface MainWorldBridgeOptions extends MainWorldOptions {
|
|
251
|
+
/** 中継を許可するチャンネル名。内部タブ取得など、未指定のチャンネルは公開しません。 */
|
|
252
|
+
channels: readonly string[];
|
|
253
|
+
}
|
|
254
|
+
/** 拡張APIがないMAIN worldで使うチャンネル管理API。 */
|
|
255
|
+
type MainWorldMessaging = Pick<Messaging, 'channel' | 'dispose'>;
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/messaging/index.d.ts
|
|
258
|
+
/**
|
|
259
|
+
* 拡張APIがないMAIN worldで、同じ型付きchannel APIを作成します。
|
|
260
|
+
* 同じフレームのコンテンツスクリプトでbridgeMainWorldを登録してください。
|
|
261
|
+
* sendの既定宛先はbackground、target: 'content-script'で同じフレームへ送れます。
|
|
262
|
+
* ページは通信を読み書きでき、namespaceやsenderは認証に使えません。
|
|
263
|
+
*/
|
|
264
|
+
export declare function createMainWorldMessaging(options: MainWorldOptions): MainWorldMessaging;
|
|
219
265
|
//#endregion
|
|
220
266
|
//#region src/popout.d.ts
|
|
221
267
|
/**
|
|
@@ -855,7 +901,7 @@ type WebExt = Omit<Browser.Browser, 'tabs' | 'action' | 'storage' | 'menus' | 's
|
|
|
855
901
|
*
|
|
856
902
|
* @remarks
|
|
857
903
|
* 繰り返し呼べます。保存値・パネル・ネイティブ設定・直接登録されたリスナーは変更しません。
|
|
858
|
-
*
|
|
904
|
+
* MAIN world中継のDOM応答待機は拒否します。ネイティブ送信の待機や実行中の受信処理は継続します。
|
|
859
905
|
* メッセージングは終端的に破棄されるため、再利用には `createWebExt()` で新しいインスタンスを作成してください。
|
|
860
906
|
*/
|
|
861
907
|
dispose(): void;
|
|
@@ -896,4 +942,4 @@ export declare function createWebExt(options?: CreateWebExtOptions): WebExt;
|
|
|
896
942
|
*/
|
|
897
943
|
export declare const webext: WebExt;
|
|
898
944
|
//#endregion
|
|
899
|
-
export type { CreateWebExtOptions, MessageChannel, MessageDefinition, MessageSchema, Messaging, PopoutOptions, SendOptions, Side, SideCapabilities, SidePathTarget, SideTarget, StorageArea, StorageHelpers, WebExt, WebExtContext, WebExtStorage, WebExtTabs };
|
|
945
|
+
export type { CreateWebExtOptions, MainWorldBridgeOptions, MainWorldMessaging, MainWorldOptions, MessageChannel, MessageDefinition, MessageSchema, MessageSender, Messaging, PopoutOptions, SendOptions, Side, SideCapabilities, SidePathTarget, SideTarget, StorageArea, StorageHelpers, WebExt, WebExtContext, WebExtStorage, WebExtTabs };
|
package/dist/index.js
CHANGED
|
@@ -236,20 +236,37 @@ function normalizeError(error) {
|
|
|
236
236
|
//#endregion
|
|
237
237
|
//#region src/messaging/transport.ts
|
|
238
238
|
const MAX_TIMEOUT_MS = 2147483647;
|
|
239
|
-
async function sendMessage(api, name, type, request, options = {}) {
|
|
239
|
+
async function sendMessage(api, name, type, request, options = {}, transport) {
|
|
240
240
|
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
241
241
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMEOUT_MS) throw new TypeError(`timeoutMs must be positive, finite and at most ${MAX_TIMEOUT_MS}`);
|
|
242
242
|
if (options.signal?.aborted) throw options.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
243
243
|
if (options.tabId !== void 0) assertTabId(options.tabId);
|
|
244
244
|
if (options.tabId === void 0 && (options.frameId !== void 0 || options.documentId !== void 0)) throw new TypeError("frameId/documentId requires tabId");
|
|
245
|
+
if (options.target !== void 0 && ![
|
|
246
|
+
"background",
|
|
247
|
+
"content-script",
|
|
248
|
+
"main-world"
|
|
249
|
+
].includes(options.target)) throw new TypeError("Invalid message target");
|
|
250
|
+
if (options.target === "background" && options.tabId !== void 0) throw new TypeError("background target cannot be combined with tabId");
|
|
245
251
|
const message = {
|
|
246
252
|
__webext_rpc__: 1,
|
|
247
253
|
channel: name,
|
|
248
254
|
type,
|
|
249
255
|
payload: encode(request),
|
|
250
|
-
empty: request === void 0
|
|
256
|
+
empty: request === void 0,
|
|
257
|
+
...options.target === "main-world" ? {
|
|
258
|
+
target: "main-world",
|
|
259
|
+
timeoutMs
|
|
260
|
+
} : {}
|
|
251
261
|
};
|
|
262
|
+
let pending;
|
|
252
263
|
const send = () => {
|
|
264
|
+
if (transport) {
|
|
265
|
+
pending = transport(message);
|
|
266
|
+
return pending.response;
|
|
267
|
+
}
|
|
268
|
+
if (!api) throw new UnsupportedOperationError("messaging.send");
|
|
269
|
+
if (options.target === "content-script" && options.tabId === void 0) throw new TypeError("content-script target requires tabId");
|
|
253
270
|
if (options.tabId === void 0) return api.runtime.sendMessage(message);
|
|
254
271
|
if (!api.tabs?.sendMessage) throw new UnsupportedOperationError("messaging.send to tab");
|
|
255
272
|
const target = {
|
|
@@ -273,21 +290,135 @@ async function sendMessage(api, name, type, request, options = {}) {
|
|
|
273
290
|
if (!response.ok) throw new RemoteError(response.error?.message ?? "Remote handler failed", response.error?.name ?? "Error");
|
|
274
291
|
return response.empty ? void 0 : response.value;
|
|
275
292
|
} finally {
|
|
293
|
+
pending?.dispose();
|
|
276
294
|
if (timer !== void 0) clearTimeout(timer);
|
|
277
295
|
if (onAbort) options.signal?.removeEventListener("abort", onAbort);
|
|
278
296
|
}
|
|
279
297
|
}
|
|
280
298
|
//#endregion
|
|
299
|
+
//#region src/messaging/window.ts
|
|
300
|
+
function errorResponse(error) {
|
|
301
|
+
return {
|
|
302
|
+
__webext_rpc__: 1,
|
|
303
|
+
ok: false,
|
|
304
|
+
error: normalizeError(error)
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/** 同じフレームのDOM通信。namespace・送信元worldはページから偽装でき、認証には使えません。 */
|
|
308
|
+
function createWindowTransport(options, side, receive) {
|
|
309
|
+
if (typeof options.namespace !== "string" || !options.namespace.trim()) throw new TypeError("MAIN world namespace must not be empty");
|
|
310
|
+
const namespace = options.namespace;
|
|
311
|
+
const window = options.window ?? globalThis.window;
|
|
312
|
+
if (!window?.postMessage || !window.addEventListener) throw new UnsupportedOperationError("MAIN world messaging");
|
|
313
|
+
const origin = window.location.origin;
|
|
314
|
+
if (origin === "null") throw new UnsupportedOperationError("MAIN world messaging on opaque origins");
|
|
315
|
+
const peer = side === "main" ? "content" : "main";
|
|
316
|
+
const pending = /* @__PURE__ */ new Map();
|
|
317
|
+
let disposed = false;
|
|
318
|
+
const post = (body) => {
|
|
319
|
+
if (disposed) return;
|
|
320
|
+
window.postMessage({
|
|
321
|
+
...body,
|
|
322
|
+
__webext_window_rpc__: 1,
|
|
323
|
+
namespace,
|
|
324
|
+
from: side
|
|
325
|
+
}, origin);
|
|
326
|
+
};
|
|
327
|
+
const listener = (event) => {
|
|
328
|
+
if (event.source !== window || event.origin !== origin) return;
|
|
329
|
+
const data = event.data;
|
|
330
|
+
if (!data || typeof data !== "object" || data.__webext_window_rpc__ !== 1 || data.namespace !== namespace || data.from !== peer || typeof data.id !== "string") return;
|
|
331
|
+
if (data.kind === "response") {
|
|
332
|
+
const entry = pending.get(data.id);
|
|
333
|
+
if (!entry) return;
|
|
334
|
+
pending.delete(data.id);
|
|
335
|
+
try {
|
|
336
|
+
entry.resolve(encode(data.response));
|
|
337
|
+
} catch (error) {
|
|
338
|
+
entry.reject(error);
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (data.kind !== "request") return;
|
|
343
|
+
const message = data.message;
|
|
344
|
+
if (!message || typeof message !== "object" || message.__webext_rpc__ !== 1 || typeof message.channel !== "string" || typeof message.type !== "string" || typeof message.empty !== "boolean") return;
|
|
345
|
+
const respond = (response) => post({
|
|
346
|
+
kind: "response",
|
|
347
|
+
id: data.id,
|
|
348
|
+
response
|
|
349
|
+
});
|
|
350
|
+
try {
|
|
351
|
+
const snapshot = encode(message);
|
|
352
|
+
if (side === "main" && (!data.sender || typeof data.sender !== "object" || Array.isArray(data.sender))) throw new TypeError("Invalid MAIN world message sender");
|
|
353
|
+
receive(snapshot, side === "content" ? {
|
|
354
|
+
url: window.location.href,
|
|
355
|
+
world: "MAIN"
|
|
356
|
+
} : encode(data.sender), data.target, respond);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
respond(errorResponse(error));
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
window.addEventListener("message", listener);
|
|
362
|
+
return {
|
|
363
|
+
window,
|
|
364
|
+
send(message, sender, target) {
|
|
365
|
+
if (disposed) throw new Error("MAIN world messaging is disposed");
|
|
366
|
+
const id = Array.from(globalThis.crypto.getRandomValues(/* @__PURE__ */ new Uint32Array(4)), (value) => value.toString(16)).join("-");
|
|
367
|
+
return {
|
|
368
|
+
response: new Promise((resolve, reject) => {
|
|
369
|
+
pending.set(id, {
|
|
370
|
+
resolve,
|
|
371
|
+
reject
|
|
372
|
+
});
|
|
373
|
+
try {
|
|
374
|
+
post({
|
|
375
|
+
kind: "request",
|
|
376
|
+
id,
|
|
377
|
+
message,
|
|
378
|
+
sender: encode(sender),
|
|
379
|
+
target
|
|
380
|
+
});
|
|
381
|
+
} catch (error) {
|
|
382
|
+
pending.delete(id);
|
|
383
|
+
reject(error);
|
|
384
|
+
}
|
|
385
|
+
}),
|
|
386
|
+
dispose: () => {
|
|
387
|
+
pending.delete(id);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
dispose() {
|
|
392
|
+
if (disposed) return;
|
|
393
|
+
disposed = true;
|
|
394
|
+
window.removeEventListener("message", listener);
|
|
395
|
+
for (const entry of pending.values()) entry.reject(/* @__PURE__ */ new Error("MAIN world messaging is disposed"));
|
|
396
|
+
pending.clear();
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
//#endregion
|
|
281
401
|
//#region src/messaging/index.ts
|
|
282
|
-
function createMessaging(api) {
|
|
402
|
+
function createMessaging(api, context) {
|
|
403
|
+
return createFactory(api, context);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* 拡張APIがないMAIN worldで、同じ型付きchannel APIを作成します。
|
|
407
|
+
* 同じフレームのコンテンツスクリプトでbridgeMainWorldを登録してください。
|
|
408
|
+
* sendの既定宛先はbackground、target: 'content-script'で同じフレームへ送れます。
|
|
409
|
+
* ページは通信を読み書きでき、namespaceやsenderは認証に使えません。
|
|
410
|
+
*/
|
|
411
|
+
function createMainWorldMessaging(options) {
|
|
412
|
+
return createFactory(void 0, "main-world", options);
|
|
413
|
+
}
|
|
414
|
+
function createFactory(api, context, mainOptions) {
|
|
283
415
|
const channels = /* @__PURE__ */ new Map();
|
|
284
416
|
const routes = /* @__PURE__ */ new Map();
|
|
285
417
|
let listening = false;
|
|
286
418
|
let factoryDisposed = false;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (envelope.__webext_rpc__ !== 1 || typeof envelope.channel !== "string" || typeof envelope.type !== "string" || sender.id !== api.runtime.id) return false;
|
|
419
|
+
let bridge;
|
|
420
|
+
let allowedChannels = /* @__PURE__ */ new Set();
|
|
421
|
+
const route = (envelope, sender, sendResponse) => {
|
|
291
422
|
const handler = routes.get(envelope.channel)?.get(envelope.type);
|
|
292
423
|
if (!handler) return false;
|
|
293
424
|
(async () => {
|
|
@@ -309,15 +440,99 @@ function createMessaging(api) {
|
|
|
309
440
|
})();
|
|
310
441
|
return true;
|
|
311
442
|
};
|
|
443
|
+
const main = mainOptions && createWindowTransport(mainOptions, "main", (message, sender, _target, respond) => {
|
|
444
|
+
if (!route(message, sender, respond)) respond(errorResponse(/* @__PURE__ */ new Error(`No receiver for ${message.channel}:${message.type}`)));
|
|
445
|
+
});
|
|
446
|
+
const listener = (message, sender, sendResponse) => {
|
|
447
|
+
if (!api || !sendResponse || !message || typeof message !== "object") return false;
|
|
448
|
+
const envelope = message;
|
|
449
|
+
if (envelope.__webext_rpc__ !== 1 || typeof envelope.channel !== "string" || typeof envelope.type !== "string" || sender.id !== api.runtime.id) return false;
|
|
450
|
+
if (envelope.target === "main-world") {
|
|
451
|
+
if (!bridge || !allowedChannels.has(envelope.channel)) return false;
|
|
452
|
+
relayToMain(envelope, sender, sendResponse);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
return route(envelope, envelope.__webext_main_world__ === 1 ? {
|
|
456
|
+
...sender,
|
|
457
|
+
world: "MAIN"
|
|
458
|
+
} : sender, sendResponse);
|
|
459
|
+
};
|
|
460
|
+
const syncListener = () => {
|
|
461
|
+
if (!api?.runtime.onMessage) return;
|
|
462
|
+
const needed = routes.size > 0 || !!bridge;
|
|
463
|
+
if (needed === listening) return;
|
|
464
|
+
if (needed) api.runtime.onMessage.addListener(listener);
|
|
465
|
+
else api.runtime.onMessage.removeListener(listener);
|
|
466
|
+
listening = needed;
|
|
467
|
+
};
|
|
468
|
+
const relayToMain = (envelope, sender, respond) => {
|
|
469
|
+
const endpoint = bridge;
|
|
470
|
+
(async () => {
|
|
471
|
+
try {
|
|
472
|
+
const value = await sendMessage(void 0, envelope.channel, envelope.type, envelope.empty === true ? void 0 : envelope.payload, { timeoutMs: envelope.timeoutMs }, (message) => endpoint.send(message, sender));
|
|
473
|
+
respond({
|
|
474
|
+
__webext_rpc__: 1,
|
|
475
|
+
ok: true,
|
|
476
|
+
value: encode(value),
|
|
477
|
+
empty: value === void 0
|
|
478
|
+
});
|
|
479
|
+
} catch (error) {
|
|
480
|
+
const response = errorResponse(error);
|
|
481
|
+
if (error instanceof RemoteError) response.error.name = error.remoteName;
|
|
482
|
+
respond(response);
|
|
483
|
+
}
|
|
484
|
+
})();
|
|
485
|
+
};
|
|
312
486
|
const removeRoute = (name, handlers) => {
|
|
313
487
|
if (routes.get(name) !== handlers) return;
|
|
314
488
|
routes.delete(name);
|
|
315
|
-
|
|
316
|
-
api.runtime.onMessage.removeListener(listener);
|
|
317
|
-
listening = false;
|
|
318
|
-
}
|
|
489
|
+
syncListener();
|
|
319
490
|
};
|
|
320
491
|
return {
|
|
492
|
+
bridgeMainWorld(options) {
|
|
493
|
+
if (factoryDisposed) throw new Error("Messaging is disposed");
|
|
494
|
+
if (!api || context !== "content-script") throw new UnsupportedOperationError("messaging.bridgeMainWorld outside content-script");
|
|
495
|
+
if (bridge) throw new Error("A MAIN world bridge is already registered");
|
|
496
|
+
if (!Array.isArray(options.channels) || options.channels.some((name) => typeof name !== "string" || !name.trim())) throw new TypeError("MAIN world bridge channels must be non-empty names");
|
|
497
|
+
if (!api.runtime.onMessage) throw new UnsupportedOperationError("messaging.bridgeMainWorld");
|
|
498
|
+
allowedChannels = new Set(options.channels);
|
|
499
|
+
const endpoint = createWindowTransport(options, "content", (message, sender, target, respond) => {
|
|
500
|
+
if (!allowedChannels.has(message.channel)) {
|
|
501
|
+
respond(errorResponse(/* @__PURE__ */ new Error("MAIN world channel is not allowed")));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (target === "content-script") {
|
|
505
|
+
if (!route(message, sender, respond)) respond(errorResponse(/* @__PURE__ */ new Error(`No receiver for ${message.channel}:${message.type}`)));
|
|
506
|
+
} else if (target === "background") (async () => {
|
|
507
|
+
try {
|
|
508
|
+
respond(await api.runtime.sendMessage({
|
|
509
|
+
__webext_rpc__: 1,
|
|
510
|
+
__webext_main_world__: 1,
|
|
511
|
+
channel: message.channel,
|
|
512
|
+
type: message.type,
|
|
513
|
+
payload: message.payload,
|
|
514
|
+
empty: message.empty
|
|
515
|
+
}));
|
|
516
|
+
} catch (error) {
|
|
517
|
+
respond(errorResponse(error));
|
|
518
|
+
}
|
|
519
|
+
})();
|
|
520
|
+
else respond(errorResponse(/* @__PURE__ */ new TypeError("Invalid MAIN world message target")));
|
|
521
|
+
});
|
|
522
|
+
bridge = endpoint;
|
|
523
|
+
syncListener();
|
|
524
|
+
let stopped = false;
|
|
525
|
+
return () => {
|
|
526
|
+
if (stopped) return;
|
|
527
|
+
stopped = true;
|
|
528
|
+
endpoint.dispose();
|
|
529
|
+
if (bridge === endpoint) {
|
|
530
|
+
bridge = void 0;
|
|
531
|
+
allowedChannels.clear();
|
|
532
|
+
syncListener();
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
},
|
|
321
536
|
channel(name) {
|
|
322
537
|
if (factoryDisposed) throw new Error("Messaging is disposed");
|
|
323
538
|
if (!name.trim()) throw new TypeError("Message channel name must not be empty");
|
|
@@ -328,18 +543,25 @@ function createMessaging(api) {
|
|
|
328
543
|
const channel = {
|
|
329
544
|
send(type, request, options = {}) {
|
|
330
545
|
if (disposed) return Promise.reject(/* @__PURE__ */ new Error("Message channel is disposed"));
|
|
331
|
-
return sendMessage(api, name, type, request, options)
|
|
546
|
+
return sendMessage(api, name, type, request, options, main ? (message) => {
|
|
547
|
+
if (options.tabId !== void 0) throw new TypeError("MAIN world cannot select another tab");
|
|
548
|
+
if (options.target === "main-world") throw new TypeError("MAIN world cannot send to itself");
|
|
549
|
+
return main.send(message, {}, options.target ?? "background");
|
|
550
|
+
} : options.target === "main-world" && options.tabId === void 0 ? (message) => {
|
|
551
|
+
if (!bridge || !allowedChannels.has(name)) throw new UnsupportedOperationError("messaging.send to MAIN world");
|
|
552
|
+
return bridge.send(message, {
|
|
553
|
+
id: api?.runtime.id,
|
|
554
|
+
url: bridge.window.location.href
|
|
555
|
+
});
|
|
556
|
+
} : void 0);
|
|
332
557
|
},
|
|
333
558
|
handle(type, handler) {
|
|
334
559
|
if (disposed) throw new Error("Message channel is disposed");
|
|
335
560
|
if (handlers.has(type)) throw new Error(`A handler for ${name}:${type} is already registered`);
|
|
336
|
-
if (!api
|
|
561
|
+
if (!main && !api?.runtime.onMessage) throw new UnsupportedOperationError("messaging.handle");
|
|
337
562
|
handlers.set(type, handler);
|
|
338
563
|
routes.set(name, handlers);
|
|
339
|
-
|
|
340
|
-
api.runtime.onMessage.addListener(listener);
|
|
341
|
-
listening = true;
|
|
342
|
-
}
|
|
564
|
+
syncListener();
|
|
343
565
|
let stopped = false;
|
|
344
566
|
return () => {
|
|
345
567
|
if (stopped) return;
|
|
@@ -362,7 +584,12 @@ function createMessaging(api) {
|
|
|
362
584
|
dispose() {
|
|
363
585
|
if (factoryDisposed) return;
|
|
364
586
|
factoryDisposed = true;
|
|
587
|
+
main?.dispose();
|
|
588
|
+
bridge?.dispose();
|
|
589
|
+
bridge = void 0;
|
|
590
|
+
allowedChannels.clear();
|
|
365
591
|
for (const channel of channels.values()) channel.dispose();
|
|
592
|
+
syncListener();
|
|
366
593
|
}
|
|
367
594
|
};
|
|
368
595
|
}
|
|
@@ -681,7 +908,7 @@ function createWebExt(options = {}) {
|
|
|
681
908
|
if (!api?.runtime?.getManifest) throw new Error("webext requires a WebExtension context or an injected API");
|
|
682
909
|
const extensionUrl = new URL(api.runtime.getURL("/"));
|
|
683
910
|
const context = createContext(api, extensionUrl, options);
|
|
684
|
-
const messaging = createMessaging(api);
|
|
911
|
+
const messaging = createMessaging(api, context.type ?? void 0);
|
|
685
912
|
const { tabs, initialize } = createTabs(api, context, messaging);
|
|
686
913
|
const { resolveTabId, openPopout } = createPopout(api, extensionUrl, () => tabs.getTargetId());
|
|
687
914
|
const action = api.action && facade(api.action, { async openPopout(options = {}) {
|
|
@@ -735,4 +962,4 @@ function createWebExt(options = {}) {
|
|
|
735
962
|
*/
|
|
736
963
|
const webext = lazyFacade(() => createWebExt());
|
|
737
964
|
//#endregion
|
|
738
|
-
export { MessageTimeoutError, RemoteError, UnsupportedOperationError, createWebExt, webext };
|
|
965
|
+
export { MessageTimeoutError, RemoteError, UnsupportedOperationError, createMainWorldMessaging, createWebExt, webext };
|