react-native-tapline 1.0.0 → 1.2.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/metro.js CHANGED
@@ -2,6 +2,99 @@
2
2
  'use strict';
3
3
 
4
4
  const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+
8
+ /** @typedef {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: () => void) => void} Middleware */
9
+
10
+ /** Private LAN, link-local, loopback: the development offer stays on the Wi-Fi (never Tailscale or the internet). */
11
+ /** @param {string | undefined} address */
12
+ function isLocalNetwork(address) {
13
+ if (typeof address !== 'string') return false;
14
+ const a = address.toLowerCase().replace(/^::ffff:/, '');
15
+ if (a === '127.0.0.1' || a === '::1') return true;
16
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(a);
17
+ if (v4) {
18
+ const x = Number(v4[1]);
19
+ const y = Number(v4[2]);
20
+ return x === 10 || (x === 192 && y === 168) || (x === 172 && y >= 16 && y <= 31) || (x === 169 && y === 254);
21
+ }
22
+ // unique-local IPv6, but not Tailscale's own range (fd7a:115c:a1e0::/48)
23
+ return (/^f[cd][0-9a-f]{2}:/.test(a) && !a.startsWith('fd7a:115c:a1e0:')) || a.startsWith('fe80:');
24
+ }
25
+
26
+ /**
27
+ * Claims the offer in `file` for `peer` (the first device to fetch it): false when another device has it.
28
+ * @param {string} file @param {string} peer
29
+ */
30
+ function claimOffer(file, peer) {
31
+ try {
32
+ const o = JSON.parse(fs.readFileSync(file, 'utf8'));
33
+ if (typeof o.claimedBy === 'string') return o.claimedBy === peer;
34
+ o.claimedBy = peer;
35
+ fs.writeFileSync(file, JSON.stringify(o), { mode: 0o600 });
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * The newest open development offer a Tapline host on this computer wrote after `pair` (Tapline 1.1, zero-QR
44
+ * pairing), or null. Files live in ~/.tapline/offers (or $TAPLINE_HOME/offers), one per host port.
45
+ * @param {string} dir
46
+ * @returns {{ code: string, name: string, port: number, file: string, claimedBy: string | undefined } | null}
47
+ */
48
+ function readOffer(dir) {
49
+ let best = null;
50
+ let names = [];
51
+ try {
52
+ names = fs.readdirSync(dir).filter((n) => /^\d+\.json$/.test(n));
53
+ } catch {
54
+ return null;
55
+ }
56
+ for (const name of names) {
57
+ try {
58
+ const file = path.join(dir, name);
59
+ const o = JSON.parse(fs.readFileSync(file, 'utf8'));
60
+ if (typeof o.code !== 'string' || typeof o.name !== 'string' || typeof o.port !== 'number' || typeof o.until !== 'number') continue;
61
+ if (o.until <= Date.now()) continue;
62
+ if (best === null || o.until > best.until) best = { ...o, file };
63
+ } catch {
64
+ // a file being written, or not ours
65
+ }
66
+ }
67
+ return best === null ? null : { code: best.code, name: best.name, port: best.port, file: best.file, claimedBy: typeof best.claimedBy === 'string' ? best.claimedBy : undefined };
68
+ }
69
+
70
+ /**
71
+ * Answers `GET /tapline/offer` on Metro, which the phone always reaches in development.
72
+ * @param {string} dir
73
+ * @returns {Middleware}
74
+ */
75
+ function offerMiddleware(dir) {
76
+ return (req, res, next) => {
77
+ if (!req.url || !req.url.startsWith('/tapline/offer')) return next();
78
+ /** @param {number} status @param {Record<string, unknown>} body */
79
+ const answer = (status, body) => {
80
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
81
+ res.end(JSON.stringify(body));
82
+ };
83
+ // Through a proxy (Tailscale Serve, a tunnel) the peer isn't on the Wi-Fi, whatever the socket says.
84
+ const forwarded = req.headers['x-forwarded-for'] !== undefined || req.headers['tailscale-user-login'] !== undefined;
85
+ // A web page could make this request (it sends an Origin, or reaches us under its own name: DNS rebinding).
86
+ const host = String(req.headers.host ?? '').toLowerCase().replace(/:\d+$/, '');
87
+ const byAddress = /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || (host.startsWith('[') && host.endsWith(']')) || host === 'localhost' || host.endsWith('.local');
88
+ const fromPage = req.headers.origin !== undefined || !byAddress;
89
+ const address = req.socket && req.socket.remoteAddress;
90
+ if (forwarded || fromPage || !isLocalNetwork(address)) return answer(403, { error: 'local network only' });
91
+ const offer = readOffer(dir);
92
+ const peer = (address ?? '').toLowerCase().replace(/^::ffff:/, '');
93
+ // The first device to fetch it claims it, and only that device can pair with it.
94
+ if (offer === null || !claimOffer(offer.file, peer)) return answer(404, { error: 'no offer open: call pair on the computer' });
95
+ return answer(200, { code: offer.code, name: offer.name, port: offer.port });
96
+ };
97
+ }
5
98
 
6
99
  /**
7
100
  * Metro config helper: runs Tapline's `register` before the app's main module, so the app
@@ -27,6 +120,10 @@ function withTapline(config, options = {}) {
27
120
  const register = path.join(__dirname, 'dist', 'register.js');
28
121
  const serializer = config.serializer ?? {};
29
122
  const previous = serializer.getModulesRunBeforeMainModule;
123
+ const server = /** @type {{ enhanceMiddleware?: (m: Middleware, s: unknown) => Middleware } & Record<string, unknown>} */ (config.server ?? {});
124
+ const enhance = server.enhanceMiddleware;
125
+ const home = process.env.TAPLINE_HOME && process.env.TAPLINE_HOME.length > 0 ? process.env.TAPLINE_HOME : path.join(os.homedir(), '.tapline');
126
+ const offers = offerMiddleware(path.join(home, 'offers'));
30
127
  return {
31
128
  ...config,
32
129
  serializer: {
@@ -34,7 +131,16 @@ function withTapline(config, options = {}) {
34
131
  /** @param {string} entryFilePath */
35
132
  getModulesRunBeforeMainModule: (entryFilePath) => [...(previous ? previous(entryFilePath) : []), register],
36
133
  },
134
+ server: {
135
+ ...server,
136
+ // Zero-QR pairing: the phone asks Metro for the offer a Tapline host on this computer opened.
137
+ /** @param {Middleware} middleware @param {unknown} metroServer @returns {Middleware} */
138
+ enhanceMiddleware: (middleware, metroServer) => {
139
+ const inner = enhance ? enhance(middleware, metroServer) : middleware;
140
+ return (req, res, next) => offers(req, res, () => inner(req, res, next));
141
+ },
142
+ },
37
143
  };
38
144
  }
39
145
 
40
- module.exports = { withTapline };
146
+ module.exports = { withTapline, readOffer, offerMiddleware, isLocalNetwork };
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "react-native-tapline",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Tapline in-app agent: lets AI agents see and drive your own React Native / Expo app on a test phone on the same Wi-Fi.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/bo5amis/tapline.git",
8
+ "url": "git+https://github.com/BO5AMIS/tapline.git",
9
9
  "directory": "packages/react-native"
10
10
  },
11
11
  "homepage": "https://github.com/bo5amis/tapline/tree/main/packages/react-native#readme",