worker.js
javascript
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65
refactor: enforce gRPC framing on all MWP wire traffic
Sonnet 4.6
minor
⚠ breaking
153 days ago
| 1 | /** |
| 2 | * musehub-pack-receiver — Cloudflare Worker |
| 3 | * |
| 4 | * Receives Muse Wire Protocol (MWP) object packs from muse CLI clients. |
| 5 | * Objects are written directly to R2 using the native R2 binding (~1 ms per PUT, |
| 6 | * no EC2→R2 hop) then the object IDs are registered in the MuseHub Postgres DB |
| 7 | * via a lightweight internal callback to the MuseHub API. |
| 8 | * |
| 9 | * Latency profile (per pack of 250 objects): |
| 10 | * Previous path EC2 receives → asyncio gather → boto3 PUT×250 → respond ~875 ms/PUT |
| 11 | * This Worker: Worker receives → R2 PUT×250 via Promise.all → notify MuseHub ~3–8 ms/PUT |
| 12 | * |
| 13 | * Bindings required in Cloudflare dashboard: |
| 14 | * R2: BUCKET (the musehub-objects R2 bucket) |
| 15 | * Secret: MUSEHUB_INTERNAL_KEY (shared secret for Worker→MuseHub calls) |
| 16 | * Var: MUSEHUB_ORIGIN (e.g. "https://staging.musehub.ai") |
| 17 | * |
| 18 | * Routes: |
| 19 | * POST /{owner}/{slug}/push/object-pack mirrors the MuseHub wire endpoint |
| 20 | * |
| 21 | * The client sends the exact same MWP msgpack body it would send to MuseHub. |
| 22 | * The Authorization header (MSign) is forwarded verbatim to MuseHub for |
| 23 | * identity verification — the Worker itself only checks MUSEHUB_INTERNAL_KEY |
| 24 | * on the callback leg, not on the inbound client request. |
| 25 | */ |
| 26 | |
| 27 | // ── msgpack decode (subset sufficient for MWP pack bodies) ──────────────────── |
| 28 | // We only decode: map, str, bin, uint, bool. No floats, no ext. |
| 29 | |
| 30 | function msgpackDecode(buf) { |
| 31 | const view = new DataView(buf); |
| 32 | let pos = 0; |
| 33 | |
| 34 | function readStr(len) { |
| 35 | const bytes = new Uint8Array(buf, pos, len); |
| 36 | pos += len; |
| 37 | return new TextDecoder().decode(bytes); |
| 38 | } |
| 39 | |
| 40 | function readBin(len) { |
| 41 | const slice = buf.slice(pos, pos + len); |
| 42 | pos += len; |
| 43 | return slice; |
| 44 | } |
| 45 | |
| 46 | function read() { |
| 47 | const b = view.getUint8(pos++); |
| 48 | |
| 49 | // positive fixint |
| 50 | if ((b & 0x80) === 0) return b; |
| 51 | // fixstr |
| 52 | if ((b & 0xe0) === 0xa0) return readStr(b & 0x1f); |
| 53 | // fixmap |
| 54 | if ((b & 0xf0) === 0x80) { |
| 55 | const n = b & 0x0f; |
| 56 | const m = {}; |
| 57 | for (let i = 0; i < n; i++) { const k = read(); m[k] = read(); } |
| 58 | return m; |
| 59 | } |
| 60 | // fixarray |
| 61 | if ((b & 0xf0) === 0x90) { |
| 62 | const n = b & 0x0f; |
| 63 | const a = []; |
| 64 | for (let i = 0; i < n; i++) a.push(read()); |
| 65 | return a; |
| 66 | } |
| 67 | // negative fixint |
| 68 | if ((b & 0xe0) === 0xe0) return b - 256; |
| 69 | |
| 70 | switch (b) { |
| 71 | case 0xc0: return null; |
| 72 | case 0xc2: return false; |
| 73 | case 0xc3: return true; |
| 74 | |
| 75 | // bin8 / bin16 / bin32 |
| 76 | case 0xc4: { const n = view.getUint8(pos++); return readBin(n); } |
| 77 | case 0xc5: { const n = view.getUint16(pos); pos += 2; return readBin(n); } |
| 78 | case 0xc6: { const n = view.getUint32(pos); pos += 4; return readBin(n); } |
| 79 | |
| 80 | // uint8 / uint16 / uint32 / uint64 |
| 81 | case 0xcc: { const v = view.getUint8(pos++); return v; } |
| 82 | case 0xcd: { const v = view.getUint16(pos); pos += 2; return v; } |
| 83 | case 0xce: { const v = view.getUint32(pos); pos += 4; return v; } |
| 84 | case 0xcf: { const hi = view.getUint32(pos); const lo = view.getUint32(pos + 4); pos += 8; return hi * 2**32 + lo; } |
| 85 | |
| 86 | // int8 / int16 / int32 |
| 87 | case 0xd0: { const v = view.getInt8(pos++); return v; } |
| 88 | case 0xd1: { const v = view.getInt16(pos); pos += 2; return v; } |
| 89 | case 0xd2: { const v = view.getInt32(pos); pos += 4; return v; } |
| 90 | |
| 91 | // str8 / str16 / str32 |
| 92 | case 0xd9: { const n = view.getUint8(pos++); return readStr(n); } |
| 93 | case 0xda: { const n = view.getUint16(pos); pos += 2; return readStr(n); } |
| 94 | case 0xdb: { const n = view.getUint32(pos); pos += 4; return readStr(n); } |
| 95 | |
| 96 | // array16 / array32 |
| 97 | case 0xdc: { const n = view.getUint16(pos); pos += 2; const a = []; for (let i=0;i<n;i++) a.push(read()); return a; } |
| 98 | case 0xdd: { const n = view.getUint32(pos); pos += 4; const a = []; for (let i=0;i<n;i++) a.push(read()); return a; } |
| 99 | |
| 100 | // map16 / map32 |
| 101 | case 0xde: { const n = view.getUint16(pos); pos += 2; const m = {}; for (let i=0;i<n;i++){const k=read();m[k]=read();} return m; } |
| 102 | case 0xdf: { const n = view.getUint32(pos); pos += 4; const m = {}; for (let i=0;i<n;i++){const k=read();m[k]=read();} return m; } |
| 103 | |
| 104 | default: |
| 105 | throw new Error(`msgpack: unsupported byte 0x${b.toString(16)} at pos ${pos-1}`); |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | return read(); |
| 110 | } |
| 111 | |
| 112 | // ── msgpack encode (only what we need for responses) ────────────────────────── |
| 113 | |
| 114 | function msgpackEncode(value) { |
| 115 | const bufs = []; |
| 116 | function enc(v) { |
| 117 | if (v === null || v === undefined) { bufs.push(new Uint8Array([0xc0])); return; } |
| 118 | if (typeof v === 'boolean') { bufs.push(new Uint8Array([v ? 0xc3 : 0xc2])); return; } |
| 119 | if (typeof v === 'number') { |
| 120 | if (Number.isInteger(v) && v >= 0 && v <= 0xffffffff) { |
| 121 | if (v <= 0x7f) { bufs.push(new Uint8Array([v])); return; } |
| 122 | if (v <= 0xff) { bufs.push(new Uint8Array([0xcc, v])); return; } |
| 123 | if (v <= 0xffff) { const b=new Uint8Array(3); b[0]=0xcd; new DataView(b.buffer).setUint16(1,v); bufs.push(b); return; } |
| 124 | const b=new Uint8Array(5); b[0]=0xce; new DataView(b.buffer).setUint32(1,v); bufs.push(b); return; |
| 125 | } |
| 126 | } |
| 127 | if (typeof v === 'string') { |
| 128 | const encoded = new TextEncoder().encode(v); |
| 129 | const len = encoded.length; |
| 130 | if (len <= 31) { bufs.push(new Uint8Array([0xa0 | len])); bufs.push(encoded); } |
| 131 | else if (len <= 0xff) { bufs.push(new Uint8Array([0xd9, len])); bufs.push(encoded); } |
| 132 | else if (len <= 0xffff) { const h=new Uint8Array(3); h[0]=0xda; new DataView(h.buffer).setUint16(1,len); bufs.push(h); bufs.push(encoded); } |
| 133 | else { const h=new Uint8Array(5); h[0]=0xdb; new DataView(h.buffer).setUint32(1,len); bufs.push(h); bufs.push(encoded); } |
| 134 | return; |
| 135 | } |
| 136 | if (v instanceof ArrayBuffer || ArrayBuffer.isView(v)) { |
| 137 | const bytes = v instanceof ArrayBuffer ? new Uint8Array(v) : new Uint8Array(v.buffer, v.byteOffset, v.byteLength); |
| 138 | const len = bytes.length; |
| 139 | if (len <= 0xff) bufs.push(new Uint8Array([0xc4, len])); |
| 140 | else if (len <= 0xffff) { const h=new Uint8Array(3); h[0]=0xc5; new DataView(h.buffer).setUint16(1,len); bufs.push(h); } |
| 141 | else { const h=new Uint8Array(5); h[0]=0xc6; new DataView(h.buffer).setUint32(1,len); bufs.push(h); } |
| 142 | bufs.push(bytes); return; |
| 143 | } |
| 144 | if (Array.isArray(v)) { |
| 145 | const len = v.length; |
| 146 | if (len <= 15) bufs.push(new Uint8Array([0x90 | len])); |
| 147 | else if (len <= 0xffff) { const h=new Uint8Array(3); h[0]=0xdc; new DataView(h.buffer).setUint16(1,len); bufs.push(h); } |
| 148 | else { const h=new Uint8Array(5); h[0]=0xdd; new DataView(h.buffer).setUint32(1,len); bufs.push(h); } |
| 149 | for (const item of v) enc(item); |
| 150 | return; |
| 151 | } |
| 152 | if (typeof v === 'object') { |
| 153 | const keys = Object.keys(v); |
| 154 | const len = keys.length; |
| 155 | if (len <= 15) bufs.push(new Uint8Array([0x80 | len])); |
| 156 | else if (len <= 0xffff) { const h=new Uint8Array(3); h[0]=0xde; new DataView(h.buffer).setUint16(1,len); bufs.push(h); } |
| 157 | else { const h=new Uint8Array(5); h[0]=0xdf; new DataView(h.buffer).setUint32(1,len); bufs.push(h); } |
| 158 | for (const k of keys) { enc(k); enc(v[k]); } |
| 159 | return; |
| 160 | } |
| 161 | throw new Error(`msgpack: cannot encode ${typeof v}`); |
| 162 | } |
| 163 | enc(value); |
| 164 | const total = bufs.reduce((s, b) => s + b.length, 0); |
| 165 | const out = new Uint8Array(total); |
| 166 | let off = 0; |
| 167 | for (const b of bufs) { out.set(b, off); off += b.length; } |
| 168 | return out.buffer; |
| 169 | } |
| 170 | |
| 171 | // ── R2 key derivation — mirrors S3Backend._key() in backends.py ─────────────── |
| 172 | |
| 173 | function r2Key(objectId) { |
| 174 | // Strip "sha256:" prefix; store as "objects/<hex>" |
| 175 | const hex = objectId.startsWith('sha256:') ? objectId.slice(7) : objectId; |
| 176 | return `objects/${hex}`; |
| 177 | } |
| 178 | |
| 179 | // ── Worker entry point ──────────────────────────────────────────────────────── |
| 180 | |
| 181 | export default { |
| 182 | async fetch(request, env) { |
| 183 | // Only accept POST to /{owner}/{slug}/push/object-pack |
| 184 | if (request.method !== 'POST') { |
| 185 | return new Response('Method Not Allowed', { status: 405 }); |
| 186 | } |
| 187 | |
| 188 | const url = new URL(request.url); |
| 189 | const parts = url.pathname.split('/').filter(Boolean); |
| 190 | // Expect: ["<owner>", "<slug>", "push", "object-pack"] |
| 191 | if (parts.length < 4 || parts[2] !== 'push' || parts[3] !== 'object-pack') { |
| 192 | return new Response('Not Found', { status: 404 }); |
| 193 | } |
| 194 | const owner = parts[0]; |
| 195 | const slug = parts[1]; |
| 196 | |
| 197 | // Read and decode the msgpack body |
| 198 | const bodyBuf = await request.arrayBuffer(); |
| 199 | let pack; |
| 200 | try { |
| 201 | pack = msgpackDecode(bodyBuf); |
| 202 | } catch (e) { |
| 203 | return new Response(`Bad Request: ${e.message}`, { status: 400 }); |
| 204 | } |
| 205 | |
| 206 | if (!pack || !Array.isArray(pack.objects)) { |
| 207 | return new Response('Bad Request: missing objects array', { status: 400 }); |
| 208 | } |
| 209 | |
| 210 | // ── Step 1: write all objects to R2 in parallel (native binding, ~1 ms each) ── |
| 211 | const stored = []; |
| 212 | const skipped = []; |
| 213 | |
| 214 | await Promise.all(pack.objects.map(async (obj) => { |
| 215 | if (!obj.object_id || obj.content == null) return; |
| 216 | const key = r2Key(obj.object_id); |
| 217 | // R2.put is idempotent — safe to re-push existing objects. |
| 218 | // head() first to avoid unnecessary writes for large repos with many |
| 219 | // shared objects. Skip head() for small packs (≤50) to keep it fast. |
| 220 | let needsWrite = true; |
| 221 | if (pack.objects.length > 50) { |
| 222 | const existing = await env.BUCKET.head(key); |
| 223 | if (existing) { skipped.push(obj.object_id); needsWrite = false; } |
| 224 | } |
| 225 | if (needsWrite) { |
| 226 | const content = obj.content instanceof ArrayBuffer |
| 227 | ? obj.content |
| 228 | : obj.content.buffer.slice(obj.content.byteOffset, obj.content.byteOffset + obj.content.byteLength); |
| 229 | await env.BUCKET.put(key, content, { |
| 230 | httpMetadata: { contentType: 'application/octet-stream' }, |
| 231 | customMetadata: { object_id: obj.object_id, path: obj.path || '' }, |
| 232 | }); |
| 233 | stored.push({ object_id: obj.object_id, path: obj.path || '', size: content.byteLength }); |
| 234 | } |
| 235 | })); |
| 236 | |
| 237 | // ── Step 2: register stored objects in MuseHub DB (one batch HTTP call) ──── |
| 238 | // Forward the original MSign Authorization header so MuseHub can verify |
| 239 | // the pusher's identity and check repo write permission. |
| 240 | if (stored.length > 0) { |
| 241 | const registerUrl = `${env.MUSEHUB_ORIGIN}/internal/register-objects`; |
| 242 | const registerBody = JSON.stringify({ |
| 243 | owner, |
| 244 | slug, |
| 245 | objects: stored, |
| 246 | }); |
| 247 | const regResp = await fetch(registerUrl, { |
| 248 | method: 'POST', |
| 249 | headers: { |
| 250 | 'Content-Type': 'application/json', |
| 251 | 'Authorization': request.headers.get('Authorization') || '', |
| 252 | 'X-Musehub-Worker-Key': env.MUSEHUB_INTERNAL_KEY, |
| 253 | }, |
| 254 | body: registerBody, |
| 255 | }); |
| 256 | if (!regResp.ok) { |
| 257 | const errText = await regResp.text(); |
| 258 | console.error(`register-objects failed: ${regResp.status} ${errText}`); |
| 259 | return new Response(`Internal Error: DB registration failed`, { status: 502 }); |
| 260 | } |
| 261 | const regJson = await regResp.json(); |
| 262 | // regJson.stored / regJson.skipped are the DB-level counts (dedup) |
| 263 | const dbStored = regJson.stored ?? stored.length; |
| 264 | const dbSkipped = regJson.skipped ?? skipped.length; |
| 265 | const accept = request.headers.get('Accept') || ''; |
| 266 | if (accept.includes('application/x-msgpack')) { |
| 267 | return new Response(msgpackEncode({ stored: dbStored, skipped: dbSkipped }), { |
| 268 | headers: { 'Content-Type': 'application/x-msgpack' }, |
| 269 | }); |
| 270 | } |
| 271 | return Response.json({ stored: dbStored, skipped: dbSkipped }); |
| 272 | } |
| 273 | |
| 274 | // Nothing new was stored — all objects were already in R2. |
| 275 | const accept = request.headers.get('Accept') || ''; |
| 276 | const totalSkipped = pack.objects.filter(o => o.object_id && o.content != null).length; |
| 277 | if (accept.includes('application/x-msgpack')) { |
| 278 | return new Response(msgpackEncode({ stored: 0, skipped: totalSkipped }), { |
| 279 | headers: { 'Content-Type': 'application/x-msgpack' }, |
| 280 | }); |
| 281 | } |
| 282 | return Response.json({ stored: 0, skipped: totalSkipped }); |
| 283 | }, |
| 284 | }; |
File History
1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65
refactor: enforce gRPC framing on all MWP wire traffic
Sonnet 4.6
minor
⚠
153 days ago