braid-blob 0.0.60 → 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 +13 -11
- 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,24 +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
|
-
### Braid-
|
|
55
|
+
### Special Braid-HTTP Headers
|
|
56
56
|
|
|
57
57
|
| Header | Description |
|
|
58
58
|
|--------|-------------|
|
|
59
59
|
| `Version` | Unique identifier for this version of the blob (e.g., `"alice-42"`) |
|
|
60
|
-
| `Parents` |
|
|
61
|
-
| `Merge-Type` |
|
|
62
|
-
| `Subscribe` |
|
|
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
63
|
| `Accept-Subscribe` | Server indicates it supports subscriptions |
|
|
64
|
-
| `Current-Version` | The version the server
|
|
64
|
+
| `Current-Version` | The latest version that the server is aware of |
|
|
65
65
|
|
|
66
|
-
### GET
|
|
66
|
+
### GET retrieves a blob
|
|
67
67
|
|
|
68
68
|
```http
|
|
69
69
|
GET /blob.png HTTP/1.1
|
|
@@ -84,7 +84,7 @@ Content-Length: 12345
|
|
|
84
84
|
|
|
85
85
|
Returns `404 Not Found` if the blob doesn't exist.
|
|
86
86
|
|
|
87
|
-
### GET with Subscribe
|
|
87
|
+
### GET with Subscribe syncs client with realtime updates
|
|
88
88
|
|
|
89
89
|
Add `Subscribe: true` to receive updates whenever the blob changes:
|
|
90
90
|
|
|
@@ -120,7 +120,7 @@ Content-Length: 23456
|
|
|
120
120
|
|
|
121
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
122
|
|
|
123
|
-
### PUT
|
|
123
|
+
### PUT stores a blob
|
|
124
124
|
|
|
125
125
|
```http
|
|
126
126
|
PUT /blob.png HTTP/1.1
|
|
@@ -139,9 +139,11 @@ HTTP/1.1 200 OK
|
|
|
139
139
|
Version: "carol-3"
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
-
|
|
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
143
|
|
|
144
|
-
|
|
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
|
|
145
147
|
|
|
146
148
|
```http
|
|
147
149
|
DELETE /blob.png HTTP/1.1
|
|
@@ -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
|
|