@lingxia/bridge 0.14.0 → 0.16.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,215 @@
1
+ var __assign = (this && this.__assign) || function () {
2
+ __assign = Object.assign || function(t) {
3
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
4
+ s = arguments[i];
5
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
6
+ t[p] = s[p];
7
+ }
8
+ return t;
9
+ };
10
+ return __assign.apply(this, arguments);
11
+ };
12
+ var __rest = (this && this.__rest) || function (s, e) {
13
+ var t = {};
14
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
15
+ t[p] = s[p];
16
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
17
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
18
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
19
+ t[p[i]] = s[p[i]];
20
+ }
21
+ return t;
22
+ };
23
+ /** Document-side framing for host-attested RequiredV3 documents. */
24
+ export var V3_PROTOCOL = 3;
25
+ export var DEFAULT_MAX_V3_FRAME_BYTES = 64 * 1024;
26
+ var documentToNativeKinds = new Set([
27
+ "hello",
28
+ "req",
29
+ "res",
30
+ "notify",
31
+ "cancel",
32
+ "ch.open",
33
+ "ch.data",
34
+ "ch.close",
35
+ "state.ack",
36
+ "console",
37
+ ]);
38
+ var nativeToDocumentKinds = new Set([
39
+ "helloAck",
40
+ "ready",
41
+ "req",
42
+ "res",
43
+ "event",
44
+ "state.snapshot",
45
+ "state.patch",
46
+ "ch.ack",
47
+ "ch.data",
48
+ "ch.close",
49
+ ]);
50
+ function isRecord(value) {
51
+ return value !== null && typeof value === "object" && !Array.isArray(value);
52
+ }
53
+ function hasDuplicateTopLevelSecurityKey(frame) {
54
+ var _a;
55
+ var securityKeys = new Set(["v", "kind", "sessionId", "secret"]);
56
+ var seen = new Set();
57
+ var depth = 0;
58
+ for (var index = 0; index < frame.length; index++) {
59
+ var char = frame[index];
60
+ if (char === "{" || char === "[") {
61
+ depth++;
62
+ continue;
63
+ }
64
+ if (char === "}" || char === "]") {
65
+ depth--;
66
+ continue;
67
+ }
68
+ if (char !== '"')
69
+ continue;
70
+ var start = index;
71
+ index++;
72
+ while (index < frame.length) {
73
+ if (frame[index] === "\\") {
74
+ index += 2;
75
+ continue;
76
+ }
77
+ if (frame[index] === '"')
78
+ break;
79
+ index++;
80
+ }
81
+ if (index >= frame.length)
82
+ return false;
83
+ if (depth !== 1)
84
+ continue;
85
+ var next = index + 1;
86
+ while (/\s/.test((_a = frame[next]) !== null && _a !== void 0 ? _a : ""))
87
+ next++;
88
+ if (frame[next] !== ":")
89
+ continue;
90
+ var key = void 0;
91
+ try {
92
+ key = JSON.parse(frame.slice(start, index + 1));
93
+ }
94
+ catch (_b) {
95
+ return false;
96
+ }
97
+ if (typeof key === "string" && securityKeys.has(key)) {
98
+ if (seen.has(key))
99
+ return true;
100
+ seen.add(key);
101
+ }
102
+ }
103
+ return false;
104
+ }
105
+ /**
106
+ * Captures the document secret in a closure. Neither the returned codec nor
107
+ * parsed native messages expose it.
108
+ */
109
+ export function createV3DocumentCodec(binding) {
110
+ if (!isRecord(binding) ||
111
+ typeof binding.sessionId !== "string" ||
112
+ binding.sessionId.length === 0 ||
113
+ typeof binding.secret !== "string" ||
114
+ binding.secret.length === 0) {
115
+ return { ok: false, error: "INVALID_DOCUMENT_BINDING" };
116
+ }
117
+ var sessionId = binding.sessionId, secret = binding.secret;
118
+ return {
119
+ ok: true,
120
+ value: {
121
+ encode: function (kind, payload) {
122
+ if (!documentToNativeKinds.has(kind) || !isRecord(payload)) {
123
+ return { ok: false, error: "INVALID_DOCUMENT_PAYLOAD" };
124
+ }
125
+ if (["v", "kind", "sessionId", "secret"].some(function (field) { return field in payload; })) {
126
+ return { ok: false, error: "SECURITY_FIELD_IN_PAYLOAD" };
127
+ }
128
+ return {
129
+ ok: true,
130
+ value: __assign(__assign({}, payload), { v: V3_PROTOCOL, kind: kind, sessionId: sessionId, secret: secret }),
131
+ };
132
+ },
133
+ parse: function (frame, maxFrameBytes) {
134
+ if (maxFrameBytes === void 0) { maxFrameBytes = DEFAULT_MAX_V3_FRAME_BYTES; }
135
+ // Envelope checks deliberately precede typed payload handling.
136
+ if (new TextEncoder().encode(frame).byteLength > maxFrameBytes) {
137
+ return { ok: false, error: "FRAME_TOO_LARGE" };
138
+ }
139
+ if (hasDuplicateTopLevelSecurityKey(frame)) {
140
+ return { ok: false, error: "MALFORMED_ENVELOPE" };
141
+ }
142
+ var candidate;
143
+ try {
144
+ candidate = JSON.parse(frame);
145
+ }
146
+ catch (_a) {
147
+ return { ok: false, error: "MALFORMED_ENVELOPE" };
148
+ }
149
+ if (!isRecord(candidate))
150
+ return { ok: false, error: "MALFORMED_ENVELOPE" };
151
+ if (candidate.v !== V3_PROTOCOL) {
152
+ return { ok: false, error: "UNSUPPORTED_VERSION" };
153
+ }
154
+ if (typeof candidate.kind !== "string" ||
155
+ !nativeToDocumentKinds.has(candidate.kind)) {
156
+ return { ok: false, error: "UNSUPPORTED_NATIVE_KIND" };
157
+ }
158
+ if (typeof candidate.sessionId !== "string" || candidate.sessionId.length === 0) {
159
+ return { ok: false, error: "MALFORMED_ENVELOPE" };
160
+ }
161
+ if (candidate.sessionId !== sessionId)
162
+ return { ok: false, error: "SESSION_MISMATCH" };
163
+ if (Object.prototype.hasOwnProperty.call(candidate, "secret")) {
164
+ return { ok: false, error: "UNEXPECTED_SECRET" };
165
+ }
166
+ var _v = candidate.v, _kind = candidate.kind, _sessionId = candidate.sessionId, payload = __rest(candidate, ["v", "kind", "sessionId"]);
167
+ return {
168
+ ok: true,
169
+ value: { kind: candidate.kind, payload: payload },
170
+ };
171
+ },
172
+ },
173
+ };
174
+ }
175
+ /**
176
+ * Atomically takes the host-installed one-shot handoff. The secret remains in
177
+ * the returned codec closure and is never copied into bridge globals/config.
178
+ */
179
+ export function consumeV3Bootstrap() {
180
+ if (typeof window === "undefined")
181
+ return { kind: "absent" };
182
+ var take = window.__LingXiaTakeControlBootstrap;
183
+ if (typeof take !== "function")
184
+ return { kind: "absent" };
185
+ try {
186
+ var bootstrap = take();
187
+ if (!bootstrap ||
188
+ bootstrap.requiredProtocol !== V3_PROTOCOL ||
189
+ typeof bootstrap.publicSessionId !== "string" ||
190
+ bootstrap.publicSessionId.length === 0 ||
191
+ typeof bootstrap.secret !== "string" ||
192
+ bootstrap.secret.length === 0) {
193
+ return { kind: "blocked" };
194
+ }
195
+ var codec = createV3DocumentCodec({
196
+ sessionId: bootstrap.publicSessionId,
197
+ secret: bootstrap.secret,
198
+ });
199
+ return codec.ok
200
+ ? { kind: "required", codec: codec.value }
201
+ : { kind: "blocked" };
202
+ }
203
+ catch (_a) {
204
+ return { kind: "blocked" };
205
+ }
206
+ finally {
207
+ // A malformed replacement must not leave a callable handoff for later.
208
+ try {
209
+ delete window.__LingXiaTakeControlBootstrap;
210
+ }
211
+ catch (_b) {
212
+ // A hostile non-configurable property has no attested secret to retain.
213
+ }
214
+ }
215
+ }
@@ -83,7 +83,7 @@ function applyDisplayLanguage(next) {
83
83
  try {
84
84
  for (var _b = __values(__spreadArray([], __read(current.listeners), false)), _c = _b.next(); !_c.done; _c = _b.next()) {
85
85
  var listener = _c.value;
86
- listener();
86
+ listener(normalized);
87
87
  }
88
88
  }
89
89
  catch (e_1_1) { e_1 = { error: e_1_1 }; }
@@ -104,7 +104,14 @@ if (typeof window !== 'undefined' && !window.__lingxiaApplyDisplayLanguage) {
104
104
  export function getDisplayLanguage() {
105
105
  return store().value;
106
106
  }
107
- /** Subscribe to host display-language changes. Returns an unsubscribe. */
107
+ /**
108
+ * Subscribe to host display-language changes. Returns an unsubscribe.
109
+ *
110
+ * Change-only, so that this composes with `useSyncExternalStore`: the listener
111
+ * runs when the language changes, never on subscribe. Read the current value
112
+ * with `getDisplayLanguage()`. Logic's `lx.app.displayLanguage.watch` differs
113
+ * deliberately — it has no render loop to feed, so it delivers immediately.
114
+ */
108
115
  export function subscribeDisplayLanguage(listener) {
109
116
  var listeners = store().listeners;
110
117
  listeners.add(listener);
@@ -115,6 +122,23 @@ export function subscribeDisplayLanguage(listener) {
115
122
  export function getPlatformOS() {
116
123
  return BRIDGE_CONFIG.os || 'unknown';
117
124
  }
125
+ /**
126
+ * Which kind of machine this is, mobile or desktop. Read through `isMobile()`
127
+ * and `isDesktop()`; the class itself is host vocabulary, not lxapp API.
128
+ *
129
+ * Fixed for the life of the document. A shipped host is one machine; the
130
+ * Runner re-serves the page when its simulated device changes class, so this
131
+ * never has to change under a page that is already rendering.
132
+ *
133
+ * Hosts from before this config key shipped send nothing, so fall back to the
134
+ * OS: every one of them is the machine it names.
135
+ */
136
+ function hostClass() {
137
+ if (BRIDGE_CONFIG.hostClass === 'mobile' || BRIDGE_CONFIG.hostClass === 'desktop') {
138
+ return BRIDGE_CONFIG.hostClass;
139
+ }
140
+ return BRIDGE_CONFIG.os === 'macOS' || BRIDGE_CONFIG.os === 'Windows' ? 'desktop' : 'mobile';
141
+ }
118
142
  export function isHarmony() {
119
143
  return BRIDGE_CONFIG.os === 'Harmony';
120
144
  }
@@ -130,8 +154,13 @@ export function isMacOS() {
130
154
  export function isWindows() {
131
155
  return BRIDGE_CONFIG.os === 'Windows';
132
156
  }
157
+ // Form factor, not OS: the Runner shows a real macOS/Windows build inside a
158
+ // phone frame, and a page that keyed off the OS would keep its desktop layout.
133
159
  export function isDesktop() {
134
- return isMacOS() || isWindows();
160
+ return hostClass() === 'desktop';
161
+ }
162
+ export function isMobile() {
163
+ return hostClass() === 'mobile';
135
164
  }
136
165
  // iOS and macOS share the WKWebView transport, so features scoped to it (e.g.
137
166
  // the streaming downstream) key off this rather than the two OS checks.
package/dist/es5/types.js CHANGED
@@ -13,4 +13,5 @@ export var BRIDGE_ERROR = {
13
13
  OUTBOX_FULL: 'BRIDGE_OUTBOX_FULL',
14
14
  STREAM_OVERFLOW: 'BRIDGE_STREAM_OVERFLOW',
15
15
  STREAM_CLOSED: 'BRIDGE_STREAM_CLOSED',
16
+ MESSAGE_TOO_LARGE: 'BRIDGE_MESSAGE_TOO_LARGE',
16
17
  };
@@ -0,0 +1,43 @@
1
+ /** Document-side framing for host-attested RequiredV3 documents. */
2
+ export declare const V3_PROTOCOL: 3;
3
+ export declare const DEFAULT_MAX_V3_FRAME_BYTES: number;
4
+ export type V3DocumentToNativeKind = "hello" | "req" | "res" | "notify" | "cancel" | "ch.open" | "ch.data" | "ch.close" | "state.ack" | "console";
5
+ export type V3NativeToDocumentKind = "helloAck" | "ready" | "req" | "res" | "event" | "state.snapshot" | "state.patch" | "ch.ack" | "ch.data" | "ch.close";
6
+ export type V3CodecError = "FRAME_TOO_LARGE" | "MALFORMED_ENVELOPE" | "UNSUPPORTED_VERSION" | "UNSUPPORTED_NATIVE_KIND" | "INVALID_DOCUMENT_BINDING" | "INVALID_DOCUMENT_PAYLOAD" | "SECURITY_FIELD_IN_PAYLOAD" | "SESSION_MISMATCH" | "UNEXPECTED_SECRET";
7
+ export type V3CodecResult<T> = {
8
+ readonly ok: true;
9
+ readonly value: T;
10
+ } | {
11
+ readonly ok: false;
12
+ readonly error: V3CodecError;
13
+ };
14
+ export type V3DocumentBinding = {
15
+ readonly sessionId: string;
16
+ readonly secret: string;
17
+ };
18
+ export type V3NativeEnvelope = {
19
+ readonly kind: V3NativeToDocumentKind;
20
+ readonly payload: Readonly<Record<string, unknown>>;
21
+ };
22
+ export type V3DocumentCodec = {
23
+ encode(kind: V3DocumentToNativeKind, payload: Record<string, unknown>): V3CodecResult<Record<string, unknown>>;
24
+ parse(frame: string, maxFrameBytes?: number): V3CodecResult<V3NativeEnvelope>;
25
+ };
26
+ export type V3BootstrapActivation = {
27
+ readonly kind: "absent";
28
+ } | {
29
+ readonly kind: "required";
30
+ readonly codec: V3DocumentCodec;
31
+ } | {
32
+ readonly kind: "blocked";
33
+ };
34
+ /**
35
+ * Captures the document secret in a closure. Neither the returned codec nor
36
+ * parsed native messages expose it.
37
+ */
38
+ export declare function createV3DocumentCodec(binding: V3DocumentBinding): V3CodecResult<V3DocumentCodec>;
39
+ /**
40
+ * Atomically takes the host-installed one-shot handoff. The secret remains in
41
+ * the returned codec closure and is never copied into bridge globals/config.
42
+ */
43
+ export declare function consumeV3Bootstrap(): V3BootstrapActivation;
@@ -3,8 +3,15 @@ export type CommunicationMethod = 'messageport' | 'jsinterface' | 'webmessage' |
3
3
  export type PlatformOS = NonNullable<BridgeConfig['os']> | 'unknown';
4
4
  export declare const BRIDGE_CONFIG: BridgeConfig;
5
5
  export declare function getDisplayLanguage(): string;
6
- /** Subscribe to host display-language changes. Returns an unsubscribe. */
7
- export declare function subscribeDisplayLanguage(listener: () => void): () => void;
6
+ /**
7
+ * Subscribe to host display-language changes. Returns an unsubscribe.
8
+ *
9
+ * Change-only, so that this composes with `useSyncExternalStore`: the listener
10
+ * runs when the language changes, never on subscribe. Read the current value
11
+ * with `getDisplayLanguage()`. Logic's `lx.app.displayLanguage.watch` differs
12
+ * deliberately — it has no render loop to feed, so it delivers immediately.
13
+ */
14
+ export declare function subscribeDisplayLanguage(listener: (language: string) => void): () => void;
8
15
  export declare function getPlatformOS(): PlatformOS;
9
16
  export declare function isHarmony(): boolean;
10
17
  export declare function isIOS(): boolean;
@@ -12,6 +19,7 @@ export declare function isAndroid(): boolean;
12
19
  export declare function isMacOS(): boolean;
13
20
  export declare function isWindows(): boolean;
14
21
  export declare function isDesktop(): boolean;
22
+ export declare function isMobile(): boolean;
15
23
  export declare function isApple(): boolean;
16
24
  export declare function isDevSession(): boolean;
17
25
  export declare function isRunner(): boolean;
package/dist/types.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  export interface BridgeConfig {
2
2
  os?: 'Harmony' | 'iOS' | 'Android' | 'macOS' | 'Windows';
3
+ /**
4
+ * Transport for the host's form factor, read through `isMobile()` /
5
+ * `isDesktop()` rather than directly. Distinct from `os`, which names the
6
+ * actual system: the Runner simulating a phone is still macOS or Windows.
7
+ */
8
+ hostClass?: 'mobile' | 'desktop';
3
9
  /** Effective host display language; distinct from the raw system locale. */
4
10
  displayLanguage?: string;
5
11
  nonce?: string;
@@ -15,6 +21,18 @@ export interface ErrorInfo {
15
21
  reason?: string;
16
22
  code?: number;
17
23
  }
24
+ /**
25
+ * Private, one-shot handoff installed only in host-attested internal HTML.
26
+ *
27
+ * This is intentionally not part of the public bridge API and V2 never reads
28
+ * it. A future V3 bootstrap consumes the handoff before protocol activation.
29
+ */
30
+ interface ControlBootstrap {
31
+ readonly requiredProtocol: 3;
32
+ readonly publicSessionId: string;
33
+ readonly secret: string;
34
+ }
35
+ type TakeControlBootstrap = () => ControlBootstrap | undefined;
18
36
  export declare const BRIDGE_ERROR: {
19
37
  readonly NOT_READY: "BRIDGE_NOT_READY";
20
38
  readonly TIMEOUT: "BRIDGE_TIMEOUT";
@@ -29,6 +47,7 @@ export declare const BRIDGE_ERROR: {
29
47
  readonly OUTBOX_FULL: "BRIDGE_OUTBOX_FULL";
30
48
  readonly STREAM_OVERFLOW: "BRIDGE_STREAM_OVERFLOW";
31
49
  readonly STREAM_CLOSED: "BRIDGE_STREAM_CLOSED";
50
+ readonly MESSAGE_TOO_LARGE: "BRIDGE_MESSAGE_TOO_LARGE";
32
51
  };
33
52
  export type BridgeErrorCode = (typeof BRIDGE_ERROR)[keyof typeof BRIDGE_ERROR];
34
53
  export interface LxBridgeError {
@@ -126,16 +145,24 @@ declare global {
126
145
  };
127
146
  __LX_BRIDGE_INIT_STATE?: 'initializing' | 'initialized';
128
147
  __LX_RUNTIME_CONFIG?: RuntimeConfig;
148
+ /**
149
+ * Host-attested, one-shot V3 bootstrap handoff. It is absent for legacy
150
+ * scheme loads, assets, and all untrusted documents.
151
+ */
152
+ __LingXiaTakeControlBootstrap?: TakeControlBootstrap;
129
153
  __pageBridge?: {
130
154
  __names: string[];
131
155
  [key: string]: unknown;
132
156
  };
133
157
  __LingXiaRecvMessage?: (message: string) => void;
158
+ /** WebView2 inject script queues inbound payloads here until the bridge binds `__LingXiaRecvMessage`. */
159
+ __LingXiaEarlyNativeMessages?: string[];
134
160
  LingXiaBridge?: LingXiaBridgeInterface;
135
161
  LingXiaProxy?: {
136
162
  supportsMessagePort: () => boolean;
137
163
  getPort: (name: string) => string;
138
164
  postMessage: (message: string) => void;
165
+ nativeComponentUpdate?: ((payload: string) => void) | ((componentId: string, payload: string) => void);
139
166
  };
140
167
  NativeComponentBridge?: {
141
168
  postMessage: (message: string) => void;
@@ -165,6 +192,11 @@ export interface LingXiaBridgeInterface {
165
192
  proto: boolean;
166
193
  all: boolean;
167
194
  };
195
+ displayLanguage: {
196
+ get(): string;
197
+ /** Change-only; read the current value with `get()`. */
198
+ subscribe(listener: (language: string) => void): () => void;
199
+ };
168
200
  platform: {
169
201
  isHarmony(): boolean;
170
202
  isIOS(): boolean;
@@ -172,6 +204,7 @@ export interface LingXiaBridgeInterface {
172
204
  isMacOS(): boolean;
173
205
  isWindows(): boolean;
174
206
  isDesktop(): boolean;
207
+ isMobile(): boolean;
175
208
  isApple(): boolean;
176
209
  isRunner(): boolean;
177
210
  getOS(): string;
@@ -204,3 +237,4 @@ export interface StreamOptions extends StreamCallOptions {
204
237
  }
205
238
  export interface ChannelOptions extends ChannelOpenOptions {
206
239
  }
240
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lingxia/bridge",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "private": false,
5
5
  "description": "LingXia Web Runtime - Bridge, boot, and error handling for WebView",
6
6
  "repository": {
@@ -39,6 +39,8 @@
39
39
  "build:modules:es2020": "tsc -p tsconfig.modules.json",
40
40
  "build:modules:es5": "tsc -p tsconfig.modules.legacy.json",
41
41
  "build:types": "tsc -p tsconfig.json --emitDeclarationOnly",
42
+ "build:v3-test": "tsc -p tsconfig.v3-test.json",
43
+ "test": "npm run build:es2020 && npm run build:es5 && npm run build:v3-test && node ./test-support/test-protocol-v3.mjs && node ./test-support/test-bridge-required-v3.mjs && node ./test-support/test-control-bootstrap.mjs",
42
44
  "dev": "rolldown -c -w",
43
45
  "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
44
46
  "prepublishOnly": "npm run build"