@rodrigocoliveira/mobile-app-bridge 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.
@@ -0,0 +1,112 @@
1
+ import {
2
+ BRIDGE_EVENT,
3
+ DEFAULT_TIMEOUT,
4
+ ErrorCode,
5
+ isBridgeMessage
6
+ } from "../chunk-SEQSUJLW.js";
7
+
8
+ // src/web/errors.ts
9
+ var BridgeError = class extends Error {
10
+ code;
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.name = "BridgeError";
14
+ this.code = code;
15
+ }
16
+ };
17
+
18
+ // src/web/client.ts
19
+ function createBridgeClient(getWindow) {
20
+ const pending = /* @__PURE__ */ new Map();
21
+ const listeners = /* @__PURE__ */ new Map();
22
+ let attachedTo;
23
+ let counter = 0;
24
+ function appWindow() {
25
+ const win = getWindow();
26
+ return win?.ReactNativeWebView ? win : void 0;
27
+ }
28
+ function handle(detail) {
29
+ if (!isBridgeMessage(detail)) return;
30
+ if (detail.kind === "evt") {
31
+ const subscribers = listeners.get(detail.name);
32
+ if (subscribers) for (const listener of [...subscribers]) listener(detail.data);
33
+ return;
34
+ }
35
+ if (detail.kind !== "res") return;
36
+ const entry = pending.get(detail.id);
37
+ if (!entry) return;
38
+ pending.delete(detail.id);
39
+ if (entry.timer !== void 0) clearTimeout(entry.timer);
40
+ if (detail.ok) entry.resolve(detail.result);
41
+ else entry.reject(new BridgeError(detail.error.code, detail.error.message));
42
+ }
43
+ function attach(win) {
44
+ if (attachedTo === win) return;
45
+ attachedTo = win;
46
+ win.addEventListener(BRIDGE_EVENT, (event) => handle(event.detail));
47
+ }
48
+ function nextId() {
49
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${(counter++).toString(36)}`;
50
+ }
51
+ return {
52
+ get isApp() {
53
+ return appWindow() !== void 0;
54
+ },
55
+ get info() {
56
+ return appWindow()?.__MOBILE_APP_BRIDGE__ ?? null;
57
+ },
58
+ call(method, params, callOptions = {}) {
59
+ const win = appWindow();
60
+ if (!win?.ReactNativeWebView) {
61
+ return Promise.reject(new BridgeError(ErrorCode.NOT_IN_APP, `Cannot call "${method}" outside the mobile app`));
62
+ }
63
+ attach(win);
64
+ const id = nextId();
65
+ const request = { bridge: 1, kind: "req", id, method };
66
+ if (params !== void 0) request.params = params;
67
+ let payload;
68
+ try {
69
+ payload = JSON.stringify(request);
70
+ } catch {
71
+ return Promise.reject(new BridgeError(ErrorCode.INVALID_PARAMS, `Params for "${method}" are not JSON-serializable`));
72
+ }
73
+ const timeout = callOptions.timeout ?? DEFAULT_TIMEOUT;
74
+ const postMessage = win.ReactNativeWebView.postMessage.bind(win.ReactNativeWebView);
75
+ return new Promise((resolve, reject) => {
76
+ const timer = timeout > 0 ? setTimeout(() => {
77
+ if (pending.delete(id)) {
78
+ reject(new BridgeError(ErrorCode.TIMEOUT, `"${method}" did not respond within ${timeout}ms`));
79
+ }
80
+ }, timeout) : void 0;
81
+ pending.set(id, { resolve, reject, timer });
82
+ postMessage(payload);
83
+ });
84
+ },
85
+ on(name, listener) {
86
+ const win = appWindow();
87
+ if (win) attach(win);
88
+ let subscribers = listeners.get(name);
89
+ if (!subscribers) {
90
+ subscribers = /* @__PURE__ */ new Set();
91
+ listeners.set(name, subscribers);
92
+ }
93
+ const typed = listener;
94
+ subscribers.add(typed);
95
+ return () => {
96
+ subscribers.delete(typed);
97
+ };
98
+ }
99
+ };
100
+ }
101
+
102
+ // src/web/index.ts
103
+ var bridge = createBridgeClient(
104
+ () => typeof window === "undefined" ? void 0 : window
105
+ );
106
+ export {
107
+ BridgeError,
108
+ ErrorCode,
109
+ bridge,
110
+ createBridgeClient
111
+ };
112
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/web/errors.ts","../../src/web/client.ts","../../src/web/index.ts"],"sourcesContent":["export class BridgeError extends Error {\n readonly code: string\n\n constructor(code: string, message: string) {\n super(message)\n this.name = 'BridgeError'\n this.code = code\n }\n}\n","import {\n BRIDGE_EVENT,\n DEFAULT_TIMEOUT,\n ErrorCode,\n isBridgeMessage,\n type AppInfo,\n type RequestMessage,\n} from '../shared/protocol'\nimport { BridgeError } from './errors'\n\nexport interface BridgeWindow {\n __MOBILE_APP_BRIDGE__?: AppInfo\n ReactNativeWebView?: { postMessage(message: string): void }\n addEventListener(type: string, listener: (event: Event) => void): void\n}\n\nexport interface CallOptions {\n /** Milliseconds to wait for the native response. Defaults to 30s; 0 disables the timeout. */\n timeout?: number\n}\n\nexport interface BridgeClient {\n readonly isApp: boolean\n readonly info: AppInfo | null\n call<T = unknown>(method: string, params?: unknown, options?: CallOptions): Promise<T>\n on<T = unknown>(name: string, listener: (data: T) => void): () => void\n}\n\ninterface PendingCall {\n resolve: (value: unknown) => void\n reject: (error: BridgeError) => void\n timer: ReturnType<typeof setTimeout> | undefined\n}\n\ntype Listener = (data: unknown) => void\n\nexport function createBridgeClient(\n getWindow: () => BridgeWindow | undefined,\n): BridgeClient {\n const pending = new Map<string, PendingCall>()\n const listeners = new Map<string, Set<Listener>>()\n let attachedTo: BridgeWindow | undefined\n let counter = 0\n\n /** Inside the app whenever react-native-webview's bridge object exists (same rule as the Agendart bridge). */\n function appWindow(): BridgeWindow | undefined {\n const win = getWindow()\n return win?.ReactNativeWebView ? win : undefined\n }\n\n function handle(detail: unknown) {\n if (!isBridgeMessage(detail)) return\n if (detail.kind === 'evt') {\n // Events are fire-and-forget: without a subscriber they are dropped. Anything that must not be\n // lost (e.g. \"app opened from a notification\") is exposed as a method the page calls when ready.\n const subscribers = listeners.get(detail.name)\n if (subscribers) for (const listener of [...subscribers]) listener(detail.data)\n return\n }\n if (detail.kind !== 'res') return\n\n const entry = pending.get(detail.id)\n if (!entry) return\n pending.delete(detail.id)\n if (entry.timer !== undefined) clearTimeout(entry.timer)\n if (detail.ok) entry.resolve(detail.result)\n else entry.reject(new BridgeError(detail.error.code, detail.error.message))\n }\n\n function attach(win: BridgeWindow) {\n if (attachedTo === win) return\n attachedTo = win\n win.addEventListener(BRIDGE_EVENT, (event) => handle((event as CustomEvent).detail))\n }\n\n function nextId(): string {\n return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${(counter++).toString(36)}`\n }\n\n return {\n get isApp() {\n return appWindow() !== undefined\n },\n\n get info() {\n return appWindow()?.__MOBILE_APP_BRIDGE__ ?? null\n },\n\n call<T = unknown>(method: string, params?: unknown, callOptions: CallOptions = {}): Promise<T> {\n const win = appWindow()\n if (!win?.ReactNativeWebView) {\n return Promise.reject(new BridgeError(ErrorCode.NOT_IN_APP, `Cannot call \"${method}\" outside the mobile app`))\n }\n attach(win)\n\n const id = nextId()\n const request: RequestMessage = { bridge: 1, kind: 'req', id, method }\n if (params !== undefined) request.params = params\n\n let payload: string\n try {\n payload = JSON.stringify(request)\n } catch {\n return Promise.reject(new BridgeError(ErrorCode.INVALID_PARAMS, `Params for \"${method}\" are not JSON-serializable`))\n }\n\n const timeout = callOptions.timeout ?? DEFAULT_TIMEOUT\n const postMessage = win.ReactNativeWebView.postMessage.bind(win.ReactNativeWebView)\n\n return new Promise<T>((resolve, reject) => {\n const timer =\n timeout > 0\n ? setTimeout(() => {\n if (pending.delete(id)) {\n reject(new BridgeError(ErrorCode.TIMEOUT, `\"${method}\" did not respond within ${timeout}ms`))\n }\n }, timeout)\n : undefined\n pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer })\n postMessage(payload)\n })\n },\n\n on<T = unknown>(name: string, listener: (data: T) => void): () => void {\n const win = appWindow()\n if (win) attach(win)\n\n let subscribers = listeners.get(name)\n if (!subscribers) {\n subscribers = new Set()\n listeners.set(name, subscribers)\n }\n const typed = listener as Listener\n subscribers.add(typed)\n\n return () => {\n subscribers.delete(typed)\n }\n },\n }\n}\n","import { createBridgeClient, type BridgeWindow } from './client'\n\n/** Shared client for the current page. Safe to import during SSR: it only reads `window` when used. */\nexport const bridge = createBridgeClient(() =>\n typeof window === 'undefined' ? undefined : (window as unknown as BridgeWindow),\n)\n\nexport { createBridgeClient } from './client'\nexport type { BridgeClient, BridgeWindow, CallOptions } from './client'\nexport { BridgeError } from './errors'\nexport { ErrorCode } from '../shared/protocol'\nexport type { AppInfo } from '../shared/protocol'\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@rodrigocoliveira/mobile-app-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Promise-based bridge between a web app and the Expo WebView shell that loads it",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/rodrigocoliveira/mobile-app-bridge.git"
9
+ },
10
+ "homepage": "https://github.com/rodrigocoliveira/mobile-app-bridge#readme",
11
+ "bugs": "https://github.com/rodrigocoliveira/mobile-app-bridge/issues",
12
+ "keywords": [
13
+ "expo",
14
+ "react-native",
15
+ "webview",
16
+ "bridge",
17
+ "postmessage"
18
+ ],
19
+ "type": "module",
20
+ "sideEffects": false,
21
+ "exports": {
22
+ "./web": {
23
+ "import": {
24
+ "types": "./dist/web/index.d.ts",
25
+ "default": "./dist/web/index.js"
26
+ },
27
+ "require": {
28
+ "types": "./dist/web/index.d.cts",
29
+ "default": "./dist/web/index.cjs"
30
+ }
31
+ },
32
+ "./native": {
33
+ "import": {
34
+ "types": "./dist/native/index.d.ts",
35
+ "default": "./dist/native/index.js"
36
+ },
37
+ "require": {
38
+ "types": "./dist/native/index.d.cts",
39
+ "default": "./dist/native/index.cjs"
40
+ }
41
+ },
42
+ "./package.json": "./package.json"
43
+ },
44
+ "files": [
45
+ "dist",
46
+ "README.md",
47
+ "LICENSE"
48
+ ],
49
+ "scripts": {
50
+ "build": "tsup",
51
+ "typecheck": "tsc -p tsconfig.json",
52
+ "test": "bun test",
53
+ "check:dist": "bun run scripts/check-dist.ts",
54
+ "changeset": "changeset",
55
+ "version": "changeset version && bun install --lockfile-only",
56
+ "release": "bun run build && bun run check:dist && changeset publish",
57
+ "example:web": "bun run build && bun run example/web/serve.ts"
58
+ },
59
+ "peerDependencies": {
60
+ "expo-constants": ">=17",
61
+ "react": ">=18",
62
+ "react-native": ">=0.76",
63
+ "react-native-webview": ">=13.10"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "expo-constants": {
67
+ "optional": true
68
+ },
69
+ "react": {
70
+ "optional": true
71
+ },
72
+ "react-native": {
73
+ "optional": true
74
+ },
75
+ "react-native-webview": {
76
+ "optional": true
77
+ }
78
+ },
79
+ "publishConfig": {
80
+ "access": "public"
81
+ },
82
+ "devDependencies": {
83
+ "@changesets/cli": "^3.0.3",
84
+ "@types/bun": "^1.4.1",
85
+ "@types/react": "~19.2.0",
86
+ "expo-constants": "~57.0.20",
87
+ "react": "19.2.3",
88
+ "react-native": "0.86.3",
89
+ "react-native-webview": "13.16.1",
90
+ "tsup": "^8.5.1",
91
+ "typescript": "~5.9.3"
92
+ }
93
+ }