anchordb-lens-link 1.3.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 AnchorDB contributors
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 ADDED
@@ -0,0 +1,87 @@
1
+ # anchordb-lens-link
2
+
3
+ **Open a QA build's AnchorDB database in Anchor Lens, on the same Android phone, from a file.** No
4
+ laptop, no relay, no pairing code.
5
+
6
+ ```bash
7
+ npm install anchordb-lens-link
8
+ ```
9
+
10
+ ```ts
11
+ import { enableLensLink } from "anchordb-lens-link";
12
+ import { db } from "./db";
13
+
14
+ // QA builds only — see "Keep it out of production" below.
15
+ if (process.env.EXPO_PUBLIC_LENS_LINK === "on") {
16
+ void enableLensLink(db, { allowInProduction: true });
17
+ }
18
+ ```
19
+
20
+ Rebuild the app (a development or release build — not Expo Go), install it, open it once. Then in
21
+ Anchor Lens: **Connect to an app → Open app from file**, and pick
22
+
23
+ ```
24
+ Android/media/<your app's package>/anchor-lens/<database>.anchorlens
25
+ ```
26
+
27
+ Lens opens the app's database live. Edits in either app show up in the other.
28
+
29
+ ## How it works
30
+
31
+ 1. `enableLensLink` starts a WebSocket server on **127.0.0.1**, on a random port, and puts an
32
+ `InspectorAgent` on the database behind it.
33
+ 2. It makes a **secret for this launch** — 32 random bytes — and writes the port and the secret into
34
+ the link file, **sealed** so that only Anchor Lens can read it.
35
+ 3. Lens reads the file, connects to `ws://127.0.0.1:<port>` with React Native's own WebSocket, and
36
+ proves it holds the secret with the inspector's HMAC challenge. The secret never crosses the
37
+ connection.
38
+ 4. From there it is an ordinary inspector session: the same protocol, capability checks and Model API
39
+ writes — validation, unique indexes, timestamps, the sync queue — as through `anchor-relay`.
40
+
41
+ The file is rewritten on every launch, because the port and the secret change. It works only while the
42
+ app is running; if Lens says the app closed, open the app and pick the file again.
43
+
44
+ ## Keep it out of production
45
+
46
+ A link exposes the database to Anchor Lens on the phone, so:
47
+
48
+ - Nothing happens unless the app calls `enableLensLink`. Put the call behind a build flag.
49
+ - In a release build — which includes a QA APK — it refuses to start without `allowInProduction: true`.
50
+ - `readOnly: true` refuses every write from Lens.
51
+ - `link.close()` stops the server and deletes the file.
52
+
53
+ What protects the database:
54
+
55
+ - **Loopback only.** The server listens on 127.0.0.1; nothing on the network can reach it. A web page in
56
+ the phone's browser can reach 127.0.0.1, and is refused by its Origin before it gets further.
57
+ - **A new secret every launch**, proved over a single-use challenge, never sent.
58
+ - **The seal** makes the file unreadable and tamper-evident to other apps. It is not secrecy from someone
59
+ who holds both the file and Anchor Lens, which carries the key — the two points above are what count.
60
+
61
+ ## API
62
+
63
+ ```ts
64
+ enableLensLink(db, options?): Promise<LensLink>
65
+ ```
66
+
67
+ | Option | |
68
+ | --- | --- |
69
+ | `allowInProduction` | Required in a release build |
70
+ | `readOnly` | Refuse every write from Lens |
71
+ | `anchorVersion`, `platform` | Reported to Lens |
72
+ | `onConnectionsChange(count)` | Lens connections opening and closing |
73
+
74
+ `LensLink` has `port`, `fileName`, `filePath`, `connections`, `agent` and `close()`. Calling
75
+ `enableLensLink` again for the same database replaces the running link, so it is safe in an effect and
76
+ through Fast Refresh.
77
+
78
+ For a Lens client: `readLensLink(fileText)` returns `{ app, database, port, secret, … }` or throws a
79
+ `LensLinkError` (`not_a_link`, `tampered`, `unsupported`, `invalid`) with a message worth showing, and
80
+ `lensLinkUrl(link)` is where to connect.
81
+
82
+ ## Requirements
83
+
84
+ - Android. The native module is an Expo module, so the app needs `expo` (any Expo app has it; a bare
85
+ React Native app needs `expo-modules-core` installed). iOS is not supported yet.
86
+ - `anchordb` 1.3 or later, whose inspector accepts a shared secret.
87
+ - Anchor Lens with **Open app from file**.
@@ -0,0 +1,21 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'expo-module-gradle-plugin'
4
+ }
5
+
6
+ group = 'dev.anchordb'
7
+ version = '1.3.0'
8
+
9
+ android {
10
+ namespace "dev.anchordb.lenslink"
11
+ defaultConfig {
12
+ versionCode 1
13
+ versionName '1.3.0'
14
+ }
15
+ }
16
+
17
+ dependencies {
18
+ // The WebSocket server on 127.0.0.1. Anchor Lens connects with React Native's built-in WebSocket, so
19
+ // this is the only native networking code on either side.
20
+ implementation "org.java-websocket:Java-WebSocket:1.6.0"
21
+ }
@@ -0,0 +1,203 @@
1
+ package dev.anchordb.lenslink
2
+
3
+ import android.os.Bundle
4
+ import expo.modules.kotlin.Promise
5
+ import expo.modules.kotlin.exception.CodedException
6
+ import expo.modules.kotlin.exception.Exceptions
7
+ import expo.modules.kotlin.modules.Module
8
+ import expo.modules.kotlin.modules.ModuleDefinition
9
+ import org.java_websocket.WebSocket
10
+ import org.java_websocket.drafts.Draft
11
+ import org.java_websocket.exceptions.InvalidDataException
12
+ import org.java_websocket.framing.CloseFrame
13
+ import org.java_websocket.handshake.ClientHandshake
14
+ import org.java_websocket.handshake.ServerHandshakeBuilder
15
+ import org.java_websocket.server.WebSocketServer
16
+ import java.io.File
17
+ import java.net.InetSocketAddress
18
+ import java.util.concurrent.ConcurrentHashMap
19
+ import java.util.concurrent.atomic.AtomicBoolean
20
+ import java.util.concurrent.atomic.AtomicInteger
21
+
22
+ /**
23
+ * The native half of Anchor Lens Link: a WebSocket server on this phone's loopback address, and the link
24
+ * file in Android/media/<package>/anchor-lens/.
25
+ *
26
+ * Only transport and files live here. Authentication, the inspector protocol and the database are all
27
+ * JavaScript, in anchordb itself — this module never sees a secret it checks, or a document.
28
+ */
29
+ class AnchorLensLinkModule : Module() {
30
+ private var server: LinkServer? = null
31
+
32
+ override fun definition() = ModuleDefinition {
33
+ Name("AnchorLensLink")
34
+
35
+ Events("onOpen", "onMessage", "onClose")
36
+
37
+ AsyncFunction("startServer") { promise: Promise ->
38
+ stopServerNow()
39
+ val next = LinkServer(this@AnchorLensLinkModule, promise)
40
+ server = next
41
+ next.start()
42
+ }
43
+
44
+ AsyncFunction("stopServer") {
45
+ stopServerNow()
46
+ }
47
+
48
+ Function("send") { id: Int, data: String ->
49
+ server?.sendTo(id, data) ?: false
50
+ }
51
+
52
+ Function("closeConnection") { id: Int ->
53
+ closeConnectionNow(id)
54
+ }
55
+
56
+ Function("packageName") {
57
+ context().packageName
58
+ }
59
+
60
+ Function("writeLinkFile") { name: String, contents: String ->
61
+ val file = linkFile(name)
62
+ file.parentFile?.mkdirs()
63
+ file.writeText(contents)
64
+ file.absolutePath
65
+ }
66
+
67
+ Function("deleteLinkFile") { name: String ->
68
+ linkFile(name).delete()
69
+ }
70
+
71
+ OnDestroy {
72
+ stopServerNow()
73
+ }
74
+ }
75
+
76
+ internal fun emit(event: String, body: Bundle) {
77
+ sendEvent(event, body)
78
+ }
79
+
80
+ private fun stopServerNow() {
81
+ server?.shutdown()
82
+ server = null
83
+ }
84
+
85
+ private fun closeConnectionNow(id: Int) {
86
+ server?.closeConnection(id)
87
+ }
88
+
89
+ private fun context() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
90
+
91
+ @Suppress("DEPRECATION")
92
+ private fun linkFile(name: String): File {
93
+ if (!FILE_NAME.matches(name)) throw InvalidLinkFileName(name)
94
+ // externalMediaDirs is the call that creates Android/media/<package>, which another app's file picker
95
+ // can open. getExternalFilesDir would put the file under Android/data, which pickers hide.
96
+ val media = context().externalMediaDirs.firstOrNull { it != null } ?: throw NoMediaDirectory()
97
+ return File(File(media, "anchor-lens"), name)
98
+ }
99
+
100
+ companion object {
101
+ private val FILE_NAME = Regex("^[A-Za-z0-9._-]{1,120}\\.anchorlens$")
102
+ }
103
+ }
104
+
105
+ private class InvalidLinkFileName(name: String) :
106
+ CodedException("\"$name\" is not an Anchor Lens link file name.")
107
+
108
+ private class NoMediaDirectory :
109
+ CodedException("This device has no shared media storage to write the Anchor Lens link file to.")
110
+
111
+ /** One launch's server. A stopped WebSocketServer cannot be started again, so each start makes a new one. */
112
+ private class LinkServer(
113
+ private val module: AnchorLensLinkModule,
114
+ private val started: Promise,
115
+ ) : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) {
116
+ private val connections = ConcurrentHashMap<Int, WebSocket>()
117
+ private val nextId = AtomicInteger(0)
118
+ private val settled = AtomicBoolean(false)
119
+
120
+ init {
121
+ isReuseAddr = true
122
+ }
123
+
124
+ override fun onWebsocketHandshakeReceivedAsServer(
125
+ conn: WebSocket,
126
+ draft: Draft,
127
+ request: ClientHandshake,
128
+ ): ServerHandshakeBuilder {
129
+ // A web page open in the phone's browser can reach 127.0.0.1 too. It could never prove the secret,
130
+ // but it is turned away before that: browsers always send their page's Origin, and React Native's
131
+ // WebSocket sends none or a loopback one.
132
+ val origin = request.getFieldValue("Origin")
133
+ if (origin.isNotEmpty() && !LOOPBACK_ORIGIN.matches(origin)) {
134
+ throw InvalidDataException(CloseFrame.POLICY_VALIDATION, "Origin not allowed")
135
+ }
136
+ return super.onWebsocketHandshakeReceivedAsServer(conn, draft, request)
137
+ }
138
+
139
+ override fun onStart() {
140
+ if (settled.compareAndSet(false, true)) started.resolve(port)
141
+ }
142
+
143
+ override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
144
+ val id = nextId.incrementAndGet()
145
+ conn.setAttachment(id)
146
+ connections[id] = conn
147
+ module.emit("onOpen", Bundle().apply { putInt("id", id) })
148
+ }
149
+
150
+ override fun onMessage(conn: WebSocket, message: String) {
151
+ val id: Int = conn.getAttachment<Int>() ?: return
152
+ module.emit(
153
+ "onMessage",
154
+ Bundle().apply {
155
+ putInt("id", id)
156
+ putString("data", message)
157
+ },
158
+ )
159
+ }
160
+
161
+ override fun onClose(conn: WebSocket, code: Int, reason: String?, remote: Boolean) {
162
+ val id: Int = conn.getAttachment<Int>() ?: return
163
+ connections.remove(id)
164
+ module.emit(
165
+ "onClose",
166
+ Bundle().apply {
167
+ putInt("id", id)
168
+ putInt("code", code)
169
+ },
170
+ )
171
+ }
172
+
173
+ override fun onError(conn: WebSocket?, ex: Exception) {
174
+ // With no connection it is the server itself that failed; before it started, that is start failing.
175
+ if (conn == null && settled.compareAndSet(false, true)) {
176
+ started.reject("ERR_LENS_LINK_START", ex.message ?: "The Anchor Lens Link server did not start.", ex)
177
+ }
178
+ }
179
+
180
+ fun sendTo(id: Int, data: String): Boolean {
181
+ val conn = connections[id] ?: return false
182
+ if (!conn.isOpen) return false
183
+ conn.send(data)
184
+ return true
185
+ }
186
+
187
+ fun closeConnection(id: Int) {
188
+ connections[id]?.close(CloseFrame.NORMAL)
189
+ }
190
+
191
+ fun shutdown() {
192
+ try {
193
+ stop(1000)
194
+ } catch (_: InterruptedException) {
195
+ Thread.currentThread().interrupt()
196
+ }
197
+ connections.clear()
198
+ }
199
+
200
+ companion object {
201
+ private val LOOPBACK_ORIGIN = Regex("^https?://(127\\.0\\.0\\.1|localhost)(:\\d+)?$")
202
+ }
203
+ }
@@ -0,0 +1,65 @@
1
+ import { InspectorAgent, type AnchorDB } from "anchordb";
2
+ /**
3
+ * The app half of Anchor Lens Link.
4
+ *
5
+ * `enableLensLink(db)` starts a WebSocket server on 127.0.0.1, puts an `InspectorAgent` on the database
6
+ * behind it, and writes the link file Lens opens. Every connection is an ordinary inspector session —
7
+ * the same protocol, capability checks and Model API writes as through the relay — authenticated with a
8
+ * secret made for this launch, which exists only inside the sealed file.
9
+ */
10
+ /** An event from the native module. */
11
+ export interface LensLinkNativeEvent {
12
+ id: number;
13
+ data?: string;
14
+ code?: number;
15
+ }
16
+ /** The native module's surface. Exported so a test, or another runtime, can supply its own. */
17
+ export interface LensLinkNative {
18
+ startServer(): Promise<number>;
19
+ stopServer(): Promise<void>;
20
+ send(id: number, data: string): boolean;
21
+ closeConnection(id: number): void;
22
+ packageName(): string;
23
+ writeLinkFile(name: string, contents: string): string;
24
+ deleteLinkFile(name: string): boolean;
25
+ addListener(event: "onOpen" | "onMessage" | "onClose", listener: (event: LensLinkNativeEvent) => void): {
26
+ remove(): void;
27
+ };
28
+ }
29
+ export interface LensLinkOptions {
30
+ /**
31
+ * Required in a release build — which includes a QA APK. Lens Link exposes the database to Anchor
32
+ * Lens on this phone, so shipping it to users has to be a decision, not an accident.
33
+ */
34
+ allowInProduction?: boolean;
35
+ /** Refuse every write from Lens. */
36
+ readOnly?: boolean;
37
+ /** Reported to Lens. */
38
+ anchorVersion?: string;
39
+ platform?: string;
40
+ /** Called whenever a Lens connection opens or closes, with how many are open. */
41
+ onConnectionsChange?: (count: number) => void;
42
+ /** The native module. Defaults to the one this package installs. */
43
+ native?: LensLinkNative;
44
+ }
45
+ export interface LensLink {
46
+ readonly agent: InspectorAgent;
47
+ /** The port on 127.0.0.1. */
48
+ readonly port: number;
49
+ /** The link file's name, `<database>.anchorlens`. */
50
+ readonly fileName: string;
51
+ /** Where it was written, e.g. /storage/emulated/0/Android/media/<package>/anchor-lens/<database>.anchorlens. */
52
+ readonly filePath: string;
53
+ /** Lens connections open right now. */
54
+ readonly connections: number;
55
+ /** Stop listening, close every Lens connection, and delete the link file. */
56
+ close(): Promise<void>;
57
+ }
58
+ /**
59
+ * Let Anchor Lens on this phone open `db`. For development and QA builds.
60
+ *
61
+ * Calling it again for the same database replaces the running link, so it is safe in an effect or
62
+ * after a reload. Resolves once the link file is written.
63
+ */
64
+ export declare function enableLensLink(db: AnchorDB, options?: LensLinkOptions): Promise<LensLink>;
65
+ //# sourceMappingURL=app.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EAGd,KAAK,QAAQ,EAEd,MAAM,UAAU,CAAC;AAGlB;;;;;;;GAOG;AAEH,uCAAuC;AACvC,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,+FAA+F;AAC/F,MAAM,WAAW,cAAc;IAC7B,WAAW,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/B,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IACxC,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,WAAW,IAAI,MAAM,CAAC;IACtB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IACtD,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IACtC,WAAW,CACT,KAAK,EAAE,QAAQ,GAAG,WAAW,GAAG,SAAS,EACzC,QAAQ,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,IAAI,GAC7C;QAAE,MAAM,IAAI,IAAI,CAAA;KAAE,CAAC;CACvB;AAED,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wBAAwB;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,oEAAoE;IACpE,MAAM,CAAC,EAAE,cAAc,CAAC;CACzB;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,6BAA6B;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gHAAgH;IAChH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uCAAuC;IACvC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,6EAA6E;IAC7E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAOD;;;;;GAKG;AACH,wBAAsB,cAAc,CAAC,EAAE,EAAE,QAAQ,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAOnG"}
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.enableLensLink = enableLensLink;
4
+ const anchordb_1 = require("anchordb");
5
+ const link_file_ts_1 = require("./link-file.js");
6
+ // On globalThis, not in module scope: Fast Refresh re-runs this module, and a registry that reset with it
7
+ // would leave the previous agent answering on the previous server's events.
8
+ const registry = (globalThis.__anchordbLensLinks ??=
9
+ new Map());
10
+ /**
11
+ * Let Anchor Lens on this phone open `db`. For development and QA builds.
12
+ *
13
+ * Calling it again for the same database replaces the running link, so it is safe in an effect or
14
+ * after a reload. Resolves once the link file is written.
15
+ */
16
+ async function enableLensLink(db, options = {}) {
17
+ (0, anchordb_1.assertInspectorAllowed)(db.name, options.allowInProduction);
18
+ const native = options.native ?? nativeModule();
19
+ await registry.get(db.name)?.close();
20
+ const link = await start(db, native, options);
21
+ registry.set(db.name, link);
22
+ return link;
23
+ }
24
+ async function start(db, native, options) {
25
+ // Uppercase, so a client that normalises what it proves the way it does a typed pairing code still
26
+ // proves the same value.
27
+ const secret = (0, link_file_ts_1.randomHex)(32).toUpperCase();
28
+ const platform = options.platform ?? "android";
29
+ const agent = new anchordb_1.InspectorAgent(db, {
30
+ auth: { sharedSecret: secret },
31
+ platform,
32
+ ...(options.anchorVersion ? { anchorVersion: options.anchorVersion } : {}),
33
+ ...(options.readOnly ? { readOnly: true } : {}),
34
+ });
35
+ const sockets = new Map();
36
+ const report = () => options.onConnectionsChange?.(sockets.size);
37
+ const subscriptions = [
38
+ native.addListener("onOpen", ({ id }) => {
39
+ const socket = new NativeSocket(native, id);
40
+ const detach = agent.attach(new anchordb_1.WebSocketTransport({ socket }));
41
+ sockets.set(id, { socket, detach });
42
+ report();
43
+ }),
44
+ native.addListener("onMessage", ({ id, data }) => {
45
+ sockets.get(id)?.socket.receive(data ?? "");
46
+ }),
47
+ native.addListener("onClose", ({ id }) => {
48
+ const entry = sockets.get(id);
49
+ if (!entry)
50
+ return;
51
+ sockets.delete(id);
52
+ entry.detach();
53
+ entry.socket.closed();
54
+ report();
55
+ }),
56
+ ];
57
+ const fileName = `${db.name.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 100)}.anchorlens`;
58
+ let port;
59
+ let filePath;
60
+ try {
61
+ port = await native.startServer();
62
+ const payload = {
63
+ v: 1,
64
+ app: native.packageName(),
65
+ database: db.name,
66
+ port,
67
+ secret,
68
+ platform,
69
+ ...(options.anchorVersion ? { anchorVersion: options.anchorVersion } : {}),
70
+ createdAt: new Date().toISOString(),
71
+ };
72
+ filePath = native.writeLinkFile(fileName, (0, link_file_ts_1.sealLensLink)(payload));
73
+ }
74
+ catch (err) {
75
+ for (const subscription of subscriptions)
76
+ subscription.remove();
77
+ await native.stopServer().catch(() => undefined);
78
+ await agent.close();
79
+ throw err;
80
+ }
81
+ let closed = false;
82
+ const link = {
83
+ agent,
84
+ port,
85
+ fileName,
86
+ filePath,
87
+ get connections() {
88
+ return sockets.size;
89
+ },
90
+ close: async () => {
91
+ if (closed)
92
+ return;
93
+ closed = true;
94
+ if (registry.get(db.name) === link)
95
+ registry.delete(db.name);
96
+ for (const subscription of subscriptions)
97
+ subscription.remove();
98
+ for (const { socket, detach } of sockets.values()) {
99
+ detach();
100
+ socket.closed();
101
+ }
102
+ sockets.clear();
103
+ try {
104
+ native.deleteLinkFile(fileName);
105
+ }
106
+ catch {
107
+ // A link file that is already gone is what closing wants anyway.
108
+ }
109
+ await native.stopServer();
110
+ await agent.close();
111
+ report();
112
+ },
113
+ };
114
+ return link;
115
+ }
116
+ /** One native connection, shaped like the WebSocket core's transport already speaks. */
117
+ class NativeSocket {
118
+ constructor(native, id) {
119
+ this.readyState = 1;
120
+ this.listeners = new Map();
121
+ this.native = native;
122
+ this.id = id;
123
+ }
124
+ send(data) {
125
+ if (this.readyState === 1)
126
+ this.native.send(this.id, data);
127
+ }
128
+ close() {
129
+ if (this.readyState !== 1)
130
+ return;
131
+ this.readyState = 2;
132
+ this.native.closeConnection(this.id);
133
+ }
134
+ addEventListener(type, listener) {
135
+ let set = this.listeners.get(type);
136
+ if (!set)
137
+ this.listeners.set(type, (set = new Set()));
138
+ set.add(listener);
139
+ }
140
+ removeEventListener(type, listener) {
141
+ this.listeners.get(type)?.delete(listener);
142
+ }
143
+ receive(data) {
144
+ this.emit("message", { data });
145
+ }
146
+ closed() {
147
+ if (this.readyState === 3)
148
+ return;
149
+ this.readyState = 3;
150
+ this.emit("close", {});
151
+ }
152
+ emit(type, event) {
153
+ for (const listener of [...(this.listeners.get(type) ?? [])])
154
+ listener(event);
155
+ }
156
+ }
157
+ function nativeModule() {
158
+ const modules = globalThis.expo?.modules;
159
+ const found = modules?.AnchorLensLink;
160
+ if (!found) {
161
+ throw new Error("Anchor Lens Link needs its native module, and this build does not have it. It runs on Android, in a " +
162
+ "development or release build (not Expo Go): install anchordb-lens-link, then rebuild the app.");
163
+ }
164
+ return found;
165
+ }
166
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.js","sourceRoot":"","sources":["../../src/app.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * anchordb-lens-link — open a QA build's database in Anchor Lens on the same Android phone.
3
+ *
4
+ * App side: `enableLensLink(db, { allowInProduction: true })`.
5
+ * Lens side: `readLensLink(fileText)` and `lensLinkUrl(link)`, then an ordinary inspector client over
6
+ * React Native's WebSocket, authenticated with `link.secret`.
7
+ */
8
+ export { enableLensLink } from "./app.js";
9
+ export type { LensLink, LensLinkNative, LensLinkNativeEvent, LensLinkOptions } from "./app.js";
10
+ export { LensLinkError, lensLinkUrl, readLensLink, sealLensLink } from "./link-file.js";
11
+ export type { LensLinkErrorCode, LensLinkPayload } from "./link-file.js";
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAC1C,YAAY,EAAE,QAAQ,EAAE,cAAc,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAC/F,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxF,YAAY,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sealLensLink = exports.readLensLink = exports.lensLinkUrl = exports.LensLinkError = exports.enableLensLink = void 0;
4
+ /**
5
+ * anchordb-lens-link — open a QA build's database in Anchor Lens on the same Android phone.
6
+ *
7
+ * App side: `enableLensLink(db, { allowInProduction: true })`.
8
+ * Lens side: `readLensLink(fileText)` and `lensLinkUrl(link)`, then an ordinary inspector client over
9
+ * React Native's WebSocket, authenticated with `link.secret`.
10
+ */
11
+ var app_ts_1 = require("./app.js");
12
+ Object.defineProperty(exports, "enableLensLink", { enumerable: true, get: function () { return app_ts_1.enableLensLink; } });
13
+ var link_file_ts_1 = require("./link-file.js");
14
+ Object.defineProperty(exports, "LensLinkError", { enumerable: true, get: function () { return link_file_ts_1.LensLinkError; } });
15
+ Object.defineProperty(exports, "lensLinkUrl", { enumerable: true, get: function () { return link_file_ts_1.lensLinkUrl; } });
16
+ Object.defineProperty(exports, "readLensLink", { enumerable: true, get: function () { return link_file_ts_1.readLensLink; } });
17
+ Object.defineProperty(exports, "sealLensLink", { enumerable: true, get: function () { return link_file_ts_1.sealLensLink; } });
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":";;;AAAA;;;;;;GAMG;AACH,mCAA0C;AAAjC,wGAAA,cAAc,OAAA;AAEvB,+CAAwF;AAA/E,6GAAA,aAAa,OAAA;AAAE,2GAAA,WAAW,OAAA;AAAE,4GAAA,YAAY,OAAA;AAAE,4GAAA,YAAY,OAAA"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The link file: where a running app can be reached on this phone, and the secret that proves it is
3
+ * Anchor Lens connecting.
4
+ *
5
+ * The app writes it on every launch, because the port and the secret change every launch. Lens reads
6
+ * it when the developer picks it.
7
+ *
8
+ * ## What the seal does, and does not do
9
+ *
10
+ * The contents are encrypted and authenticated with a key this package and Anchor Lens share: an
11
+ * HMAC-SHA256 keystream, and an HMAC tag checked before anything is decrypted. So the file is unreadable
12
+ * and tamper-evident to anything that is not Lens. It is not secrecy from someone who holds both the
13
+ * file and a copy of Lens, because Lens ships the key. What actually protects the database is the rest:
14
+ * the server listens on 127.0.0.1 only, the secret changes every launch and is proved rather than sent,
15
+ * and none of it exists unless a QA build calls `enableLensLink`.
16
+ *
17
+ * Built on anchordb's own HMAC-SHA256, so there is no crypto dependency and it behaves the same in
18
+ * Hermes, Node and a browser.
19
+ */
20
+ export interface LensLinkPayload {
21
+ v: 1;
22
+ /** The app's Android package, such as dev.anchordb.example. */
23
+ app: string;
24
+ database: string;
25
+ /** Where the app listens, on 127.0.0.1. Changes every launch. */
26
+ port: number;
27
+ /** 64 uppercase hex digits. Changes every launch. Proved over the connection, never sent. */
28
+ secret: string;
29
+ platform: string;
30
+ anchorVersion?: string;
31
+ createdAt: string;
32
+ }
33
+ export type LensLinkErrorCode = "not_a_link" | "tampered" | "unsupported" | "invalid";
34
+ export declare class LensLinkError extends Error {
35
+ readonly code: LensLinkErrorCode;
36
+ constructor(code: LensLinkErrorCode, message: string);
37
+ }
38
+ /** The file an app writes. The first lines say what it is to anyone who opens it. */
39
+ export declare function sealLensLink(payload: LensLinkPayload, nonce?: string): string;
40
+ /** Read a link file picked in Lens. Throws `LensLinkError` with a message worth showing. */
41
+ export declare function readLensLink(text: string): LensLinkPayload;
42
+ /** Where Lens connects for a link. */
43
+ export declare function lensLinkUrl(payload: Pick<LensLinkPayload, "port">): string;
44
+ /** Random hex from the platform. Drawn only when a link is made, never at import. */
45
+ export declare function randomHex(byteCount: number): string;
46
+ //# sourceMappingURL=link-file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"link-file.d.ts","sourceRoot":"","sources":["../../src/link-file.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,eAAe;IAC9B,CAAC,EAAE,CAAC,CAAC;IACL,+DAA+D;IAC/D,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,iEAAiE;IACjE,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,MAAM,iBAAiB,GAAG,YAAY,GAAG,UAAU,GAAG,aAAa,GAAG,SAAS,CAAC;AAEtF,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;gBAErB,IAAI,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM;CAKrD;AAYD,qFAAqF;AACrF,wBAAgB,YAAY,CAAC,OAAO,EAAE,eAAe,EAAE,KAAK,GAAE,MAAsB,GAAG,MAAM,CAU5F;AAED,4FAA4F;AAC5F,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,CAwC1D;AAED,sCAAsC;AACtC,wBAAgB,WAAW,CAAC,OAAO,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,CAAC,GAAG,MAAM,CAE1E;AAyED,qFAAqF;AACrF,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAQnD"}