@dtudury/streamo 0.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.
- package/.claude/settings.local.json +10 -0
- package/LICENSE +661 -0
- package/README.md +194 -0
- package/ROADMAP.md +111 -0
- package/bin/streamo.js +238 -0
- package/jsconfig.json +9 -0
- package/package.json +26 -0
- package/public/apps/chat/index.html +61 -0
- package/public/apps/chat/main.js +144 -0
- package/public/apps/styles/proto.css +71 -0
- package/public/index.html +109 -0
- package/public/streamo/Addressifier.js +212 -0
- package/public/streamo/CodecRegistry.js +195 -0
- package/public/streamo/ContentMap.js +79 -0
- package/public/streamo/DESIGN.md +61 -0
- package/public/streamo/Repo.js +176 -0
- package/public/streamo/Repo.test.js +82 -0
- package/public/streamo/RepoRegistry.js +91 -0
- package/public/streamo/RepoRegistry.test.js +87 -0
- package/public/streamo/Signature.js +15 -0
- package/public/streamo/Signer.js +91 -0
- package/public/streamo/Streamo.js +392 -0
- package/public/streamo/Streamo.test.js +205 -0
- package/public/streamo/archiveSync.js +62 -0
- package/public/streamo/chat-cli.js +122 -0
- package/public/streamo/chat-server.js +60 -0
- package/public/streamo/codecs.js +400 -0
- package/public/streamo/fileSync.js +238 -0
- package/public/streamo/h.js +202 -0
- package/public/streamo/h.mount.test.js +67 -0
- package/public/streamo/h.test.js +121 -0
- package/public/streamo/mount.js +248 -0
- package/public/streamo/originSync.js +60 -0
- package/public/streamo/outletSync.js +105 -0
- package/public/streamo/registrySync.js +333 -0
- package/public/streamo/registrySync.test.js +373 -0
- package/public/streamo/s3Sync.js +99 -0
- package/public/streamo/stateFileSync.js +17 -0
- package/public/streamo/sync.test.js +98 -0
- package/public/streamo/utils/NestedSet.js +41 -0
- package/public/streamo/utils/Recaller.js +77 -0
- package/public/streamo/utils/mockDOM.js +113 -0
- package/public/streamo/utils/nextTick.js +22 -0
- package/public/streamo/utils/noble-secp256k1.js +602 -0
- package/public/streamo/utils/testing.js +90 -0
- package/public/streamo/utils.js +57 -0
- package/public/streamo/webSync.js +118 -0
- package/scripts/serve.js +15 -0
- package/smoke.test.js +132 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Signer } from '/streamo/Signer.js'
|
|
2
|
+
import { RepoRegistry } from '/streamo/RepoRegistry.js'
|
|
3
|
+
import { registrySync } from '/streamo/registrySync.js'
|
|
4
|
+
import { bytesToHex } from '/streamo/utils.js'
|
|
5
|
+
|
|
6
|
+
const loginEl = document.getElementById('login')
|
|
7
|
+
const chatEl = document.getElementById('chat')
|
|
8
|
+
const statusEl = document.getElementById('status')
|
|
9
|
+
const myNameEl = document.getElementById('my-name')
|
|
10
|
+
const msgsEl = document.getElementById('messages')
|
|
11
|
+
const inputEl = document.getElementById('msg-input')
|
|
12
|
+
const sendBtn = document.getElementById('send-btn')
|
|
13
|
+
const joinBtn = document.getElementById('join-btn')
|
|
14
|
+
|
|
15
|
+
const { rootKey } = await fetch('/api/chat-info').then(r => r.json())
|
|
16
|
+
|
|
17
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function fmt (ts) {
|
|
20
|
+
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Rendering ──────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
let myKey = null
|
|
26
|
+
|
|
27
|
+
/** Flat list of { name, text, at, mine } sorted by `at` */
|
|
28
|
+
function collectMessages (registry) {
|
|
29
|
+
const all = []
|
|
30
|
+
for (const [keyHex, repo] of registry) {
|
|
31
|
+
const name = repo.get('name')
|
|
32
|
+
const messages = repo.get('messages') ?? []
|
|
33
|
+
for (const msg of messages) {
|
|
34
|
+
const text = typeof msg === 'string' ? msg : msg?.text ?? String(msg)
|
|
35
|
+
const at = msg?.at ?? 0
|
|
36
|
+
all.push({ name, text, at, mine: keyHex === myKey })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
all.sort((a, b) => a.at - b.at)
|
|
40
|
+
return all
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let rendered = 0
|
|
44
|
+
|
|
45
|
+
function renderMessages (registry) {
|
|
46
|
+
const all = collectMessages(registry)
|
|
47
|
+
// Only append new messages (simple: clear + rebuild if out of order, else append)
|
|
48
|
+
if (all.length < rendered) {
|
|
49
|
+
msgsEl.innerHTML = ''
|
|
50
|
+
rendered = 0
|
|
51
|
+
}
|
|
52
|
+
for (let i = rendered; i < all.length; i++) {
|
|
53
|
+
const { name, text, at, mine } = all[i]
|
|
54
|
+
const div = document.createElement('div')
|
|
55
|
+
div.className = `msg ${mine ? 'mine' : 'theirs'}`
|
|
56
|
+
div.innerHTML = `
|
|
57
|
+
${!mine ? `<div class="sender">${escHtml(name)}</div>` : ''}
|
|
58
|
+
<div class="text">${escHtml(text)}</div>
|
|
59
|
+
<div class="time">${fmt(at)}</div>
|
|
60
|
+
`
|
|
61
|
+
msgsEl.appendChild(div)
|
|
62
|
+
}
|
|
63
|
+
rendered = all.length
|
|
64
|
+
if (all.length > rendered - 1) msgsEl.scrollTop = msgsEl.scrollHeight
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function escHtml (s) {
|
|
68
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Join ───────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
joinBtn.onclick = async () => {
|
|
74
|
+
const username = document.getElementById('username').value.trim()
|
|
75
|
+
const password = document.getElementById('password').value.trim()
|
|
76
|
+
if (!username || !password) { statusEl.textContent = 'enter username and password'; return }
|
|
77
|
+
|
|
78
|
+
joinBtn.disabled = true
|
|
79
|
+
statusEl.textContent = 'connecting…'
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const signer = new Signer(username, password, 1)
|
|
83
|
+
const { publicKey } = await signer.keysFor('chat')
|
|
84
|
+
myKey = bytesToHex(publicKey)
|
|
85
|
+
|
|
86
|
+
const registry = new RepoRegistry()
|
|
87
|
+
|
|
88
|
+
const session = await registrySync(registry, location.hostname, Number(location.port) || 80, {
|
|
89
|
+
filter: k => k === rootKey,
|
|
90
|
+
follow: (keyHex, repo, subscribe) => {
|
|
91
|
+
for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
|
|
92
|
+
},
|
|
93
|
+
onAnnounce: (key) => {
|
|
94
|
+
session.subscribe(key)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// Open own repo
|
|
99
|
+
const myRepo = await registry.open(myKey)
|
|
100
|
+
if (!myRepo.get('name')) {
|
|
101
|
+
myRepo.set({ name: username, messages: [] })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
session.interest(rootKey)
|
|
105
|
+
session.announce(myKey, rootKey)
|
|
106
|
+
|
|
107
|
+
// Switch to chat view
|
|
108
|
+
loginEl.style.display = 'none'
|
|
109
|
+
chatEl.style.display = 'flex'
|
|
110
|
+
myNameEl.textContent = `(${username})`
|
|
111
|
+
|
|
112
|
+
// Reactive rendering: re-render on any repo change
|
|
113
|
+
function watchRepo (keyHex, repo) {
|
|
114
|
+
repo.watch(`chat-render:${keyHex}`, () => renderMessages(registry))
|
|
115
|
+
}
|
|
116
|
+
for (const [k, r] of registry) watchRepo(k, r)
|
|
117
|
+
registry.onOpen((keyHex, repo) => {
|
|
118
|
+
watchRepo(keyHex, repo)
|
|
119
|
+
renderMessages(registry)
|
|
120
|
+
})
|
|
121
|
+
renderMessages(registry)
|
|
122
|
+
|
|
123
|
+
// ── Send ────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
async function sendMessage () {
|
|
126
|
+
const text = inputEl.value.trim()
|
|
127
|
+
if (!text) return
|
|
128
|
+
inputEl.value = ''
|
|
129
|
+
const messages = myRepo.get('messages') ?? []
|
|
130
|
+
myRepo.set({ name: username, messages: [...messages, { text, at: Date.now() }] })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
sendBtn.onclick = sendMessage
|
|
134
|
+
inputEl.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() } }
|
|
135
|
+
inputEl.focus()
|
|
136
|
+
|
|
137
|
+
} catch (e) {
|
|
138
|
+
statusEl.textContent = `error: ${e.message}`
|
|
139
|
+
joinBtn.disabled = false
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
document.getElementById('username').onkeydown = e => { if (e.key === 'Enter') document.getElementById('password').focus() }
|
|
144
|
+
document.getElementById('password').onkeydown = e => { if (e.key === 'Enter') joinBtn.click() }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* proto.css — shared prototype stylesheet
|
|
3
|
+
*
|
|
4
|
+
* Deliberately rough. Use this so design bikeshedding doesn't happen.
|
|
5
|
+
* Replace per-app when the app is ready to grow up.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
:root {
|
|
9
|
+
--ink: #1c1917;
|
|
10
|
+
--ink-dim: #78716c;
|
|
11
|
+
--paper: #fefdf8;
|
|
12
|
+
--rule: #e7e5e0;
|
|
13
|
+
--accent: #2563eb;
|
|
14
|
+
--warn: #ca8a04;
|
|
15
|
+
--flash: #fef08a;
|
|
16
|
+
--radius: 2px 8px 3px 7px / 7px 3px 8px 2px;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
20
|
+
|
|
21
|
+
body {
|
|
22
|
+
font-family: cursive;
|
|
23
|
+
background: var(--paper);
|
|
24
|
+
color: var(--ink);
|
|
25
|
+
padding: 1.25rem;
|
|
26
|
+
line-height: 1.5;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* data / code always monospace */
|
|
30
|
+
code, pre, .mono { font-family: monospace; }
|
|
31
|
+
|
|
32
|
+
/* hand-drawn border feel */
|
|
33
|
+
.box {
|
|
34
|
+
border: 1.5px solid var(--ink);
|
|
35
|
+
border-radius: var(--radius);
|
|
36
|
+
box-shadow: 2px 3px 0 var(--ink);
|
|
37
|
+
padding: 0.75rem 1rem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* status pill */
|
|
41
|
+
.pill {
|
|
42
|
+
font-family: cursive;
|
|
43
|
+
font-size: 0.75rem;
|
|
44
|
+
padding: 0.15rem 0.6rem;
|
|
45
|
+
border: 1.5px solid currentColor;
|
|
46
|
+
border-radius: 999px;
|
|
47
|
+
}
|
|
48
|
+
.pill.ok { color: #16a34a; }
|
|
49
|
+
.pill.err { color: #dc2626; }
|
|
50
|
+
|
|
51
|
+
/* flash animation — add .flash class, it auto-expires */
|
|
52
|
+
@keyframes proto-flash {
|
|
53
|
+
0% { background-color: var(--flash); }
|
|
54
|
+
100% { background-color: transparent; }
|
|
55
|
+
}
|
|
56
|
+
.flash { animation: proto-flash 0.7s ease-out; }
|
|
57
|
+
|
|
58
|
+
/* divider */
|
|
59
|
+
hr { border: none; border-top: 1.5px dashed var(--rule); margin: 0.75rem 0; }
|
|
60
|
+
|
|
61
|
+
/* muted / helper text */
|
|
62
|
+
.dim { color: var(--ink-dim); font-size: 0.8rem; }
|
|
63
|
+
|
|
64
|
+
/* inline code snippet */
|
|
65
|
+
.snippet {
|
|
66
|
+
font-family: monospace;
|
|
67
|
+
font-size: 0.85rem;
|
|
68
|
+
background: var(--rule);
|
|
69
|
+
border-radius: 3px;
|
|
70
|
+
padding: 0.1em 0.35em;
|
|
71
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>streamo</title>
|
|
7
|
+
<link rel="stylesheet" href="/apps/styles/proto.css">
|
|
8
|
+
<style>
|
|
9
|
+
body { max-width: 44rem; margin: 0 auto; padding: 2.5rem 1.25rem; }
|
|
10
|
+
|
|
11
|
+
.wordmark { font-size: 2.4rem; letter-spacing: -0.02em; margin-bottom: 0.15rem; }
|
|
12
|
+
.tagline { color: var(--ink-dim); font-size: 0.95rem; margin-bottom: 2rem; }
|
|
13
|
+
|
|
14
|
+
.ideas {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: 0.6rem;
|
|
18
|
+
margin-bottom: 2.5rem;
|
|
19
|
+
}
|
|
20
|
+
.idea {
|
|
21
|
+
display: flex;
|
|
22
|
+
gap: 0.75rem;
|
|
23
|
+
align-items: baseline;
|
|
24
|
+
font-size: 0.9rem;
|
|
25
|
+
}
|
|
26
|
+
.idea-glyph { font-size: 1rem; flex-shrink: 0; width: 1.4rem; text-align: center; }
|
|
27
|
+
.idea-text { color: var(--ink-dim); }
|
|
28
|
+
.idea-text strong { color: var(--ink); }
|
|
29
|
+
|
|
30
|
+
hr { margin: 2rem 0; }
|
|
31
|
+
|
|
32
|
+
.apps-heading {
|
|
33
|
+
font-size: 0.7rem;
|
|
34
|
+
text-transform: uppercase;
|
|
35
|
+
letter-spacing: 0.1em;
|
|
36
|
+
color: var(--ink-dim);
|
|
37
|
+
margin-bottom: 1rem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.app-grid {
|
|
41
|
+
display: grid;
|
|
42
|
+
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
|
|
43
|
+
gap: 0.75rem;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.app-card {
|
|
47
|
+
display: block;
|
|
48
|
+
text-decoration: none;
|
|
49
|
+
color: var(--ink);
|
|
50
|
+
border: 1.5px solid var(--ink);
|
|
51
|
+
border-radius: var(--radius);
|
|
52
|
+
box-shadow: 2px 3px 0 var(--ink);
|
|
53
|
+
padding: 0.9rem 1rem;
|
|
54
|
+
transition: transform 0.08s, box-shadow 0.08s;
|
|
55
|
+
}
|
|
56
|
+
.app-card:hover { transform: translate(-1px, -1px); box-shadow: 3px 4px 0 var(--ink); }
|
|
57
|
+
.app-card:active { transform: translate(1px, 2px); box-shadow: none; }
|
|
58
|
+
|
|
59
|
+
.app-name { font-size: 1rem; margin-bottom: 0.2rem; }
|
|
60
|
+
.app-desc { font-size: 0.78rem; color: var(--ink-dim); line-height: 1.4; }
|
|
61
|
+
|
|
62
|
+
.footer {
|
|
63
|
+
margin-top: 3rem;
|
|
64
|
+
font-size: 0.75rem;
|
|
65
|
+
color: var(--ink-dim);
|
|
66
|
+
}
|
|
67
|
+
.footer a { color: var(--ink-dim); }
|
|
68
|
+
</style>
|
|
69
|
+
</head>
|
|
70
|
+
<body>
|
|
71
|
+
|
|
72
|
+
<div class="wordmark">streamo</div>
|
|
73
|
+
<p class="tagline">every device is an equal author</p>
|
|
74
|
+
|
|
75
|
+
<div class="ideas">
|
|
76
|
+
<div class="idea">
|
|
77
|
+
<span class="idea-glyph">⬡</span>
|
|
78
|
+
<span class="idea-text"><strong>content-addressed</strong> — data is identified by what it is, not where it lives</span>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="idea">
|
|
81
|
+
<span class="idea-glyph">✍</span>
|
|
82
|
+
<span class="idea-text"><strong>signed</strong> — every write is authenticated with a secp256k1 keypair derived from your identity</span>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="idea">
|
|
85
|
+
<span class="idea-glyph">↔</span>
|
|
86
|
+
<span class="idea-text"><strong>p2p sync</strong> — repos replicate over WebSocket without a central authority; the server is just another peer</span>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="idea">
|
|
89
|
+
<span class="idea-glyph">∞</span>
|
|
90
|
+
<span class="idea-text"><strong>append-only</strong> — history is never rewritten; every commit is permanent and verifiable</span>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<hr>
|
|
95
|
+
|
|
96
|
+
<p class="apps-heading">apps</p>
|
|
97
|
+
<div class="app-grid">
|
|
98
|
+
<a class="app-card" href="/apps/chat/">
|
|
99
|
+
<div class="app-name">chat</div>
|
|
100
|
+
<div class="app-desc">p2p messaging — each participant owns their message stream</div>
|
|
101
|
+
</a>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<p class="footer">
|
|
105
|
+
<a href="https://github.com/dtudury/streamo">github</a>
|
|
106
|
+
</p>
|
|
107
|
+
|
|
108
|
+
</body>
|
|
109
|
+
</html>
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { ContentMap } from './ContentMap.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Append-only, content-addressable byte store.
|
|
5
|
+
*
|
|
6
|
+
* Each appended Uint8Array gets an address equal to the index of its last byte.
|
|
7
|
+
* Duplicate content is rejected: the same bytes always live at the same address.
|
|
8
|
+
*
|
|
9
|
+
* The store exposes ReadableStream / WritableStream for network sync — the wire
|
|
10
|
+
* format is a sequence of length-prefixed chunks (4-byte little-endian length
|
|
11
|
+
* followed by the chunk bytes).
|
|
12
|
+
*/
|
|
13
|
+
export class Addressifier {
|
|
14
|
+
/** @type {Array.<{uint8Array: Uint8Array, offset: number}>} */
|
|
15
|
+
#chunks = []
|
|
16
|
+
#contentMap = new ContentMap()
|
|
17
|
+
|
|
18
|
+
#resolveNext
|
|
19
|
+
#nextChunk = new Promise(resolve => { this.#resolveNext = resolve })
|
|
20
|
+
|
|
21
|
+
get byteLength () {
|
|
22
|
+
if (!this.#chunks.length) return 0
|
|
23
|
+
const last = this.#chunks[this.#chunks.length - 1]
|
|
24
|
+
return last.offset + last.uint8Array.length
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the Uint8Array whose last byte is at `address`.
|
|
29
|
+
* For negative addresses, the caller (CodecRegistry) must override.
|
|
30
|
+
* @param {number} address
|
|
31
|
+
* @returns {Uint8Array}
|
|
32
|
+
*/
|
|
33
|
+
resolve (address) {
|
|
34
|
+
return this.#chunkAt(address).uint8Array
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Look up the address of a previously appended chunk.
|
|
39
|
+
* @param {Uint8Array} code
|
|
40
|
+
* @returns {number|undefined}
|
|
41
|
+
*/
|
|
42
|
+
addressOf (code) {
|
|
43
|
+
return this.#contentMap.get(code)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Append a new chunk to the store. Returns its address.
|
|
48
|
+
* Throws if the chunk is empty or already present.
|
|
49
|
+
* @param {Uint8Array} code
|
|
50
|
+
* @returns {number}
|
|
51
|
+
*/
|
|
52
|
+
append (code) {
|
|
53
|
+
if (!code.length) throw new Error('chunk must not be empty')
|
|
54
|
+
if (this.#contentMap.get(code) !== undefined) throw new Error('chunk already exists')
|
|
55
|
+
this.#chunks.push({ uint8Array: code, offset: this.byteLength })
|
|
56
|
+
const address = this.byteLength - 1
|
|
57
|
+
this.#contentMap.set(code, address)
|
|
58
|
+
const prev = this.#resolveNext
|
|
59
|
+
this.#nextChunk = new Promise(resolve => { this.#resolveNext = resolve })
|
|
60
|
+
prev(code)
|
|
61
|
+
return address
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Clear all stored chunks and reset the store to empty.
|
|
66
|
+
* Any readers waiting on future chunks will never resolve after this call;
|
|
67
|
+
* use only when no live readers exist (e.g. before an archiveSync write loop).
|
|
68
|
+
*/
|
|
69
|
+
_reset () {
|
|
70
|
+
this.#chunks = []
|
|
71
|
+
this.#contentMap = new ContentMap()
|
|
72
|
+
this.#nextChunk = new Promise(resolve => { this.#resolveNext = resolve })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Clone this store up to (and including) `address`.
|
|
77
|
+
* @param {number} address
|
|
78
|
+
* @returns {Addressifier}
|
|
79
|
+
*/
|
|
80
|
+
clone (address) {
|
|
81
|
+
return this._applyClone(new Addressifier(), address)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Copy internal store state (chunks + content map) into `target` up to
|
|
86
|
+
* `address`. Called by subclass clone() methods so they can pass a
|
|
87
|
+
* subclass instance as `target`.
|
|
88
|
+
* @param {Addressifier} target
|
|
89
|
+
* @param {number} address
|
|
90
|
+
* @returns {Addressifier}
|
|
91
|
+
*/
|
|
92
|
+
_applyClone (target, address) {
|
|
93
|
+
const idx = this.#indexAt(address, false)
|
|
94
|
+
target.#chunks = this.#chunks.slice(0, idx + 1)
|
|
95
|
+
target.#contentMap = this.#contentMap.clone(address)
|
|
96
|
+
return target
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Extract a byte range as a single Uint8Array.
|
|
101
|
+
* @param {number} [start=0]
|
|
102
|
+
* @param {number} [end=this.byteLength]
|
|
103
|
+
* @returns {Uint8Array}
|
|
104
|
+
*/
|
|
105
|
+
slice (start = 0, end = this.byteLength) {
|
|
106
|
+
const parts = []
|
|
107
|
+
for (const { uint8Array, offset } of this.#chunks) {
|
|
108
|
+
const chunkEnd = offset + uint8Array.length
|
|
109
|
+
if (chunkEnd <= start || offset >= end) continue
|
|
110
|
+
parts.push(uint8Array.slice(Math.max(0, start - offset), Math.min(uint8Array.length, end - offset)))
|
|
111
|
+
}
|
|
112
|
+
if (parts.length === 1) return parts[0]
|
|
113
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
|
|
114
|
+
let pos = 0
|
|
115
|
+
for (const p of parts) { out.set(p, pos); pos += p.length }
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Return a byte at a specific index (not address — just a raw byte position).
|
|
121
|
+
* @param {number} byteIndex
|
|
122
|
+
* @returns {number|undefined}
|
|
123
|
+
*/
|
|
124
|
+
byteAt (byteIndex) {
|
|
125
|
+
const chunk = this.#chunkAt(byteIndex, false)
|
|
126
|
+
if (!chunk) return undefined
|
|
127
|
+
return chunk.uint8Array[byteIndex - chunk.offset]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* ReadableStream that emits all chunks, then waits for new ones.
|
|
132
|
+
* Wire format: 4-byte LE length prefix followed by chunk bytes.
|
|
133
|
+
* @returns {ReadableStream}
|
|
134
|
+
*/
|
|
135
|
+
makeReadableStream () {
|
|
136
|
+
const self = this
|
|
137
|
+
let index = 0
|
|
138
|
+
return new ReadableStream({
|
|
139
|
+
async start (controller) {
|
|
140
|
+
while (true) {
|
|
141
|
+
while (index < self.#chunks.length) {
|
|
142
|
+
const { uint8Array } = self.#chunks[index++]
|
|
143
|
+
const len = new Uint8Array(new Uint32Array([uint8Array.length]).buffer)
|
|
144
|
+
const frame = new Uint8Array(len.length + uint8Array.length)
|
|
145
|
+
frame.set(len)
|
|
146
|
+
frame.set(uint8Array, len.length)
|
|
147
|
+
controller.enqueue(frame)
|
|
148
|
+
}
|
|
149
|
+
await self.#nextChunk
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* WritableStream that accepts the wire format and calls append() for each chunk.
|
|
157
|
+
*
|
|
158
|
+
* Resilient by design: duplicate chunks are silently skipped (they are already
|
|
159
|
+
* stored at the same address). Frames with implausible lengths are rejected so
|
|
160
|
+
* a single corrupt byte cannot stall the stream indefinitely.
|
|
161
|
+
*
|
|
162
|
+
* @param {number} [maxFrameSize=64*1024*1024] reject frames larger than this
|
|
163
|
+
* @returns {WritableStream}
|
|
164
|
+
*/
|
|
165
|
+
makeWritableStream (maxFrameSize = 64 * 1024 * 1024) {
|
|
166
|
+
const self = this
|
|
167
|
+
let buf = new Uint8Array(0)
|
|
168
|
+
return new WritableStream({
|
|
169
|
+
write (incoming) {
|
|
170
|
+
const next = new Uint8Array(buf.length + incoming.length)
|
|
171
|
+
next.set(buf); next.set(incoming, buf.length)
|
|
172
|
+
buf = next
|
|
173
|
+
while (buf.length >= 4) {
|
|
174
|
+
const len = new Uint32Array(buf.slice(0, 4).buffer)[0]
|
|
175
|
+
if (len === 0) throw new Error('malformed frame: zero-length chunk')
|
|
176
|
+
if (len > maxFrameSize) throw new Error(`malformed frame: length ${len} exceeds ${maxFrameSize}`)
|
|
177
|
+
if (buf.length < 4 + len) break
|
|
178
|
+
const code = buf.slice(4, 4 + len)
|
|
179
|
+
if (self.addressOf(code) === undefined) self.append(code)
|
|
180
|
+
buf = buf.slice(4 + len)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#indexAt (byteIndex, strict = true) {
|
|
187
|
+
const chunks = this.#chunks
|
|
188
|
+
if (!chunks.length) return -1
|
|
189
|
+
let lo = 0
|
|
190
|
+
let hi = chunks.length - 1
|
|
191
|
+
while (lo <= hi) {
|
|
192
|
+
const mid = (lo + hi) >>> 1
|
|
193
|
+
const { offset, uint8Array } = chunks[mid]
|
|
194
|
+
const end = offset + uint8Array.length - 1
|
|
195
|
+
if (strict) {
|
|
196
|
+
if (byteIndex === end) return mid
|
|
197
|
+
if (byteIndex < offset) hi = mid - 1
|
|
198
|
+
else lo = mid + 1
|
|
199
|
+
} else {
|
|
200
|
+
if (byteIndex >= offset && byteIndex < offset + uint8Array.length) return mid
|
|
201
|
+
if (byteIndex < offset) hi = mid - 1
|
|
202
|
+
else lo = mid + 1
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return -1
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#chunkAt (byteIndex, strict = true) {
|
|
209
|
+
const idx = this.#indexAt(byteIndex, strict)
|
|
210
|
+
return idx >= 0 ? this.#chunks[idx] : undefined
|
|
211
|
+
}
|
|
212
|
+
}
|