0x0-cli 1.0.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/src/web/api.js ADDED
@@ -0,0 +1,375 @@
1
+ import { randomUUID } from 'crypto'
2
+ import Hyperswarm from 'hyperswarm'
3
+ import { channelSecret } from '../core/channel.js'
4
+ import { createMessage, createFileMessage, parseMessage } from '../core/message.js'
5
+ import { generatePin } from '../core/pin.js'
6
+ import * as identityStore from '../storage/identity.js'
7
+ import * as pinsStore from '../storage/pins.js'
8
+ import * as messagesStore from '../storage/messages.js'
9
+ import * as contactsStore from '../storage/contacts.js'
10
+
11
+ export function createApiHandler(ws) {
12
+ const connections = new Map() // pinId → { swarm, conn }
13
+ const contactConnections = new Map() // contactId → { swarm, conn }
14
+ const messageQueue = new Map() // pinId → [{ id, content, timestamp }]
15
+
16
+ function sendEvent(event, payload = {}) {
17
+ if (ws.readyState !== ws.OPEN) return
18
+ ws.send(JSON.stringify({ event, ...payload }))
19
+ }
20
+
21
+ // 接続確立時に初期データを送信
22
+ const identity = identityStore.load()
23
+ if (identity) {
24
+ sendEvent('init', { data: {
25
+ number: identity.number,
26
+ inbox: buildInbox(),
27
+ contacts: buildContacts(),
28
+ prefs: { theme: 'dark' }
29
+ }})
30
+ }
31
+
32
+ ws.on('message', async (raw) => {
33
+ let msg
34
+ try { msg = JSON.parse(raw.toString()) } catch { return }
35
+ await dispatch(msg)
36
+ })
37
+
38
+ ws.on('close', async () => {
39
+ for (const { swarm } of connections.values()) {
40
+ await swarm.destroy().catch(() => {})
41
+ }
42
+ for (const { swarm } of contactConnections.values()) {
43
+ await swarm.destroy().catch(() => {})
44
+ }
45
+ connections.clear()
46
+ contactConnections.clear()
47
+ })
48
+
49
+ async function dispatch(msg) {
50
+ const { cmd, pinId, content, label, expiry, key, value } = msg
51
+
52
+ switch (cmd) {
53
+ case 'inbox.list':
54
+ sendEvent('inbox.list', { data: buildInbox() })
55
+ break
56
+
57
+ case 'messages.list': {
58
+ const pin = pinsStore.findById(pinId)
59
+ if (!pin) return
60
+ const msgs = messagesStore.list(pin.value)
61
+ sendEvent('messages.list', { pinId, data: msgs })
62
+ // まだ接続していなければ開始
63
+ if (!connections.has(pinId)) await openConnection(pin)
64
+ break
65
+ }
66
+
67
+ case 'message.send': {
68
+ const pin = pinsStore.findById(pinId)
69
+ if (!pin) return
70
+ const localId = msg.localId || randomUUID()
71
+ const entry = connections.get(pinId)
72
+ if (!entry?.conn) {
73
+ const pending = messageQueue.get(pinId) || []
74
+ pending.push({ id: localId, content, timestamp: Date.now() })
75
+ messageQueue.set(pinId, pending)
76
+ sendEvent('message.queued', { pinId, localId })
77
+ break
78
+ }
79
+ entry.conn.write(createMessage(content))
80
+ const senderIdentity = identityStore.load()
81
+ messagesStore.append(pin.value, { from: senderIdentity.number, content, isMine: true })
82
+ sendEvent('message.sent', { pinId, localId, data: { content, isMine: true, timestamp: Date.now(), status: 'delivered' } })
83
+ break
84
+ }
85
+
86
+ case 'pin.create': {
87
+ const { expiry: exp = 'none', expiresAt = null } = parseExpiry(expiry)
88
+ const value = generatePin()
89
+ const pin = pinsStore.create({ value, label: label || '', expiry: exp, expiresAt })
90
+ sendEvent('pin.created', { data: pin })
91
+ sendEvent('inbox.list', { data: buildInbox() })
92
+ break
93
+ }
94
+
95
+ case 'pin.rotate': {
96
+ const pin = pinsStore.findById(pinId)
97
+ if (!pin) return
98
+ pinsStore.rotate(pin.id, generatePin())
99
+ await destroyConnection(pinId)
100
+ sendEvent('pin.rotated', { pinId, data: pinsStore.findById(pinId) })
101
+ sendEvent('inbox.list', { data: buildInbox() })
102
+ break
103
+ }
104
+
105
+ case 'pin.revoke': {
106
+ const pin = pinsStore.findById(pinId)
107
+ if (!pin) return
108
+ pinsStore.revoke(pin.id)
109
+ await destroyConnection(pinId)
110
+ sendEvent('pin.revoked', { pinId })
111
+ sendEvent('inbox.list', { data: buildInbox() })
112
+ break
113
+ }
114
+
115
+ case 'number.renew': {
116
+ const identity = identityStore.load()
117
+ if (!identity) return
118
+ const pins = pinsStore.getActive()
119
+ for (const p of pins) pinsStore.revoke(p.id)
120
+ for (const { swarm } of connections.values()) await swarm.destroy().catch(() => {})
121
+ connections.clear()
122
+ const { generateNumber } = await import('../core/identity.js')
123
+ const newNumber = generateNumber()
124
+ identityStore.save({ ...identity, number: newNumber, renewedAt: Date.now() })
125
+ sendEvent('number.renewed', { data: { number: newNumber } })
126
+ break
127
+ }
128
+
129
+ case 'prefs.set':
130
+ // 将来: プリファレンス保存
131
+ break
132
+
133
+ case 'notify.register': {
134
+ const { token, platform } = msg
135
+ if (!token || !platform) break
136
+ const notifyIdentity = identityStore.load()
137
+ if (!notifyIdentity) break
138
+ await fetch('https://0x0-notification.tiidatech.workers.dev/register', {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({ number: notifyIdentity.number, token, platform })
142
+ }).catch(() => {})
143
+ sendEvent('notify.registered', {})
144
+ break
145
+ }
146
+
147
+ case 'notify.unregister': {
148
+ const unregIdentity = identityStore.load()
149
+ if (!unregIdentity) break
150
+ await fetch('https://0x0-notification.tiidatech.workers.dev/register', {
151
+ method: 'DELETE',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify({ number: unregIdentity.number })
154
+ }).catch(() => {})
155
+ sendEvent('notify.unregistered', {})
156
+ break
157
+ }
158
+
159
+ case 'file.send': {
160
+ const { filename, mimeType, dataBase64 } = msg
161
+ const filePin = pinsStore.findById(pinId)
162
+ if (!filePin) break
163
+ const fileEntry = connections.get(pinId)
164
+ if (!fileEntry?.conn) break
165
+ fileEntry.conn.write(createFileMessage(filename, mimeType, dataBase64))
166
+ const fileIdentity = identityStore.load()
167
+ messagesStore.append(filePin.value, {
168
+ from: fileIdentity.number, type: 'file', filename, mimeType, isMine: true
169
+ })
170
+ sendEvent('file.sent', { pinId, data: { filename, mimeType, isMine: true, timestamp: Date.now() } })
171
+ break
172
+ }
173
+
174
+ case 'chat.start': {
175
+ const { theirNumber, theirPin, label: cLabel } = msg
176
+ if (!theirNumber || !theirPin) return
177
+ const contact = contactsStore.create({ theirNumber, theirPin, label: cLabel || '' })
178
+ sendEvent('chat.started', { contactId: contact.id, data: contact })
179
+ sendEvent('contacts.list', { data: buildContacts() })
180
+ const cMsgs = messagesStore.list(contact.theirPin)
181
+ sendEvent('contact.messages.list', { contactId: contact.id, data: cMsgs })
182
+ if (!contactConnections.has(contact.id)) await openContactConnection(contact)
183
+ break
184
+ }
185
+
186
+ case 'contact.message.send': {
187
+ const { contactId, content: cContent } = msg
188
+ const contact = contactsStore.findById(contactId)
189
+ if (!contact) return
190
+ const cEntry = contactConnections.get(contactId)
191
+ if (!cEntry?.conn) return
192
+ cEntry.conn.write(createMessage(cContent))
193
+ const me = identityStore.load()
194
+ messagesStore.append(contact.theirPin, { from: me.number, content: cContent, isMine: true })
195
+ sendEvent('contact.message.sent', { contactId, data: { content: cContent, isMine: true, timestamp: Date.now() } })
196
+ // 相手に通知を送る(バックグラウンド、失敗は無視)
197
+ fetch('https://0x0-notification.tiidatech.workers.dev/notify', {
198
+ method: 'POST',
199
+ headers: { 'Content-Type': 'application/json' },
200
+ body: JSON.stringify({ recipientNumber: contact.theirNumber })
201
+ }).catch(() => {})
202
+ break
203
+ }
204
+
205
+ case 'contacts.list':
206
+ sendEvent('contacts.list', { data: buildContacts() })
207
+ break
208
+
209
+ case 'contact.label': {
210
+ const { contactId, label: newLabel } = msg
211
+ contactsStore.updateLabel(contactId, newLabel || '')
212
+ sendEvent('contacts.list', { data: buildContacts() })
213
+ break
214
+ }
215
+
216
+ case 'contact.remove': {
217
+ const { contactId: removeId } = msg
218
+ if (contactConnections.has(removeId)) {
219
+ const { swarm } = contactConnections.get(removeId)
220
+ await swarm.destroy().catch(() => {})
221
+ contactConnections.delete(removeId)
222
+ }
223
+ contactsStore.remove(removeId)
224
+ sendEvent('contact.removed', { contactId: removeId })
225
+ sendEvent('contacts.list', { data: buildContacts() })
226
+ break
227
+ }
228
+ }
229
+ }
230
+
231
+ async function openConnection(pin) {
232
+ const identity = identityStore.load()
233
+ if (!identity) return
234
+
235
+ const swarm = new Hyperswarm()
236
+ const topic = channelSecret(identity.number, pin.value)
237
+ connections.set(pin.id, { swarm, conn: null })
238
+
239
+ sendEvent('peer.status', { pinId: pin.id, status: 'connecting' })
240
+
241
+ swarm.join(topic, { server: true, client: true })
242
+
243
+ swarm.on('connection', (conn) => {
244
+ const entry = connections.get(pin.id)
245
+ if (entry) entry.conn = conn
246
+
247
+ sendEvent('peer.status', { pinId: pin.id, status: 'connected' })
248
+ flushMessageQueue(pin, conn)
249
+
250
+ conn.on('data', (data) => {
251
+ const msg = parseMessage(data)
252
+ if (!msg) return
253
+
254
+ if (msg.type === 'message') {
255
+ messagesStore.append(pin.value, {
256
+ from: 'peer', content: msg.content, isMine: false
257
+ })
258
+ sendEvent('message.received', {
259
+ pinId: pin.id,
260
+ data: { content: msg.content, isMine: false, timestamp: Date.now() }
261
+ })
262
+ } else if (msg.type === 'file') {
263
+ messagesStore.append(pin.value, {
264
+ from: 'peer', type: 'file', filename: msg.filename, mimeType: msg.mimeType, isMine: false
265
+ })
266
+ sendEvent('file.received', {
267
+ pinId: pin.id,
268
+ data: {
269
+ filename: msg.filename,
270
+ mimeType: msg.mimeType,
271
+ dataBase64: msg.data,
272
+ isMine: false,
273
+ timestamp: Date.now()
274
+ }
275
+ })
276
+ }
277
+ })
278
+
279
+ conn.on('close', () => {
280
+ const entry = connections.get(pin.id)
281
+ if (entry) entry.conn = null
282
+ sendEvent('peer.status', { pinId: pin.id, status: 'disconnected' })
283
+ })
284
+ })
285
+ }
286
+
287
+ async function destroyConnection(pinId) {
288
+ if (!connections.has(pinId)) return
289
+ const { swarm } = connections.get(pinId)
290
+ await swarm.destroy().catch(() => {})
291
+ connections.delete(pinId)
292
+ }
293
+
294
+ function flushMessageQueue(pin, conn) {
295
+ const pending = messageQueue.get(pin.id)
296
+ if (!pending || pending.length === 0) return
297
+ const senderIdentity = identityStore.load()
298
+ for (const queued of pending) {
299
+ conn.write(createMessage(queued.content))
300
+ if (senderIdentity) messagesStore.append(pin.value, { from: senderIdentity.number, content: queued.content, isMine: true })
301
+ sendEvent('message.delivered', { pinId: pin.id, localId: queued.id })
302
+ }
303
+ messageQueue.delete(pin.id)
304
+ }
305
+
306
+ function buildInbox() {
307
+ const pins = pinsStore.getActive()
308
+ return pins.map(pin => {
309
+ const msgs = messagesStore.list(pin.value)
310
+ const latest = msgs[msgs.length - 1] || null
311
+ const unread = msgs.filter(m => !m.isMine).length
312
+ return { ...pin, messageCount: msgs.length, unread, latest }
313
+ })
314
+ }
315
+
316
+ function buildContacts() {
317
+ return contactsStore.loadAll().map(c => {
318
+ const msgs = messagesStore.list(c.theirPin)
319
+ const latest = msgs[msgs.length - 1] || null
320
+ const unread = msgs.filter(m => !m.isMine).length
321
+ return { ...c, messageCount: msgs.length, unread, latest }
322
+ })
323
+ }
324
+
325
+ async function openContactConnection(contact) {
326
+ const swarm = new Hyperswarm()
327
+ const topic = channelSecret(contact.theirNumber, contact.theirPin)
328
+ contactConnections.set(contact.id, { swarm, conn: null })
329
+
330
+ sendEvent('peer.status', { contactId: contact.id, status: 'connecting' })
331
+
332
+ swarm.join(topic, { server: false, client: true })
333
+
334
+ swarm.on('connection', (conn) => {
335
+ const entry = contactConnections.get(contact.id)
336
+ if (entry) entry.conn = conn
337
+
338
+ sendEvent('peer.status', { contactId: contact.id, status: 'connected' })
339
+
340
+ conn.on('data', (data) => {
341
+ const msg = parseMessage(data)
342
+ if (!msg || msg.type !== 'message') return
343
+
344
+ messagesStore.append(contact.theirPin, {
345
+ from: contact.theirNumber, content: msg.content, isMine: false
346
+ })
347
+
348
+ sendEvent('contact.message.received', {
349
+ contactId: contact.id,
350
+ data: { content: msg.content, isMine: false, timestamp: Date.now() }
351
+ })
352
+ })
353
+
354
+ conn.on('error', () => {})
355
+
356
+ conn.on('close', () => {
357
+ const entry = contactConnections.get(contact.id)
358
+ if (entry) entry.conn = null
359
+ sendEvent('peer.status', { contactId: contact.id, status: 'disconnected' })
360
+ })
361
+ })
362
+ }
363
+ }
364
+
365
+ function parseExpiry(expires) {
366
+ if (!expires || expires === 'none') return { expiry: 'none', expiresAt: null }
367
+ const match = expires.match(/^(\d+)(h|d|w)$/)
368
+ if (!match) return { expiry: expires, expiresAt: null }
369
+ const num = parseInt(match[1])
370
+ const unit = match[2]
371
+ const ms = unit === 'h' ? num * 3_600_000
372
+ : unit === 'd' ? num * 86_400_000
373
+ : num * 7 * 86_400_000
374
+ return { expiry: expires, expiresAt: Date.now() + ms }
375
+ }
@@ -0,0 +1,52 @@
1
+ import http from 'http'
2
+ import os from 'os'
3
+ import path from 'path'
4
+ import { fileURLToPath } from 'url'
5
+ import express from 'express'
6
+ import { WebSocketServer } from 'ws'
7
+ import { createApiHandler } from './api.js'
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
10
+ const DIST = path.resolve(__dirname, '../../dist/web')
11
+
12
+ export async function startWebServer(port = 3000) {
13
+ const app = express()
14
+
15
+ // ビルド済みフロントエンド配信
16
+ app.use(express.static(DIST))
17
+ app.get('*', (_req, res) => res.sendFile(path.join(DIST, 'index.html')))
18
+
19
+ const server = http.createServer(app)
20
+
21
+ // WebSocket: LAN 全体から受け付ける
22
+ const wss = new WebSocketServer({ server })
23
+ wss.on('connection', (ws) => {
24
+ createApiHandler(ws)
25
+ })
26
+
27
+ return new Promise((resolve, reject) => {
28
+ server.listen(port, '0.0.0.0', () => {
29
+ resolve({ server, wss, port })
30
+ })
31
+ server.on('error', (err) => {
32
+ if (err.code === 'EADDRINUSE') {
33
+ resolve(startWebServer(port + 1))
34
+ } else {
35
+ reject(err)
36
+ }
37
+ })
38
+ })
39
+ }
40
+
41
+ export function getLanIps() {
42
+ const interfaces = os.networkInterfaces()
43
+ const ips = []
44
+ for (const iface of Object.values(interfaces)) {
45
+ for (const entry of iface ?? []) {
46
+ if (entry.family === 'IPv4' && !entry.internal) {
47
+ ips.push(entry.address)
48
+ }
49
+ }
50
+ }
51
+ return ips
52
+ }