@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.
Files changed (49) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/LICENSE +661 -0
  3. package/README.md +194 -0
  4. package/ROADMAP.md +111 -0
  5. package/bin/streamo.js +238 -0
  6. package/jsconfig.json +9 -0
  7. package/package.json +26 -0
  8. package/public/apps/chat/index.html +61 -0
  9. package/public/apps/chat/main.js +144 -0
  10. package/public/apps/styles/proto.css +71 -0
  11. package/public/index.html +109 -0
  12. package/public/streamo/Addressifier.js +212 -0
  13. package/public/streamo/CodecRegistry.js +195 -0
  14. package/public/streamo/ContentMap.js +79 -0
  15. package/public/streamo/DESIGN.md +61 -0
  16. package/public/streamo/Repo.js +176 -0
  17. package/public/streamo/Repo.test.js +82 -0
  18. package/public/streamo/RepoRegistry.js +91 -0
  19. package/public/streamo/RepoRegistry.test.js +87 -0
  20. package/public/streamo/Signature.js +15 -0
  21. package/public/streamo/Signer.js +91 -0
  22. package/public/streamo/Streamo.js +392 -0
  23. package/public/streamo/Streamo.test.js +205 -0
  24. package/public/streamo/archiveSync.js +62 -0
  25. package/public/streamo/chat-cli.js +122 -0
  26. package/public/streamo/chat-server.js +60 -0
  27. package/public/streamo/codecs.js +400 -0
  28. package/public/streamo/fileSync.js +238 -0
  29. package/public/streamo/h.js +202 -0
  30. package/public/streamo/h.mount.test.js +67 -0
  31. package/public/streamo/h.test.js +121 -0
  32. package/public/streamo/mount.js +248 -0
  33. package/public/streamo/originSync.js +60 -0
  34. package/public/streamo/outletSync.js +105 -0
  35. package/public/streamo/registrySync.js +333 -0
  36. package/public/streamo/registrySync.test.js +373 -0
  37. package/public/streamo/s3Sync.js +99 -0
  38. package/public/streamo/stateFileSync.js +17 -0
  39. package/public/streamo/sync.test.js +98 -0
  40. package/public/streamo/utils/NestedSet.js +41 -0
  41. package/public/streamo/utils/Recaller.js +77 -0
  42. package/public/streamo/utils/mockDOM.js +113 -0
  43. package/public/streamo/utils/nextTick.js +22 -0
  44. package/public/streamo/utils/noble-secp256k1.js +602 -0
  45. package/public/streamo/utils/testing.js +90 -0
  46. package/public/streamo/utils.js +57 -0
  47. package/public/streamo/webSync.js +118 -0
  48. package/scripts/serve.js +15 -0
  49. package/smoke.test.js +132 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Universal test utility for stream/ tests.
3
+ *
4
+ * In Node.js: wraps node:test describe/test — tests actually execute via `node --test`
5
+ * In browser: no-op for now; browser test runner to be rebuilt as a stream module
6
+ *
7
+ * Usage in test files:
8
+ *
9
+ * import { describe, assert } from './utils/testing.js'
10
+ *
11
+ * describe(import.meta.url, ({ test }) => {
12
+ * test('my case', () => {
13
+ * assert.equal(actual, expected)
14
+ * })
15
+ * test('async case', async () => {
16
+ * const result = await someAsyncThing()
17
+ * assert.ok(result)
18
+ * })
19
+ * })
20
+ */
21
+
22
+ const IS_NODE = typeof process !== 'undefined' && process.versions?.node != null
23
+
24
+ // ── Assertions ────────────────────────────────────────────────────────────
25
+ // Throws on failure, works in any environment.
26
+
27
+ class AssertionError extends Error {
28
+ constructor (message) {
29
+ super(message)
30
+ this.name = 'AssertionError'
31
+ }
32
+ }
33
+
34
+ function fmt (v) {
35
+ try {
36
+ if (v instanceof Uint8Array) return `Uint8Array[${[...v]}]`
37
+ return JSON.stringify(v)
38
+ } catch {
39
+ return String(v)
40
+ }
41
+ }
42
+
43
+ export const assert = {
44
+ ok (val, msg) {
45
+ if (!val) throw new AssertionError(msg ?? `expected truthy, got ${fmt(val)}`)
46
+ },
47
+ equal (actual, expected, msg) {
48
+ if (actual !== expected) throw new AssertionError(msg ?? `${fmt(actual)} !== ${fmt(expected)}`)
49
+ },
50
+ notEqual (actual, expected, msg) {
51
+ if (actual === expected) throw new AssertionError(msg ?? `expected values to differ, both were ${fmt(actual)}`)
52
+ },
53
+ deepEqual (actual, expected, msg) {
54
+ if (fmt(actual) !== fmt(expected)) throw new AssertionError(msg ?? `${fmt(actual)} !== ${fmt(expected)}`)
55
+ },
56
+ throws (fn, msg) {
57
+ let threw = false
58
+ try { fn() } catch { threw = true }
59
+ if (!threw) throw new AssertionError(msg ?? 'expected function to throw')
60
+ }
61
+ }
62
+
63
+ // ── describe / test ───────────────────────────────────────────────────────
64
+
65
+ let _impl
66
+
67
+ if (IS_NODE) {
68
+ const { describe: nodeDescribe, test: nodeTest } = await import('node:test')
69
+ _impl = {
70
+ describe (name, fn) {
71
+ nodeDescribe(name, () => fn({
72
+ test: (testName, testFn) => nodeTest(testName, () => testFn({ assert }))
73
+ }))
74
+ }
75
+ }
76
+ } else {
77
+ // Browser test runner: TODO rebuild as a first-class stream module
78
+ _impl = { describe () {} }
79
+ }
80
+
81
+ /**
82
+ * Declare a group of tests. Pass import.meta.url as the name so the
83
+ * test runner can show which file each group came from.
84
+ *
85
+ * @param {string} name typically import.meta.url
86
+ * @param {function({ test: function }): void} fn
87
+ */
88
+ export function describe (name, fn) {
89
+ _impl.describe(name, fn)
90
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Encode a Uint8Array as a lowercase hex string.
3
+ * @param {Uint8Array} bytes
4
+ * @returns {string}
5
+ */
6
+ export function bytesToHex (bytes) {
7
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
8
+ }
9
+
10
+ /**
11
+ * Decode a hex string to a Uint8Array.
12
+ * @param {string} hex
13
+ * @returns {Uint8Array}
14
+ */
15
+ export function hexToBytes (hex) {
16
+ const bytes = new Uint8Array(hex.length / 2)
17
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
18
+ return bytes
19
+ }
20
+
21
+ /**
22
+ * Encode a non-negative integer as a little-endian byte array.
23
+ * The number of bytes needed is the minimum to represent the value.
24
+ * @param {number} n
25
+ * @returns {Uint8Array}
26
+ */
27
+ export function numberToVar (n) {
28
+ if (n < 0) throw new Error('n must be non-negative')
29
+ if (!n) return new Uint8Array([0])
30
+ const bytes = []
31
+ while (n) {
32
+ bytes.push(n & 0xff)
33
+ n >>>= 8
34
+ }
35
+ return new Uint8Array(bytes)
36
+ }
37
+
38
+ /**
39
+ * Decode a little-endian byte array back to a number.
40
+ * @param {Uint8Array} bytes
41
+ * @returns {number}
42
+ */
43
+ export function varToNumber (bytes) {
44
+ let n = 0
45
+ for (let i = bytes.length - 1; i >= 0; i--) {
46
+ n = (n * 256 + bytes[i]) >>> 0
47
+ }
48
+ return n
49
+ }
50
+
51
+ /**
52
+ * @param {number} length
53
+ * @returns {Array.<number>}
54
+ */
55
+ export function range (length) {
56
+ return Array.from({ length }, (_, i) => i)
57
+ }
@@ -0,0 +1,118 @@
1
+ import { createServer } from 'http'
2
+ import { WebSocketServer } from 'ws'
3
+ import express from 'express'
4
+ import { fileURLToPath } from 'url'
5
+ import { dirname, join } from 'path'
6
+ import { attachStreamSync } from './outletSync.js'
7
+
8
+ const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..')
9
+
10
+ /**
11
+ * Start an HTTP + WebSocket server that exposes a RepoRegistry to browsers
12
+ * and other peers.
13
+ *
14
+ * HTTP endpoints:
15
+ * GET / → 200 JSON: current value of the primary streamo
16
+ * GET /streams/:key → 200 JSON: current value of streamo `key`
17
+ * GET /streams/:key/raw → 200 application/octet-stream: full wire-format archive
18
+ *
19
+ * WebSocket (same port, upgraded from HTTP):
20
+ * Uses the same handshake + full-duplex wire protocol as outletSync, so any
21
+ * originSync client can connect here directly.
22
+ *
23
+ * @param {import('./RepoRegistry.js').RepoRegistry} registry
24
+ * @param {string} primaryKeyHex public key of the "main" streamo for GET /
25
+ * @param {number} port
26
+ * @returns {Promise<import('http').Server>}
27
+ */
28
+ export async function webSync (registry, primaryKeyHex, port, name, keyIterations = 100000) {
29
+ const app = express()
30
+
31
+ app.use(express.static(publicDir))
32
+
33
+ app.use(express.json())
34
+
35
+ // Expose primary key so the browser app knows which streamo to open
36
+ app.get('/api/info', (req, res) => {
37
+ res.json({ primaryKeyHex, name, keyIterations })
38
+ })
39
+
40
+ // Write a single file to the primary streamo's latest commit
41
+ app.post('/api/file', async (req, res) => {
42
+ try {
43
+ const { path, content, message } = req.body
44
+ if (typeof path !== 'string' || typeof content !== 'string') {
45
+ return res.status(400).json({ error: 'path and content must be strings' })
46
+ }
47
+ const repo = await registry.open(primaryKeyHex)
48
+ const working = repo.checkout()
49
+ // Store JSON files as parsed objects so they round-trip cleanly with fileSync
50
+ let value = content
51
+ if (path.endsWith('.json')) {
52
+ try { value = JSON.parse(content) } catch {}
53
+ }
54
+ working.set(path, value)
55
+ repo.commit(working, message || `edit ${path}`)
56
+ res.json({ ok: true })
57
+ } catch (e) {
58
+ res.status(500).json({ error: e.message })
59
+ }
60
+ })
61
+
62
+ // Current value of the primary streamo as JSON
63
+ app.get('/', async (req, res) => {
64
+ try {
65
+ const streamo = await registry.open(primaryKeyHex)
66
+ res.json(streamo.byteLength > 0 ? streamo.get() : null)
67
+ } catch (e) {
68
+ res.status(500).json({ error: e.message })
69
+ }
70
+ })
71
+
72
+ // Current value of any streamo as JSON
73
+ app.get('/streams/:key', async (req, res) => {
74
+ try {
75
+ const streamo = await registry.open(req.params.key)
76
+ res.json(streamo.byteLength > 0 ? streamo.get() : null)
77
+ } catch (e) {
78
+ res.status(500).json({ error: e.message })
79
+ }
80
+ })
81
+
82
+ // Full wire-format snapshot of a streamo's current chunks (finite — does not
83
+ // stream future appends). Used by browsers to bootstrap before WebSocket sync
84
+ // so both sides share the same address space.
85
+ app.get('/streams/:key/raw', async (req, res) => {
86
+ try {
87
+ const streamo = await registry.open(req.params.key)
88
+ res.set('Content-Type', 'application/octet-stream')
89
+ const target = streamo.byteLength // snapshot length; stop here
90
+ if (target === 0) { res.end(); return }
91
+ const reader = streamo.makeReadableStream().getReader()
92
+ res.on('close', () => reader.cancel().catch(() => {}))
93
+ let contentSent = 0
94
+ const pump = async () => {
95
+ if (contentSent >= target) { reader.cancel().catch(() => {}); res.end(); return }
96
+ const { value, done } = await reader.read()
97
+ if (done || !res.writable) { res.end(); return }
98
+ // wire frame: [4-byte LE length][chunk bytes] — read length to track progress
99
+ contentSent += (value[0]) | (value[1] << 8) | (value[2] << 16) | (value[3] << 24)
100
+ res.write(Buffer.from(value))
101
+ pump()
102
+ }
103
+ pump()
104
+ } catch (e) {
105
+ res.status(500).json({ error: e.message })
106
+ }
107
+ })
108
+
109
+ const server = createServer(app)
110
+
111
+ attachStreamSync(new WebSocketServer({ server }), registry, 'web')
112
+
113
+ await new Promise((resolve, reject) => {
114
+ server.listen(port, err => err ? reject(err) : resolve())
115
+ })
116
+
117
+ return server
118
+ }
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Serve public/ as a static site.
4
+ * Usage: node scripts/serve.js [port]
5
+ */
6
+ import express from 'express'
7
+ import { fileURLToPath } from 'url'
8
+ import { join, dirname } from 'path'
9
+
10
+ const port = Number(process.argv[2] ?? process.env.PORT ?? 3000)
11
+ const root = join(dirname(fileURLToPath(import.meta.url)), '../public')
12
+
13
+ const app = express()
14
+ app.use(express.static(root))
15
+ app.listen(port, () => console.log(`http://localhost:${port}`))
package/smoke.test.js ADDED
@@ -0,0 +1,132 @@
1
+ import { describe } from './public/streamo/utils/testing.js'
2
+ import { Streamo } from './public/streamo/Streamo.js'
3
+ import { Repo } from './public/streamo/Repo.js'
4
+ import { RepoRegistry } from './public/streamo/RepoRegistry.js'
5
+ import { archiveSync } from './public/streamo/archiveSync.js'
6
+ import { webSync } from './public/streamo/webSync.js'
7
+ import { Signer } from './public/streamo/Signer.js'
8
+ import WebSocket from 'ws'
9
+ import { rm, mkdtemp } from 'fs/promises'
10
+ import { tmpdir } from 'os'
11
+ import { join } from 'path'
12
+
13
+ // 1 iteration keeps key derivation fast without compromising what we're testing
14
+ const KEY_ITERATIONS = 1
15
+ const toHex = bytes => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
16
+
17
+ async function makeKey (name = 'smoke') {
18
+ const signer = new Signer('test', 'test', KEY_ITERATIONS)
19
+ const { publicKey } = await signer.keysFor(name)
20
+ return { signer, publicKey, publicKeyHex: toHex(publicKey) }
21
+ }
22
+
23
+ async function startServer (publicKeyHex, stream) {
24
+ const registry = new RepoRegistry(() => stream)
25
+ const server = await webSync(registry, publicKeyHex, 0, 'smoke-test', KEY_ITERATIONS)
26
+ const { port } = server.address()
27
+ const close = () => new Promise(resolve => server.close(resolve))
28
+ return { port, close }
29
+ }
30
+
31
+ describe(import.meta.url, ({ test }) => {
32
+ test('GET /api/info returns primaryKeyHex and name', async ({ assert }) => {
33
+ const { publicKeyHex } = await makeKey()
34
+ const { port, close } = await startServer(publicKeyHex, new Streamo())
35
+ try {
36
+ const info = await fetch(`http://localhost:${port}/api/info`).then(r => r.json())
37
+ assert.equal(info.primaryKeyHex, publicKeyHex)
38
+ assert.equal(info.name, 'smoke-test')
39
+ } finally {
40
+ await close()
41
+ }
42
+ })
43
+
44
+ test('GET /streams/:key/raw loads into a fresh Streamo', async ({ assert }) => {
45
+ const { publicKeyHex } = await makeKey()
46
+ const stream = new Streamo()
47
+ stream.set({ hello: 'world' })
48
+ const { port, close } = await startServer(publicKeyHex, stream)
49
+ try {
50
+ const buf = await fetch(`http://localhost:${port}/streams/${publicKeyHex}/raw`)
51
+ .then(r => r.arrayBuffer())
52
+ const fresh = new Streamo()
53
+ await fresh.makeWritableStream().getWriter().write(new Uint8Array(buf))
54
+ assert.deepEqual(fresh.get(), { hello: 'world' })
55
+ } finally {
56
+ await close()
57
+ }
58
+ })
59
+
60
+ test('WebSocket syncs existing chunks to a connecting client', async ({ assert }) => {
61
+ const { publicKeyHex } = await makeKey()
62
+ const stream = new Streamo()
63
+ stream.set({ synced: true })
64
+ const { port, close } = await startServer(publicKeyHex, stream)
65
+ try {
66
+ const client = new Streamo()
67
+ const writer = client.makeWritableStream().getWriter()
68
+ const ws = new WebSocket(`ws://localhost:${port}`)
69
+
70
+ await new Promise((resolve, reject) => {
71
+ ws.on('open', () => ws.send(publicKeyHex))
72
+ ws.on('message', async data => {
73
+ await writer.write(new Uint8Array(data))
74
+ if (client.byteLength >= stream.byteLength) resolve()
75
+ })
76
+ ws.on('error', reject)
77
+ setTimeout(() => reject(new Error('WS sync timed out')), 2000)
78
+ })
79
+
80
+ ws.close()
81
+ assert.deepEqual(client.get(), { synced: true })
82
+ } finally {
83
+ await close()
84
+ }
85
+ })
86
+
87
+ test('set() with a path works when root is VARIABLE-encoded (old server data)', ({ assert }) => {
88
+ // Old server archives encoded the root value as VARIABLE (a boxed address).
89
+ // asRefs() previously returned the VARIABLE address number rather than the
90
+ // inner object's refs, causing refs['toggle'] === undefined → crash.
91
+ const stream = new Streamo()
92
+ // encodeVariable to simulate old-style encoded root
93
+ const rootCode = stream.encodeVariable({ toggle: { value: false, label: 'enabled' }, counter: { value: 0 } })
94
+ stream.append(rootCode)
95
+ // get() should see through VARIABLE
96
+ assert.equal(stream.get('toggle', 'value'), false)
97
+ // set() with a 2-level path must not crash
98
+ stream.set('toggle', 'value', true)
99
+ assert.equal(stream.get('toggle', 'value'), true)
100
+ assert.equal(stream.get('counter', 'value'), 0)
101
+ })
102
+
103
+ test('set() after sign() reads/writes user data, not the signature chunk', async ({ assert }) => {
104
+ const { signer } = await makeKey()
105
+ const stream = new Streamo()
106
+ stream.set({ count: 0 })
107
+ await stream.sign(signer, 'smoke')
108
+ // Before the fix, set() with a path would crash here because byteLength - 1
109
+ // pointed to the signature chunk instead of the data chunk.
110
+ stream.set('count', stream.get('count') + 1)
111
+ assert.equal(stream.get('count'), 1)
112
+ // get() must also skip past the signature
113
+ assert.deepEqual(stream.get(), { count: 1 })
114
+ })
115
+
116
+ test('archiveSync persists data and reloads it on a fresh Streamo', async ({ assert }) => {
117
+ const { publicKeyHex } = await makeKey('archive')
118
+ const dir = await mkdtemp(join(tmpdir(), 'smoke-'))
119
+ try {
120
+ const stream1 = new Streamo()
121
+ await archiveSync(stream1, dir, publicKeyHex)
122
+ stream1.set({ persisted: true })
123
+ await new Promise(r => setTimeout(r, 100)) // let write loop flush
124
+
125
+ const stream2 = new Streamo()
126
+ await archiveSync(stream2, dir, publicKeyHex)
127
+ assert.deepEqual(stream2.get(), { persisted: true })
128
+ } finally {
129
+ await rm(dir, { recursive: true, force: true })
130
+ }
131
+ })
132
+ })