@midra/webext 0.0.2 → 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 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
- メッセージの受信リスナーは `createWebExt()` のインスタンスごとに1つを共有し、handlerが存在する間だけ登録します。ストレージの監視とactionクリックの解除関数は、繰り返し呼んでも解除処理を重複実行しません。
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
- * 外部拡張・Webページ宛ての送信は提供しません。対象フレーム・ドキュメントの存在と各オプションへの
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
- * ファクトリーごとに単一の `runtime.onMessage` ルーターを共有し、ハンドラーがある間だけ登録します。
124
- * 受信は `sender.id === runtime.id` の内部メッセージに限定し、未登録のチャンネル・メッセージは扱いません。
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
- * コンテンツスクリプトへは `tabId` を指定してください。待機の中止は受信処理を停止しません。
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 - 要求値とネイティブのMessageSenderを受け取り、JSON互換の応答値またはそのPromiseを返す関数。
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: Browser.Runtime.MessageSender) => Schema[K]['response'] | Promise<Schema[K]['response']>): () => void;
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
- const listener = (message, sender, sendResponse) => {
288
- if (!sendResponse || !message || typeof message !== "object") return false;
289
- const envelope = message;
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
- if (!routes.size && listening) {
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.runtime.onMessage) throw new UnsupportedOperationError("messaging.handle");
561
+ if (!main && !api?.runtime.onMessage) throw new UnsupportedOperationError("messaging.handle");
337
562
  handlers.set(type, handler);
338
563
  routes.set(name, handlers);
339
- if (!listening) {
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@midra/webext",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "description": "ブラウザ拡張機能の開発用ライブラリ",
5
5
  "author": "Midra <me@midra.me> (https://github.com/Midra429)",
6
6
  "license": "MIT",