uniwrtc 1.0.9 → 1.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,6 @@
1
+ # Nostr notes
2
+
3
+ The demo page now defaults to Nostr (no UI toggle).
4
+
5
+ - Nostr client: [src/nostr/nostrClient.js](src/nostr/nostrClient.js)
6
+ - UI integration: [src/main.js](src/main.js)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniwrtc",
3
- "version": "1.0.9",
3
+ "version": "1.1.0",
4
4
  "description": "A universal WebRTC signaling service",
5
5
  "main": "server.js",
6
6
  "type": "module",
@@ -20,7 +20,9 @@
20
20
  ],
21
21
  "author": "",
22
22
  "license": "MIT",
23
- "dependencies": {},
23
+ "dependencies": {
24
+ "nostr-tools": "^2.9.0"
25
+ },
24
26
  "devDependencies": {
25
27
  "@playwright/test": "^1.57.0",
26
28
  "vite": "^6.0.6"
package/src/main.js CHANGED
@@ -1,9 +1,13 @@
1
1
  import './style.css';
2
2
  import UniWRTCClient from '../client-browser.js';
3
+ import { createNostrClient } from './nostr/nostrClient.js';
3
4
 
4
5
  // Make UniWRTCClient available globally for backwards compatibility
5
6
  window.UniWRTCClient = UniWRTCClient;
6
7
 
8
+ // Nostr is the default transport (no toggle)
9
+ let nostrClient = null;
10
+
7
11
  let client = null;
8
12
  const peerConnections = new Map();
9
13
  const dataChannels = new Map();
@@ -20,8 +24,8 @@ document.getElementById('app').innerHTML = `
20
24
  <h2>Connection</h2>
21
25
  <div class="connection-controls">
22
26
  <div>
23
- <label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Server URL</label>
24
- <input type="text" id="serverUrl" data-testid="serverUrl" placeholder="wss://signal.peer.ooo or ws://localhost:8080" value="wss://signal.peer.ooo">
27
+ <label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Relay URL</label>
28
+ <input type="text" id="relayUrl" placeholder="wss://relay.damus.io" value="wss://relay.damus.io" style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px; font-family: monospace; font-size: 12px;">
25
29
  </div>
26
30
  <div>
27
31
  <label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Room / Session ID</label>
@@ -147,6 +151,67 @@ function updatePeerList() {
147
151
  }
148
152
 
149
153
  window.connect = async function() {
154
+ await connectNostr();
155
+ };
156
+
157
+ async function connectNostr() {
158
+ const relayUrl = document.getElementById('relayUrl').value.trim();
159
+ const roomIdInput = document.getElementById('roomId');
160
+ const roomId = roomIdInput.value.trim();
161
+
162
+ if (!relayUrl) {
163
+ log('Please enter a relay URL', 'error');
164
+ return;
165
+ }
166
+
167
+ const effectiveRoom = roomId || `room-${Math.random().toString(36).substring(2, 10)}`;
168
+ if (!roomId) roomIdInput.value = effectiveRoom;
169
+
170
+ try {
171
+ log(`Connecting to Nostr relay: ${relayUrl}...`, 'info');
172
+
173
+ if (nostrClient) {
174
+ await nostrClient.disconnect();
175
+ nostrClient = null;
176
+ }
177
+
178
+ nostrClient = createNostrClient({
179
+ relayUrl,
180
+ room: effectiveRoom,
181
+ onState: (state) => {
182
+ if (state === 'connected') updateStatus(true);
183
+ if (state === 'disconnected') updateStatus(false);
184
+ },
185
+ onMessage: ({ from, text }) => {
186
+ displayChatMessage(text, from, false);
187
+ },
188
+ onPeer: ({ peerId }) => {
189
+ // Use the existing peer list UI as a simple "seen peers" list
190
+ if (!peerConnections.has(peerId)) {
191
+ peerConnections.set(peerId, null);
192
+ updatePeerList();
193
+ log(`Peer seen: ${peerId.substring(0, 6)}...`, 'success');
194
+ }
195
+ }
196
+ });
197
+
198
+ await nostrClient.connect();
199
+
200
+ const myPubkey = nostrClient.getPublicKey();
201
+ document.getElementById('clientId').textContent = myPubkey.substring(0, 16) + '...';
202
+ document.getElementById('sessionId').textContent = effectiveRoom;
203
+ log(`Joined Nostr room: ${effectiveRoom}`, 'success');
204
+
205
+ updateStatus(true);
206
+
207
+ log('Nostr connection established', 'success');
208
+ } catch (error) {
209
+ log(`Nostr connection error: ${error.message}`, 'error');
210
+ updateStatus(false);
211
+ }
212
+ }
213
+
214
+ async function connectWebRTC() {
150
215
  const serverUrl = document.getElementById('serverUrl').value.trim();
151
216
  const roomId = document.getElementById('roomId').value.trim();
152
217
 
@@ -161,35 +226,35 @@ window.connect = async function() {
161
226
  }
162
227
 
163
228
  try {
164
- log(`Connecting to ${serverUrl}...`, 'info');
229
+ log(`Connecting to ${serverUrl}...`, 'info');
165
230
 
166
231
  // For Cloudflare, use /ws endpoint with room ID query param
167
232
  let finalUrl = serverUrl;
168
233
  if (serverUrl.includes('signal.peer.ooo')) {
169
- finalUrl = `${serverUrl.replace(/\/$/, '')}/ws?room=${roomId}`;
170
- log(`Using Cloudflare Durable Objects with session: ${roomId}`, 'info');
234
+ finalUrl = `${serverUrl.replace(/\/$/, '')}/ws?room=${roomId}`;
235
+ log(`Using Cloudflare Durable Objects with session: ${roomId}`, 'info');
171
236
  }
172
237
 
173
238
  client = new UniWRTCClient(finalUrl, { autoReconnect: false });
174
239
 
175
240
  client.on('connected', (data) => {
176
- log(`Connected with client ID: ${data.clientId}`, 'success');
241
+ log(`Connected with client ID: ${data.clientId}`, 'success');
177
242
  document.getElementById('clientId').textContent = data.clientId;
178
243
  updateStatus(true);
179
244
 
180
245
  // Auto-join the room
181
- log(`Joining session: ${roomId}`, 'info');
246
+ log(`Joining session: ${roomId}`, 'info');
182
247
  client.joinSession(roomId);
183
248
  });
184
249
 
185
250
  client.on('joined', (data) => {
186
- log(`Joined session: ${data.sessionId}`, 'success');
251
+ log(`Joined session: ${data.sessionId}`, 'success');
187
252
  document.getElementById('sessionId').textContent = data.sessionId;
188
253
 
189
254
  if (data.clients && data.clients.length > 0) {
190
- log(`Found ${data.clients.length} existing peers`, 'info');
255
+ log(`Found ${data.clients.length} existing peers`, 'info');
191
256
  data.clients.forEach(peerId => {
192
- log(`Creating connection to existing peer: ${peerId.substring(0, 6)}...`, 'info');
257
+ log(`Creating connection to existing peer: ${peerId.substring(0, 6)}...`, 'info');
193
258
  createPeerConnection(peerId, true);
194
259
  });
195
260
  }
@@ -198,11 +263,11 @@ window.connect = async function() {
198
263
  client.on('peer-joined', (data) => {
199
264
  // Only handle peers in our session
200
265
  if (client.sessionId && data.sessionId !== client.sessionId) {
201
- log(`Ignoring peer from different session: ${data.sessionId}`, 'warning');
266
+ log(`Ignoring peer from different session: ${data.sessionId}`, 'warning');
202
267
  return;
203
268
  }
204
269
 
205
- log(`Peer joined: ${data.peerId.substring(0, 6)}...`, 'success');
270
+ log(`Peer joined: ${data.peerId.substring(0, 6)}...`, 'success');
206
271
 
207
272
  // Wait a bit to ensure both peers are ready
208
273
  setTimeout(() => {
@@ -211,7 +276,7 @@ window.connect = async function() {
211
276
  });
212
277
 
213
278
  client.on('peer-left', (data) => {
214
- log(`Peer left: ${data.peerId.substring(0, 6)}...`, 'warning');
279
+ log(`Peer left: ${data.peerId.substring(0, 6)}...`, 'warning');
215
280
  const pc = peerConnections.get(data.peerId);
216
281
  if (pc) {
217
282
  pc.close();
@@ -222,17 +287,17 @@ window.connect = async function() {
222
287
  });
223
288
 
224
289
  client.on('offer', async (data) => {
225
- log(`Received offer from ${data.peerId.substring(0, 6)}...`, 'info');
290
+ log(`Received offer from ${data.peerId.substring(0, 6)}...`, 'info');
226
291
  const pc = peerConnections.get(data.peerId) || await createPeerConnection(data.peerId, false);
227
292
  await pc.setRemoteDescription(new RTCSessionDescription(data.offer));
228
293
  const answer = await pc.createAnswer();
229
294
  await pc.setLocalDescription(answer);
230
295
  client.sendAnswer(answer, data.peerId);
231
- log(`Sent answer to ${data.peerId.substring(0, 6)}...`, 'success');
296
+ log(`Sent answer to ${data.peerId.substring(0, 6)}...`, 'success');
232
297
  });
233
298
 
234
299
  client.on('answer', async (data) => {
235
- log(`Received answer from ${data.peerId.substring(0, 6)}...`, 'info');
300
+ log(`Received answer from ${data.peerId.substring(0, 6)}...`, 'info');
236
301
  const pc = peerConnections.get(data.peerId);
237
302
  if (pc) {
238
303
  await pc.setRemoteDescription(new RTCSessionDescription(data.answer));
@@ -240,7 +305,7 @@ window.connect = async function() {
240
305
  });
241
306
 
242
307
  client.on('ice-candidate', async (data) => {
243
- log(`Received ICE candidate from ${data.peerId.substring(0, 6)}...`, 'info');
308
+ log(`Received ICE candidate from ${data.peerId.substring(0, 6)}...`, 'info');
244
309
  const pc = peerConnections.get(data.peerId);
245
310
  if (pc && data.candidate) {
246
311
  await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
@@ -257,17 +322,21 @@ window.connect = async function() {
257
322
  });
258
323
 
259
324
  client.on('error', (data) => {
260
- log(`Error: ${data.message}`, 'error');
325
+ log(`Error: ${data.message}`, 'error');
261
326
  });
262
327
 
263
328
  await client.connect();
264
329
  } catch (error) {
265
- log(`Connection error: ${error.message}`, 'error');
330
+ log(`Connection error: ${error.message}`, 'error');
266
331
  updateStatus(false);
267
332
  }
268
- };
333
+ }
269
334
 
270
335
  window.disconnect = function() {
336
+ if (nostrClient) {
337
+ nostrClient.disconnect().catch(() => {});
338
+ nostrClient = null;
339
+ }
271
340
  if (client) {
272
341
  client.disconnect();
273
342
  client = null;
@@ -347,6 +416,15 @@ window.sendChatMessage = function() {
347
416
  return;
348
417
  }
349
418
 
419
+ if (nostrClient) {
420
+ nostrClient.sendMessage(message).catch((e) => {
421
+ log(`Failed to send via Nostr: ${e?.message || e}`, 'error');
422
+ });
423
+ displayChatMessage(message, 'You', true);
424
+ document.getElementById('chatMessage').value = '';
425
+ return;
426
+ }
427
+
350
428
  if (dataChannels.size === 0) {
351
429
  log('No peer connections available. Wait for data channels to open.', 'error');
352
430
  return;
@@ -0,0 +1,243 @@
1
+ import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
2
+
3
+ function bytesToHex(bytes) {
4
+ return Array.from(bytes)
5
+ .map((b) => b.toString(16).padStart(2, '0'))
6
+ .join('');
7
+ }
8
+
9
+ function isHex64(s) {
10
+ return typeof s === 'string' && /^[0-9a-fA-F]{64}$/.test(s);
11
+ }
12
+
13
+ /**
14
+ * Minimal Nostr relay client using raw WebSocket protocol.
15
+ * - Publishes kind:1 events tagged with ['t', room] and ['room', room]
16
+ * - Subscribes to kind:1 events filtered by #t
17
+ */
18
+ export function createNostrClient({ relayUrl, room, onMessage, onPeer, onState } = {}) {
19
+ if (!relayUrl) throw new Error('relayUrl is required');
20
+ if (!room) throw new Error('room is required');
21
+
22
+ const state = {
23
+ relayUrl,
24
+ room,
25
+ ws: null,
26
+ subId: `sub-${room}-${Math.random().toString(36).slice(2, 8)}`,
27
+ secretKeyHex: null,
28
+ pubkey: null,
29
+ seen: new Set(),
30
+ };
31
+
32
+ function ensureKeys() {
33
+ if (state.pubkey && state.secretKeyHex) return;
34
+
35
+ let stored = localStorage.getItem('nostr-secret-key');
36
+ // If stored value looks like an array string from prior buggy storage, clear it
37
+ if (stored && stored.includes(',')) {
38
+ localStorage.removeItem('nostr-secret-key');
39
+ stored = null;
40
+ }
41
+
42
+ if (!isHex64(stored)) {
43
+ const secretBytes = generateSecretKey();
44
+ state.secretKeyHex = bytesToHex(secretBytes);
45
+ localStorage.setItem('nostr-secret-key', state.secretKeyHex);
46
+ } else {
47
+ state.secretKeyHex = stored;
48
+ }
49
+
50
+ state.pubkey = getPublicKey(state.secretKeyHex);
51
+ }
52
+
53
+ function getPublicKeyHex() {
54
+ ensureKeys();
55
+ return state.pubkey;
56
+ }
57
+
58
+ function setState(next) {
59
+ try {
60
+ onState?.(next);
61
+ } catch {
62
+ // ignore
63
+ }
64
+ }
65
+
66
+ function parseIncoming(event) {
67
+ let msg;
68
+ try {
69
+ msg = JSON.parse(event.data);
70
+ } catch {
71
+ return;
72
+ }
73
+
74
+ if (!Array.isArray(msg) || msg.length < 2) return;
75
+ const [type] = msg;
76
+
77
+ if (type === 'EVENT') {
78
+ const nostrEvent = msg[2];
79
+ if (!nostrEvent || typeof nostrEvent !== 'object') return;
80
+ if (nostrEvent.id && state.seen.has(nostrEvent.id)) return;
81
+ if (nostrEvent.id) state.seen.add(nostrEvent.id);
82
+
83
+ // Ensure it's for our room
84
+ const tags = Array.isArray(nostrEvent.tags) ? nostrEvent.tags : [];
85
+ const roomTag = tags.find((t) => Array.isArray(t) && t[0] === 'room');
86
+ if (!roomTag || roomTag[1] !== state.room) return;
87
+
88
+ // Content is JSON
89
+ let payload;
90
+ try {
91
+ payload = JSON.parse(nostrEvent.content);
92
+ } catch {
93
+ return;
94
+ }
95
+
96
+ if (payload?.type === 'peer-join' && payload.peerId) {
97
+ try {
98
+ onPeer?.({ peerId: payload.peerId });
99
+ } catch {
100
+ // ignore
101
+ }
102
+ return;
103
+ }
104
+
105
+ if (payload?.type === 'message' && typeof payload.message === 'string') {
106
+ const from = (payload.peerId || nostrEvent.pubkey || 'peer').substring(0, 8) + '...';
107
+ try {
108
+ onMessage?.({ from, text: payload.message });
109
+ } catch {
110
+ // ignore
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ function sendRaw(frame) {
117
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
118
+ throw new Error('Relay not connected');
119
+ }
120
+ state.ws.send(JSON.stringify(frame));
121
+ }
122
+
123
+ async function connect() {
124
+ ensureKeys();
125
+
126
+ setState('connecting');
127
+
128
+ const ws = new WebSocket(state.relayUrl);
129
+ state.ws = ws;
130
+
131
+ await new Promise((resolve, reject) => {
132
+ const onOpen = () => {
133
+ cleanup();
134
+ resolve();
135
+ };
136
+ const onError = () => {
137
+ cleanup();
138
+ reject(new Error('Failed to connect to relay'));
139
+ };
140
+ const cleanup = () => {
141
+ ws.removeEventListener('open', onOpen);
142
+ ws.removeEventListener('error', onError);
143
+ };
144
+ ws.addEventListener('open', onOpen);
145
+ ws.addEventListener('error', onError);
146
+ });
147
+
148
+ ws.addEventListener('message', parseIncoming);
149
+ ws.addEventListener('close', () => setState('disconnected'));
150
+
151
+ // Subscribe to this room (topic-tag filtered)
152
+ const now = Math.floor(Date.now() / 1000);
153
+ const filter = {
154
+ kinds: [1],
155
+ '#t': [state.room],
156
+ since: now - 3600,
157
+ limit: 200,
158
+ };
159
+
160
+ sendRaw(['REQ', state.subId, filter]);
161
+
162
+ // Announce presence
163
+ await sendJoin();
164
+
165
+ setState('connected');
166
+ }
167
+
168
+ async function sendJoin() {
169
+ ensureKeys();
170
+ const created_at = Math.floor(Date.now() / 1000);
171
+ const tags = [
172
+ ['room', state.room],
173
+ ['t', state.room],
174
+ ];
175
+
176
+ const eventTemplate = {
177
+ kind: 1,
178
+ created_at,
179
+ tags,
180
+ content: JSON.stringify({
181
+ type: 'peer-join',
182
+ peerId: state.pubkey,
183
+ room: state.room,
184
+ timestamp: Date.now(),
185
+ }),
186
+ pubkey: state.pubkey,
187
+ };
188
+
189
+ const signed = finalizeEvent(eventTemplate, state.secretKeyHex);
190
+ sendRaw(['EVENT', signed]);
191
+ }
192
+
193
+ async function sendMessage(text) {
194
+ ensureKeys();
195
+ const created_at = Math.floor(Date.now() / 1000);
196
+ const tags = [
197
+ ['room', state.room],
198
+ ['t', state.room],
199
+ ];
200
+
201
+ const eventTemplate = {
202
+ kind: 1,
203
+ created_at,
204
+ tags,
205
+ content: JSON.stringify({
206
+ type: 'message',
207
+ message: text,
208
+ peerId: state.pubkey,
209
+ room: state.room,
210
+ timestamp: Date.now(),
211
+ }),
212
+ pubkey: state.pubkey,
213
+ };
214
+
215
+ const signed = finalizeEvent(eventTemplate, state.secretKeyHex);
216
+ sendRaw(['EVENT', signed]);
217
+ }
218
+
219
+ async function disconnect() {
220
+ const ws = state.ws;
221
+ state.ws = null;
222
+
223
+ try {
224
+ if (ws && ws.readyState === WebSocket.OPEN) {
225
+ try {
226
+ ws.send(JSON.stringify(['CLOSE', state.subId]));
227
+ } catch {
228
+ // ignore
229
+ }
230
+ ws.close();
231
+ }
232
+ } finally {
233
+ setState('disconnected');
234
+ }
235
+ }
236
+
237
+ return {
238
+ connect,
239
+ disconnect,
240
+ sendMessage,
241
+ getPublicKey: getPublicKeyHex,
242
+ };
243
+ }
@@ -0,0 +1,6 @@
1
+ export function useNostr() {
2
+ throw new Error(
3
+ 'useNostr() (Vue composable) is not wired into this demo. ' +
4
+ 'Use createNostrClient() from src/nostr/nostrClient.js instead.'
5
+ );
6
+ }
@@ -0,0 +1,6 @@
1
+ export function useWebRTC() {
2
+ throw new Error(
3
+ 'useWebRTC() (Vue composable) is not wired into this demo. ' +
4
+ 'The demo page uses src/main.js and (optionally) src/nostr/nostrClient.js.'
5
+ );
6
+ }
@@ -0,0 +1,129 @@
1
+ import { subscribe } from 'nostr-tools/relay';
2
+ import { SimplePool } from 'nostr-tools/pool';
3
+
4
+ // List of public Nostr relays
5
+ const DEFAULT_RELAYS = [
6
+ 'wss://relay.damus.io',
7
+ 'wss://relay.nostr.band',
8
+ 'wss://nostr.wine',
9
+ 'wss://relay.current.fyi'
10
+ ];
11
+
12
+ let pool = new SimplePool();
13
+ let relayConnections = new Map();
14
+ let subscriptions = new Map();
15
+
16
+ /**
17
+ * Add or connect to relays
18
+ */
19
+ export async function addRelays(relayUrls = DEFAULT_RELAYS) {
20
+ for (const url of relayUrls) {
21
+ if (!relayConnections.has(url)) {
22
+ try {
23
+ await pool.ensureRelay(url);
24
+ relayConnections.set(url, true);
25
+ console.log(`Connected to relay: ${url}`);
26
+ } catch (error) {
27
+ console.error(`Failed to connect to relay ${url}:`, error);
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Ensure relay connection is established
35
+ */
36
+ export async function ensureRelayConnection(relayUrl = DEFAULT_RELAYS[0]) {
37
+ if (!relayConnections.has(relayUrl)) {
38
+ await addRelays([relayUrl]);
39
+ }
40
+ return relayConnections.get(relayUrl);
41
+ }
42
+
43
+ /**
44
+ * Crawl available relays
45
+ */
46
+ export async function crawlRelays() {
47
+ return DEFAULT_RELAYS;
48
+ }
49
+
50
+ /**
51
+ * Ensure connections to multiple relays
52
+ */
53
+ export async function ensureConnections(relayUrls = DEFAULT_RELAYS) {
54
+ await addRelays(relayUrls);
55
+ }
56
+
57
+ /**
58
+ * Publish an event to all connected relays
59
+ */
60
+ export async function publishEvent(event) {
61
+ const relayUrls = Array.from(relayConnections.keys());
62
+ if (relayUrls.length === 0) {
63
+ await addRelays();
64
+ }
65
+
66
+ try {
67
+ const publishPromises = Array.from(relayConnections.keys()).map(url =>
68
+ pool.publish(url, event).catch(err => {
69
+ console.error(`Failed to publish to ${url}:`, err);
70
+ })
71
+ );
72
+ await Promise.all(publishPromises);
73
+ console.log('Event published to all relays');
74
+ } catch (error) {
75
+ console.error('Error publishing event:', error);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Subscribe to events with a filter
81
+ */
82
+ export async function subscribeToEvents(filter, onEvent, subscriptionId = 'default') {
83
+ if (relayConnections.size === 0) {
84
+ await addRelays();
85
+ }
86
+
87
+ try {
88
+ const relayUrls = Array.from(relayConnections.keys());
89
+
90
+ const subscription = pool.subscribeMany(relayUrls, [filter], {
91
+ onevent: (event) => onEvent(event),
92
+ onclose: () => console.log(`Subscription ${subscriptionId} closed`),
93
+ oneose: () => console.log(`Subscription ${subscriptionId} received all events`)
94
+ });
95
+
96
+ subscriptions.set(subscriptionId, subscription);
97
+ console.log(`Subscribed with filter:`, filter);
98
+ return subscriptionId;
99
+ } catch (error) {
100
+ console.error('Error subscribing to events:', error);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Unsubscribe from events
106
+ */
107
+ export async function unsubscribeFromEvents(subscriptionId = 'default') {
108
+ const subscription = subscriptions.get(subscriptionId);
109
+ if (subscription) {
110
+ subscription.close();
111
+ subscriptions.delete(subscriptionId);
112
+ console.log(`Unsubscribed: ${subscriptionId}`);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Close all relay connections
118
+ */
119
+ export async function closeAllConnections() {
120
+ subscriptions.forEach(sub => sub.close());
121
+ subscriptions.clear();
122
+
123
+ relayConnections.forEach((_, url) => {
124
+ pool.close(url);
125
+ });
126
+ relayConnections.clear();
127
+
128
+ console.log('Closed all relay connections');
129
+ }