braid-blob 0.0.59 → 0.0.61
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/README.md +46 -5
- package/img-live-demo.html +35 -0
- package/img-live.js +108 -0
- package/package.json +1 -1
- package/server-demo.js +19 -1
package/README.md
CHANGED
|
@@ -46,13 +46,24 @@ node server-demo.js
|
|
|
46
46
|
|
|
47
47
|
Now open up http://localhost:8888 in your browser, to see the client. Open two windows. You can drag and drop images between them, and they will always stay synchronized.
|
|
48
48
|
|
|
49
|
-
<video src="https://github.com/user-attachments/assets/
|
|
49
|
+
<video src="https://github.com/user-attachments/assets/66ba0004-138c-4faa-a1d5-cb2cd06d3525" controls width="600"></video>
|
|
50
50
|
|
|
51
51
|
## Network API
|
|
52
52
|
|
|
53
53
|
Braid-blob speaks [Braid-HTTP](https://github.com/braid-org/braid-spec), an extension to HTTP for synchronization.
|
|
54
54
|
|
|
55
|
-
###
|
|
55
|
+
### Special Braid-HTTP Headers
|
|
56
|
+
|
|
57
|
+
| Header | Description |
|
|
58
|
+
|--------|-------------|
|
|
59
|
+
| `Version` | Unique identifier for this version of the blob (e.g., `"alice-42"`) |
|
|
60
|
+
| `Parents` | The previous version |
|
|
61
|
+
| `Merge-Type` | How conflicts resolve consistently (*e.g.* `aww` for [arbitrary-writer-wins](https://braid.org/protocol/merge-types/aww)) |
|
|
62
|
+
| `Subscribe` | In GET, subscribes client to all future changes |
|
|
63
|
+
| `Accept-Subscribe` | Server indicates it supports subscriptions |
|
|
64
|
+
| `Current-Version` | The latest version that the server is aware of |
|
|
65
|
+
|
|
66
|
+
### GET retrieves a blob
|
|
56
67
|
|
|
57
68
|
```http
|
|
58
69
|
GET /blob.png HTTP/1.1
|
|
@@ -62,12 +73,18 @@ Response:
|
|
|
62
73
|
|
|
63
74
|
```http
|
|
64
75
|
HTTP/1.1 200 OK
|
|
76
|
+
Version: "alice-1"
|
|
65
77
|
Content-Type: image/png
|
|
78
|
+
Merge-Type: aww
|
|
79
|
+
Accept-Subscribe: true
|
|
80
|
+
Content-Length: 12345
|
|
66
81
|
|
|
67
82
|
<binary data>
|
|
68
83
|
```
|
|
69
84
|
|
|
70
|
-
|
|
85
|
+
Returns `404 Not Found` if the blob doesn't exist.
|
|
86
|
+
|
|
87
|
+
### GET with Subscribe syncs client with realtime updates
|
|
71
88
|
|
|
72
89
|
Add `Subscribe: true` to receive updates whenever the blob changes:
|
|
73
90
|
|
|
@@ -83,6 +100,7 @@ HTTP/1.1 209 Subscription
|
|
|
83
100
|
Subscribe: true
|
|
84
101
|
Current-Version: "alice-1"
|
|
85
102
|
|
|
103
|
+
HTTP 200 OK
|
|
86
104
|
Version: "alice-1"
|
|
87
105
|
Content-Type: image/png
|
|
88
106
|
Merge-Type: aww
|
|
@@ -90,6 +108,7 @@ Content-Length: 12345
|
|
|
90
108
|
|
|
91
109
|
<binary data>
|
|
92
110
|
|
|
111
|
+
HTTP 200 OK
|
|
93
112
|
Version: "bob-2"
|
|
94
113
|
Content-Type: image/png
|
|
95
114
|
Merge-Type: aww
|
|
@@ -99,23 +118,45 @@ Content-Length: 23456
|
|
|
99
118
|
...
|
|
100
119
|
```
|
|
101
120
|
|
|
102
|
-
|
|
121
|
+
If the blob doesn't exist yet, `Current-Version` will be blank and no initial update is sent. If the blob is deleted, a `404` update is streamed.
|
|
122
|
+
|
|
123
|
+
### PUT stores a blob
|
|
103
124
|
|
|
104
125
|
```http
|
|
105
126
|
PUT /blob.png HTTP/1.1
|
|
106
127
|
Version: "carol-3"
|
|
107
128
|
Content-Type: image/png
|
|
108
129
|
Merge-Type: aww
|
|
130
|
+
Content-Length: 34567
|
|
109
131
|
|
|
110
132
|
<binary data>
|
|
111
133
|
```
|
|
112
134
|
|
|
113
|
-
|
|
135
|
+
Response:
|
|
136
|
+
|
|
137
|
+
```http
|
|
138
|
+
HTTP/1.1 200 OK
|
|
139
|
+
Version: "carol-3"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
If the sent version is older or eclipsed by the server's current version, the returned `Version` will be the server's version (not the one you sent).
|
|
143
|
+
|
|
144
|
+
The `braid_blob.serve()` method (below) will accept every PUT sent to it, but you can implement access control for any request before passing it to `serve()`, and return e.g. `401 Unauthorized` if you do no want to allow the PUT.
|
|
145
|
+
|
|
146
|
+
### DELETE removes a blob
|
|
114
147
|
|
|
115
148
|
```http
|
|
116
149
|
DELETE /blob.png HTTP/1.1
|
|
117
150
|
```
|
|
118
151
|
|
|
152
|
+
Response:
|
|
153
|
+
|
|
154
|
+
```http
|
|
155
|
+
HTTP/1.1 200 OK
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Returns `200 OK` even if the blob didn't exist.
|
|
159
|
+
|
|
119
160
|
### Understanding versions
|
|
120
161
|
|
|
121
162
|
Versions look like `"alice-42"` where:
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Live Image Demo</title>
|
|
5
|
+
<style>
|
|
6
|
+
body {
|
|
7
|
+
font-family: system-ui, sans-serif;
|
|
8
|
+
max-width: 800px;
|
|
9
|
+
margin: 40px auto;
|
|
10
|
+
padding: 20px;
|
|
11
|
+
}
|
|
12
|
+
img[live] {
|
|
13
|
+
max-width: 100%;
|
|
14
|
+
border: 2px solid #ccc;
|
|
15
|
+
border-radius: 8px;
|
|
16
|
+
}
|
|
17
|
+
.controls {
|
|
18
|
+
margin: 20px 0;
|
|
19
|
+
}
|
|
20
|
+
input[type="file"] {
|
|
21
|
+
margin-right: 10px;
|
|
22
|
+
}
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<h1>Braid-Blob Live Image Demo</h1>
|
|
27
|
+
<p>This image updates in real-time across all connected clients.</p>
|
|
28
|
+
|
|
29
|
+
<img live src="/blob.png" alt="Live updating image">
|
|
30
|
+
|
|
31
|
+
<script src="https://unpkg.com/braid-http@~1.3/braid-http-client.js"></script>
|
|
32
|
+
<script src="client.js"></script>
|
|
33
|
+
<script src="img-live.js"></script>
|
|
34
|
+
</body>
|
|
35
|
+
</html>
|
package/img-live.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Braid-Blob Live Images
|
|
2
|
+
// requires client.js
|
|
3
|
+
|
|
4
|
+
var live_images = new Map() // url -> { blob, images: Set<img> }
|
|
5
|
+
|
|
6
|
+
function sync(img) {
|
|
7
|
+
var url = img.src
|
|
8
|
+
if (!url) return
|
|
9
|
+
|
|
10
|
+
var entry = live_images.get(url)
|
|
11
|
+
if (entry) {
|
|
12
|
+
entry.images.add(img)
|
|
13
|
+
// Apply current blob URL if we have one
|
|
14
|
+
if (entry.objectUrl)
|
|
15
|
+
img.src = entry.objectUrl
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Create new subscription for this URL
|
|
20
|
+
entry = { images: new Set([img]), objectUrl: null, blob: null }
|
|
21
|
+
live_images.set(url, entry)
|
|
22
|
+
|
|
23
|
+
entry.blob = braid_blob_client(url, {
|
|
24
|
+
on_update: (body, content_type) => {
|
|
25
|
+
// Revoke old object URL if exists
|
|
26
|
+
if (entry.objectUrl)
|
|
27
|
+
URL.revokeObjectURL(entry.objectUrl)
|
|
28
|
+
|
|
29
|
+
// Create new blob and object URL
|
|
30
|
+
var blob = new Blob([body], { type: content_type || 'image/png' })
|
|
31
|
+
entry.objectUrl = URL.createObjectURL(blob)
|
|
32
|
+
|
|
33
|
+
// Update all images subscribed to this URL
|
|
34
|
+
entry.images.forEach(img => {
|
|
35
|
+
img.src = entry.objectUrl
|
|
36
|
+
})
|
|
37
|
+
},
|
|
38
|
+
on_delete: () => {
|
|
39
|
+
if (entry.objectUrl) {
|
|
40
|
+
URL.revokeObjectURL(entry.objectUrl)
|
|
41
|
+
entry.objectUrl = null
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
on_error: (error) => {
|
|
45
|
+
console.error('Live image error for', url, error)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function unsync(img) {
|
|
51
|
+
// Find which entry this image belongs to
|
|
52
|
+
for (var [url, entry] of live_images) {
|
|
53
|
+
if (entry.images.has(img)) {
|
|
54
|
+
entry.images.delete(img)
|
|
55
|
+
|
|
56
|
+
// If no more images using this URL, clean up
|
|
57
|
+
if (entry.images.size === 0) {
|
|
58
|
+
if (entry.objectUrl)
|
|
59
|
+
URL.revokeObjectURL(entry.objectUrl)
|
|
60
|
+
// Note: braid_blob_client doesn't expose unsubscribe,
|
|
61
|
+
// would need to pass AbortSignal in options to cancel
|
|
62
|
+
live_images.delete(url)
|
|
63
|
+
}
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
var observer = new MutationObserver(function(mutations) {
|
|
70
|
+
mutations.forEach(function(mutation) {
|
|
71
|
+
mutation.addedNodes.forEach(function(node) {
|
|
72
|
+
if (node.nodeType === 1) {
|
|
73
|
+
if (node.tagName === 'IMG' && node.hasAttribute('live'))
|
|
74
|
+
sync(node)
|
|
75
|
+
node.querySelectorAll('img[live]').forEach(sync)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
mutation.removedNodes.forEach(function(node) {
|
|
79
|
+
if (node.nodeType === 1) {
|
|
80
|
+
if (node.tagName === 'IMG' && node.hasAttribute('live'))
|
|
81
|
+
unsync(node)
|
|
82
|
+
node.querySelectorAll('img[live]').forEach(unsync)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
if (mutation.type === 'attributes' && mutation.attributeName === 'live' && mutation.target.tagName === 'IMG') {
|
|
86
|
+
if (mutation.target.hasAttribute('live'))
|
|
87
|
+
sync(mutation.target)
|
|
88
|
+
else
|
|
89
|
+
unsync(mutation.target)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
if (document.readyState === 'loading')
|
|
95
|
+
document.addEventListener('DOMContentLoaded', init)
|
|
96
|
+
else
|
|
97
|
+
init()
|
|
98
|
+
|
|
99
|
+
function init() {
|
|
100
|
+
observer.observe(document.body, {
|
|
101
|
+
childList: true,
|
|
102
|
+
subtree: true,
|
|
103
|
+
attributes: true,
|
|
104
|
+
attributeFilter: ['live']
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
document.querySelectorAll('img[live]').forEach(sync)
|
|
108
|
+
}
|
package/package.json
CHANGED
package/server-demo.js
CHANGED
|
@@ -23,7 +23,16 @@ var server = require("http").createServer(async (req, res) => {
|
|
|
23
23
|
return
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
if (url === '/') {
|
|
26
|
+
if (url === '/img-live.js') {
|
|
27
|
+
res.writeHead(200, {
|
|
28
|
+
"Content-Type": "text/javascript",
|
|
29
|
+
"Cache-Control": "no-cache"
|
|
30
|
+
})
|
|
31
|
+
require("fs").createReadStream(`${__dirname}/img-live.js`).pipe(res)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (url === '/' || url === '/client-demo.html') {
|
|
27
36
|
res.writeHead(200, {
|
|
28
37
|
"Content-Type": "text/html",
|
|
29
38
|
"Cache-Control": "no-cache"
|
|
@@ -32,6 +41,15 @@ var server = require("http").createServer(async (req, res) => {
|
|
|
32
41
|
return
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
if (url === '/img-live-demo.html') {
|
|
45
|
+
res.writeHead(200, {
|
|
46
|
+
"Content-Type": "text/html",
|
|
47
|
+
"Cache-Control": "no-cache"
|
|
48
|
+
})
|
|
49
|
+
require("fs").createReadStream(`${__dirname}/img-live-demo.html`).pipe(res)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
braid_blob.serve(req, res)
|
|
36
54
|
})
|
|
37
55
|
|