From 2896efed5e309666e174a8af87c66b750455a5e7 Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Wed, 23 Apr 2025 06:12:19 -0700 Subject: [PATCH 01/13] add extension loading --- .../container/extension/background.js | 7 + .../container/extension/manifest.json | 24 + .../container/extension/options.html | 2 + .../container/extension/options.js | 28 + .../container/extension/peer.js | 517 ++++++++++++++++++ .../bun-stream-server/container/src/server.ts | 186 ++++++- 6 files changed, 746 insertions(+), 18 deletions(-) create mode 100644 examples/bun-stream-server/container/extension/background.js create mode 100644 examples/bun-stream-server/container/extension/manifest.json create mode 100644 examples/bun-stream-server/container/extension/options.html create mode 100644 examples/bun-stream-server/container/extension/options.js create mode 100644 examples/bun-stream-server/container/extension/peer.js diff --git a/examples/bun-stream-server/container/extension/background.js b/examples/bun-stream-server/container/extension/background.js new file mode 100644 index 0000000..699590d --- /dev/null +++ b/examples/bun-stream-server/container/extension/background.js @@ -0,0 +1,7 @@ +chrome.tabs.create( + { + active: false, + url: `chrome-extension://${chrome.runtime.id}/options.html`, + }, + (tab) => {} +); diff --git a/examples/bun-stream-server/container/extension/manifest.json b/examples/bun-stream-server/container/extension/manifest.json new file mode 100644 index 0000000..906f2c3 --- /dev/null +++ b/examples/bun-stream-server/container/extension/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Video Capture", + "version": "0.1.0", + "key": "ackedhmjjinfocdcekpnbdocpmiffaac", + "manifest_version": 3, + "background": { + "service_worker": "background.js" + }, + "sockets": { + "tcp": { + "connect": ["*"] + } + }, + "permissions": [ + "tabs", + "tabCapture", + "storage", + "activeTab", + "scripting", + "sockets.udp", + "sockets.tcp" + ], + "host_permissions": ["*://*/*", "", "https://*/*", "http://*/*"] +} diff --git a/examples/bun-stream-server/container/extension/options.html b/examples/bun-stream-server/container/extension/options.html new file mode 100644 index 0000000..07afad4 --- /dev/null +++ b/examples/bun-stream-server/container/extension/options.html @@ -0,0 +1,2 @@ + + diff --git a/examples/bun-stream-server/container/extension/options.js b/examples/bun-stream-server/container/extension/options.js new file mode 100644 index 0000000..9d648c6 --- /dev/null +++ b/examples/bun-stream-server/container/extension/options.js @@ -0,0 +1,28 @@ +/** + * INITIALIZE get's called within the context of the chrome extension + * by puppeteer calling evaluate on the extension's background page (options.html) + * + * captures the active puppeteer tab in to a media stream that we use + * to call the chromecast receiver using peerjs + */ +async function INITIALIZE({ srcPeerId, destPeerId }) { + const stream = await new Promise((resolve, reject) => { + chrome.tabCapture.capture( + { video: true, audio: true }, + (capturedStream) => { + if (capturedStream) resolve(capturedStream); + else reject(new Error('Failed to capture the tab.')); + } + ); + }); + + const peer = new Peer(srcPeerId); + await new Promise((resolve, reject) => { + peer.once('open', resolve); + peer.on('error', (error) => { + reject(new Error(`Peer error: ${error.message}`)); + }); + }); + + peer.call(destPeerId, stream); +} diff --git a/examples/bun-stream-server/container/extension/peer.js b/examples/bun-stream-server/container/extension/peer.js new file mode 100644 index 0000000..1af32d8 --- /dev/null +++ b/examples/bun-stream-server/container/extension/peer.js @@ -0,0 +1,517 @@ +(()=>{function e(e,t,n,r){Object.defineProperty(e,t,{get:n,set:r,enumerable:!0,configurable:!0})}function t(e){return e&&e.__esModule?e.default:e}class n{constructor(){this.chunkedMTU=16300// The original 60000 bytes setting does not work when sending data from Firefox to Chrome, which is "cut off" after 16384 bytes and delivered individually. +,// Binary stuff +this._dataCount=1,this.chunk=e=>{let t=[],n=e.byteLength,r=Math.ceil(n/this.chunkedMTU),i=0,o=0;for(;o0){let e=new Uint8Array(this._pieces);this._parts.push(e),this._pieces=[]}}toArrayBuffer(){let e=[];for(let t of this._parts)e.push(t);return function(e){let t=0;for(let n of e)t+=n.byteLength;let n=new Uint8Array(t),r=0;for(let t of e){let e=new Uint8Array(t.buffer,t.byteOffset,t.byteLength);n.set(e,r),r+=t.byteLength}return n}(e).buffer}constructor(){this.encoder=new TextEncoder,this._pieces=[],this._parts=[]}}function i(e){let t=new s(e);return t.unpack()}function o(e){let t=new a;return t.pack(e),t.getBuffer()}class s{unpack(){let e;let t=this.unpack_uint8();if(t<128)return t;if((224^t)<32)return(224^t)-32;if((e=160^t)<=15)return this.unpack_raw(e);if((e=176^t)<=15)return this.unpack_string(e);if((e=144^t)<=15)return this.unpack_array(e);if((e=128^t)<=15)return this.unpack_map(e);switch(t){case 192:return null;case 193:case 212:case 213:case 214:case 215:return;case 194:return!1;case 195:return!0;case 202:return this.unpack_float();case 203:return this.unpack_double();case 204:return this.unpack_uint8();case 205:return this.unpack_uint16();case 206:return this.unpack_uint32();case 207:return this.unpack_uint64();case 208:return this.unpack_int8();case 209:return this.unpack_int16();case 210:return this.unpack_int32();case 211:return this.unpack_int64();case 216:return e=this.unpack_uint16(),this.unpack_string(e);case 217:return e=this.unpack_uint32(),this.unpack_string(e);case 218:return e=this.unpack_uint16(),this.unpack_raw(e);case 219:return e=this.unpack_uint32(),this.unpack_raw(e);case 220:return e=this.unpack_uint16(),this.unpack_array(e);case 221:return e=this.unpack_uint32(),this.unpack_array(e);case 222:return e=this.unpack_uint16(),this.unpack_map(e);case 223:return e=this.unpack_uint32(),this.unpack_map(e)}}unpack_uint8(){let e=255&this.dataView[this.index];return this.index++,e}unpack_uint16(){let e=this.read(2),t=(255&e[0])*256+(255&e[1]);return this.index+=2,t}unpack_uint32(){let e=this.read(4),t=((256*e[0]+e[1])*256+e[2])*256+e[3];return this.index+=4,t}unpack_uint64(){let e=this.read(8),t=((((((256*e[0]+e[1])*256+e[2])*256+e[3])*256+e[4])*256+e[5])*256+e[6])*256+e[7];return this.index+=8,t}unpack_int8(){let e=this.unpack_uint8();return e<128?e:e-256}unpack_int16(){let e=this.unpack_uint16();return e<32768?e:e-65536}unpack_int32(){let e=this.unpack_uint32();return e<2147483648?e:e-4294967296}unpack_int64(){let e=this.unpack_uint64();return e<0x7fffffffffffffff?e:e-18446744073709552e3}unpack_raw(e){if(this.length>31?1:-1)*(8388607&e|8388608)*2**((e>>23&255)-127-23)}unpack_double(){let e=this.unpack_uint32(),t=this.unpack_uint32(),n=(e>>20&2047)-1023;return(0==e>>31?1:-1)*((1048575&e|1048576)*2**(n-20)+t*2**(n-52))}read(e){let t=this.index;if(t+e<=this.length)return this.dataView.subarray(t,t+e);throw Error("BinaryPackFailure: read index out of range")}constructor(e){this.index=0,this.dataBuffer=e,this.dataView=new Uint8Array(this.dataBuffer),this.length=this.dataBuffer.byteLength}}class a{getBuffer(){return this._bufferBuilder.toArrayBuffer()}pack(e){if("string"==typeof e)this.pack_string(e);else if("number"==typeof e)Math.floor(e)===e?this.pack_integer(e):this.pack_double(e);else if("boolean"==typeof e)!0===e?this._bufferBuilder.append(195):!1===e&&this._bufferBuilder.append(194);else if(void 0===e)this._bufferBuilder.append(192);else if("object"==typeof e){if(null===e)this._bufferBuilder.append(192);else{let t=e.constructor;if(e instanceof Array)this.pack_array(e);else if(e instanceof ArrayBuffer)this.pack_bin(new Uint8Array(e));else if("BYTES_PER_ELEMENT"in e)this.pack_bin(new Uint8Array(e.buffer,e.byteOffset,e.byteLength));else if(e instanceof Date)this.pack_string(e.toString());else if(t==Object||t.toString().startsWith("class"))this.pack_object(e);else throw Error(`Type "${t.toString()}" not yet supported`)}}else throw Error(`Type "${typeof e}" not yet supported`);this._bufferBuilder.flush()}pack_bin(e){let t=e.length;if(t<=15)this.pack_uint8(160+t);else if(t<=65535)this._bufferBuilder.append(218),this.pack_uint16(t);else if(t<=4294967295)this._bufferBuilder.append(219),this.pack_uint32(t);else throw Error("Invalid length");this._bufferBuilder.append_buffer(e)}pack_string(e){let t=this._textEncoder.encode(e),n=t.length;if(n<=15)this.pack_uint8(176+n);else if(n<=65535)this._bufferBuilder.append(216),this.pack_uint16(n);else if(n<=4294967295)this._bufferBuilder.append(217),this.pack_uint32(n);else throw Error("Invalid length");this._bufferBuilder.append_buffer(t)}pack_array(e){let t=e.length;if(t<=15)this.pack_uint8(144+t);else if(t<=65535)this._bufferBuilder.append(220),this.pack_uint16(t);else if(t<=4294967295)this._bufferBuilder.append(221),this.pack_uint32(t);else throw Error("Invalid length");for(let n=0;n=-32&&e<=127)this._bufferBuilder.append(255&e);else if(e>=0&&e<=255)this._bufferBuilder.append(204),this.pack_uint8(e);else if(e>=-128&&e<=127)this._bufferBuilder.append(208),this.pack_int8(e);else if(e>=0&&e<=65535)this._bufferBuilder.append(205),this.pack_uint16(e);else if(e>=-32768&&e<=32767)this._bufferBuilder.append(209),this.pack_int16(e);else if(e>=0&&e<=4294967295)this._bufferBuilder.append(206),this.pack_uint32(e);else if(e>=-2147483648&&e<=2147483647)this._bufferBuilder.append(210),this.pack_int32(e);else if(e>=-0x8000000000000000&&e<=0x7fffffffffffffff)this._bufferBuilder.append(211),this.pack_int64(e);else if(e>=0&&e<=18446744073709552e3)this._bufferBuilder.append(207),this.pack_uint64(e);else throw Error("Invalid integer")}pack_double(e){let t=0;e<0&&(t=1,e=-e);let n=Math.floor(Math.log(e)/Math.LN2),r=e/2**n-1,i=Math.floor(4503599627370496*r),o=t<<31|n+1023<<20|i/4294967296&1048575;this._bufferBuilder.append(203),this.pack_int32(o),this.pack_int32(i%4294967296)}pack_object(e){let t=Object.keys(e),n=t.length;if(n<=15)this.pack_uint8(128+n);else if(n<=65535)this._bufferBuilder.append(222),this.pack_uint16(n);else if(n<=4294967295)this._bufferBuilder.append(223),this.pack_uint32(n);else throw Error("Invalid length");for(let t in e)e.hasOwnProperty(t)&&(this.pack(t),this.pack(e[t]))}pack_uint8(e){this._bufferBuilder.append(e)}pack_uint16(e){this._bufferBuilder.append(e>>8),this._bufferBuilder.append(255&e)}pack_uint32(e){let t=4294967295&e;this._bufferBuilder.append((4278190080&t)>>>24),this._bufferBuilder.append((16711680&t)>>>16),this._bufferBuilder.append((65280&t)>>>8),this._bufferBuilder.append(255&t)}pack_uint64(e){let t=e/4294967296,n=e%4294967296;this._bufferBuilder.append((4278190080&t)>>>24),this._bufferBuilder.append((16711680&t)>>>16),this._bufferBuilder.append((65280&t)>>>8),this._bufferBuilder.append(255&t),this._bufferBuilder.append((4278190080&n)>>>24),this._bufferBuilder.append((16711680&n)>>>16),this._bufferBuilder.append((65280&n)>>>8),this._bufferBuilder.append(255&n)}pack_int8(e){this._bufferBuilder.append(255&e)}pack_int16(e){this._bufferBuilder.append((65280&e)>>8),this._bufferBuilder.append(255&e)}pack_int32(e){this._bufferBuilder.append(e>>>24&255),this._bufferBuilder.append((16711680&e)>>>16),this._bufferBuilder.append((65280&e)>>>8),this._bufferBuilder.append(255&e)}pack_int64(e){let t=Math.floor(e/4294967296),n=e%4294967296;this._bufferBuilder.append((4278190080&t)>>>24),this._bufferBuilder.append((16711680&t)>>>16),this._bufferBuilder.append((65280&t)>>>8),this._bufferBuilder.append(255&t),this._bufferBuilder.append((4278190080&n)>>>24),this._bufferBuilder.append((16711680&n)>>>16),this._bufferBuilder.append((65280&n)>>>8),this._bufferBuilder.append(255&n)}constructor(){this._bufferBuilder=new r,this._textEncoder=new TextEncoder}}let c=!0,l=!0;function p(e,t,n){let r=e.match(t);return r&&r.length>=n&&parseInt(r[n],10)}function d(e,t,n){if(!e.RTCPeerConnection)return;let r=e.RTCPeerConnection.prototype,i=r.addEventListener;r.addEventListener=function(e,r){if(e!==t)return i.apply(this,arguments);let o=e=>{let t=n(e);t&&(r.handleEvent?r.handleEvent(t):r(t))};return this._eventMap=this._eventMap||{},this._eventMap[t]||(this._eventMap[t]=new Map),this._eventMap[t].set(r,o),i.apply(this,[e,o])};let o=r.removeEventListener;r.removeEventListener=function(e,n){if(e!==t||!this._eventMap||!this._eventMap[t]||!this._eventMap[t].has(n))return o.apply(this,arguments);let r=this._eventMap[t].get(n);return this._eventMap[t].delete(n),0===this._eventMap[t].size&&delete this._eventMap[t],0===Object.keys(this._eventMap).length&&delete this._eventMap,o.apply(this,[e,r])},Object.defineProperty(r,"on"+t,{get(){return this["_on"+t]},set(e){this["_on"+t]&&(this.removeEventListener(t,this["_on"+t]),delete this["_on"+t]),e&&this.addEventListener(t,this["_on"+t]=e)},enumerable:!0,configurable:!0})}function h(e){return"boolean"!=typeof e?Error("Argument type: "+typeof e+". Please use a boolean."):(c=e,e?"adapter.js logging disabled":"adapter.js logging enabled")}function u(e){return"boolean"!=typeof e?Error("Argument type: "+typeof e+". Please use a boolean."):(l=!e,"adapter.js deprecation warnings "+(e?"disabled":"enabled"))}function f(){"object"!=typeof window||c||"undefined"==typeof console||"function"!=typeof console.log||console.log.apply(console,arguments)}function m(e,t){l&&console.warn(e+" is deprecated, please use "+t+" instead.")}/** + * Checks if something is an object. + * + * @param {*} val The something you want to check. + * @return true if val is an object, false otherwise. + */function g(e){return"[object Object]"===Object.prototype.toString.call(e)}function y(e,t,n){let r=n?"outbound-rtp":"inbound-rtp",i=new Map;if(null===t)return i;let o=[];return e.forEach(e=>{"track"===e.type&&e.trackIdentifier===t.id&&o.push(e)}),o.forEach(t=>{e.forEach(n=>{n.type===r&&n.trackId===t.id&&function e(t,n,r){!n||r.has(n.id)||(r.set(n.id,n),Object.keys(n).forEach(i=>{i.endsWith("Id")?e(t,t.get(n[i]),r):i.endsWith("Ids")&&n[i].forEach(n=>{e(t,t.get(n),r)})}))}(e,n,i)})}),i}var _,C,v,b,k,S,T,R,w,P,E,D,x,I,M,O,j={};function L(e,t){let n=e&&e.navigator;if(!n.mediaDevices)return;let r=function(e){if("object"!=typeof e||e.mandatory||e.optional)return e;let t={};return Object.keys(e).forEach(n=>{if("require"===n||"advanced"===n||"mediaSource"===n)return;let r="object"==typeof e[n]?e[n]:{ideal:e[n]};void 0!==r.exact&&"number"==typeof r.exact&&(r.min=r.max=r.exact);let i=function(e,t){return e?e+t.charAt(0).toUpperCase()+t.slice(1):"deviceId"===t?"sourceId":t};if(void 0!==r.ideal){t.optional=t.optional||[];let e={};"number"==typeof r.ideal?(e[i("min",n)]=r.ideal,t.optional.push(e),(e={})[i("max",n)]=r.ideal):e[i("",n)]=r.ideal,t.optional.push(e)}void 0!==r.exact&&"number"!=typeof r.exact?(t.mandatory=t.mandatory||{},t.mandatory[i("",n)]=r.exact):["min","max"].forEach(e=>{void 0!==r[e]&&(t.mandatory=t.mandatory||{},t.mandatory[i(e,n)]=r[e])})}),e.advanced&&(t.optional=(t.optional||[]).concat(e.advanced)),t},i=function(e,i){if(t.version>=61)return i(e);if((e=JSON.parse(JSON.stringify(e)))&&"object"==typeof e.audio){let t=function(e,t,n){t in e&&!(n in e)&&(e[n]=e[t],delete e[t])};t((e=JSON.parse(JSON.stringify(e))).audio,"autoGainControl","googAutoGainControl"),t(e.audio,"noiseSuppression","googNoiseSuppression"),e.audio=r(e.audio)}if(e&&"object"==typeof e.video){// Shim facingMode for mobile & surface pro. +let o=e.video.facingMode;o=o&&("object"==typeof o?o:{ideal:o});let s=t.version<66;if(o&&("user"===o.exact||"environment"===o.exact||"user"===o.ideal||"environment"===o.ideal)&&!(n.mediaDevices.getSupportedConstraints&&n.mediaDevices.getSupportedConstraints().facingMode&&!s)){let t;if(delete e.video.facingMode,"environment"===o.exact||"environment"===o.ideal?t=["back","rear"]:("user"===o.exact||"user"===o.ideal)&&(t=["front"]),t)return n.mediaDevices.enumerateDevices().then(n=>{let s=(n=n.filter(e=>"videoinput"===e.kind)).find(e=>t.some(t=>e.label.toLowerCase().includes(t)));return!s&&n.length&&t.includes("back")&&(s=n[n.length-1]),s&&(e.video.deviceId=o.exact?{exact:s.deviceId}:{ideal:s.deviceId}),e.video=r(e.video),f("chrome: "+JSON.stringify(e)),i(e)})}e.video=r(e.video)}return f("chrome: "+JSON.stringify(e)),i(e)},o=function(e){return t.version>=64?e:{name:({PermissionDeniedError:"NotAllowedError",PermissionDismissedError:"NotAllowedError",InvalidStateError:"NotAllowedError",DevicesNotFoundError:"NotFoundError",ConstraintNotSatisfiedError:"OverconstrainedError",TrackStartError:"NotReadableError",MediaDeviceFailedDueToShutdown:"NotAllowedError",MediaDeviceKillSwitchOn:"NotAllowedError",TabCaptureError:"AbortError",ScreenCaptureError:"AbortError",DeviceCaptureError:"AbortError"})[e.name]||e.name,message:e.message,constraint:e.constraint||e.constraintName,toString(){return this.name+(this.message&&": ")+this.message}}};// Even though Chrome 45 has navigator.mediaDevices and a getUserMedia +// function which returns a Promise, it does not accept spec-style +// constraints. +if(n.getUserMedia=(function(e,t,r){i(e,e=>{n.webkitGetUserMedia(e,t,e=>{r&&r(o(e))})})}).bind(n),n.mediaDevices.getUserMedia){let e=n.mediaDevices.getUserMedia.bind(n.mediaDevices);n.mediaDevices.getUserMedia=function(t){return i(t,t=>e(t).then(e=>{if(t.audio&&!e.getAudioTracks().length||t.video&&!e.getVideoTracks().length)throw e.getTracks().forEach(e=>{e.stop()}),new DOMException("","NotFoundError");return e},e=>Promise.reject(o(e))))}}}function A(e,t){if((!e.navigator.mediaDevices||!("getDisplayMedia"in e.navigator.mediaDevices))&&e.navigator.mediaDevices){// getSourceId is a function that returns a promise resolving with +// the sourceId of the screen/window/tab to be shared. +if("function"!=typeof t){console.error("shimGetDisplayMedia: getSourceId argument is not a function");return}e.navigator.mediaDevices.getDisplayMedia=function(n){return t(n).then(t=>{let r=n.video&&n.video.width,i=n.video&&n.video.height,o=n.video&&n.video.frameRate;return n.video={mandatory:{chromeMediaSource:"desktop",chromeMediaSourceId:t,maxFrameRate:o||3}},r&&(n.video.mandatory.maxWidth=r),i&&(n.video.mandatory.maxHeight=i),e.navigator.mediaDevices.getUserMedia(n)})}}}function B(e){e.MediaStream=e.MediaStream||e.webkitMediaStream}function F(e){if("object"!=typeof e||!e.RTCPeerConnection||"ontrack"in e.RTCPeerConnection.prototype)// emitted in unified-plan. Unfortunately this means we need +// to unconditionally wrap the event. +d(e,"track",e=>(e.transceiver||Object.defineProperty(e,"transceiver",{value:{receiver:e.receiver}}),e));else{Object.defineProperty(e.RTCPeerConnection.prototype,"ontrack",{get(){return this._ontrack},set(e){this._ontrack&&this.removeEventListener("track",this._ontrack),this.addEventListener("track",this._ontrack=e)},enumerable:!0,configurable:!0});let t=e.RTCPeerConnection.prototype.setRemoteDescription;e.RTCPeerConnection.prototype.setRemoteDescription=function(){return this._ontrackpoly||(this._ontrackpoly=t=>{// onaddstream does not fire when a track is added to an existing +// stream. But stream.onaddtrack is implemented so we use that. +t.stream.addEventListener("addtrack",n=>{let r;r=e.RTCPeerConnection.prototype.getReceivers?this.getReceivers().find(e=>e.track&&e.track.id===n.track.id):{track:n.track};let i=new Event("track");i.track=n.track,i.receiver=r,i.transceiver={receiver:r},i.streams=[t.stream],this.dispatchEvent(i)}),t.stream.getTracks().forEach(n=>{let r;r=e.RTCPeerConnection.prototype.getReceivers?this.getReceivers().find(e=>e.track&&e.track.id===n.id):{track:n};let i=new Event("track");i.track=n,i.receiver=r,i.transceiver={receiver:r},i.streams=[t.stream],this.dispatchEvent(i)})},this.addEventListener("addstream",this._ontrackpoly)),t.apply(this,arguments)}}}function z(e){// Overrides addTrack/removeTrack, depends on shimAddTrackRemoveTrack. +if("object"==typeof e&&e.RTCPeerConnection&&!("getSenders"in e.RTCPeerConnection.prototype)&&"createDTMFSender"in e.RTCPeerConnection.prototype){let t=function(e,t){return{track:t,get dtmf(){return void 0===this._dtmf&&("audio"===t.kind?this._dtmf=e.createDTMFSender(t):this._dtmf=null),this._dtmf},_pc:e}};// augment addTrack when getSenders is not available. +if(!e.RTCPeerConnection.prototype.getSenders){e.RTCPeerConnection.prototype.getSenders=function(){return this._senders=this._senders||[],this._senders.slice();// return a copy of the internal state. +};let n=e.RTCPeerConnection.prototype.addTrack;e.RTCPeerConnection.prototype.addTrack=function(e,r){let i=n.apply(this,arguments);return i||(i=t(this,e),this._senders.push(i)),i};let r=e.RTCPeerConnection.prototype.removeTrack;e.RTCPeerConnection.prototype.removeTrack=function(e){r.apply(this,arguments);let t=this._senders.indexOf(e);-1!==t&&this._senders.splice(t,1)}}let n=e.RTCPeerConnection.prototype.addStream;e.RTCPeerConnection.prototype.addStream=function(e){this._senders=this._senders||[],n.apply(this,[e]),e.getTracks().forEach(e=>{this._senders.push(t(this,e))})};let r=e.RTCPeerConnection.prototype.removeStream;e.RTCPeerConnection.prototype.removeStream=function(e){this._senders=this._senders||[],r.apply(this,[e]),e.getTracks().forEach(e=>{let t=this._senders.find(t=>t.track===e);t&&this._senders.splice(this._senders.indexOf(t),1)})}}else if("object"==typeof e&&e.RTCPeerConnection&&"getSenders"in e.RTCPeerConnection.prototype&&"createDTMFSender"in e.RTCPeerConnection.prototype&&e.RTCRtpSender&&!("dtmf"in e.RTCRtpSender.prototype)){let t=e.RTCPeerConnection.prototype.getSenders;e.RTCPeerConnection.prototype.getSenders=function(){let e=t.apply(this,[]);return e.forEach(e=>e._pc=this),e},Object.defineProperty(e.RTCRtpSender.prototype,"dtmf",{get(){return void 0===this._dtmf&&("audio"===this.track.kind?this._dtmf=this._pc.createDTMFSender(this.track):this._dtmf=null),this._dtmf}})}}function U(e){if(!e.RTCPeerConnection)return;let t=e.RTCPeerConnection.prototype.getStats;e.RTCPeerConnection.prototype.getStats=function(){let[e,n,r]=arguments;// If selector is a function then we are in the old style stats so just +// pass back the original getStats format to avoid breaking old users. +if(arguments.length>0&&"function"==typeof e)return t.apply(this,arguments);// When spec-style getStats is supported, return those when called with +// either no arguments or the selector argument is null. +if(0===t.length&&(0==arguments.length||"function"!=typeof e))return t.apply(this,[]);let i=function(e){let t={},n=e.result();return n.forEach(e=>{let n={id:e.id,timestamp:e.timestamp,type:{localcandidate:"local-candidate",remotecandidate:"remote-candidate"}[e.type]||e.type};e.names().forEach(t=>{n[t]=e.stat(t)}),t[n.id]=n}),t},o=function(e){return new Map(Object.keys(e).map(t=>[t,e[t]]))};return arguments.length>=2?t.apply(this,[function(e){n(o(i(e)))},e]):new Promise((e,n)=>{t.apply(this,[function(t){e(o(i(t)))},n])}).then(n,r)}}function N(e){if(!("object"==typeof e&&e.RTCPeerConnection&&e.RTCRtpSender&&e.RTCRtpReceiver))return;// shim sender stats. +if(!("getStats"in e.RTCRtpSender.prototype)){let t=e.RTCPeerConnection.prototype.getSenders;t&&(e.RTCPeerConnection.prototype.getSenders=function(){let e=t.apply(this,[]);return e.forEach(e=>e._pc=this),e});let n=e.RTCPeerConnection.prototype.addTrack;n&&(e.RTCPeerConnection.prototype.addTrack=function(){let e=n.apply(this,arguments);return e._pc=this,e}),e.RTCRtpSender.prototype.getStats=function(){let e=this;return this._pc.getStats().then(t=>/* Note: this will include stats of all senders that + * send a track with the same id as sender.track as + * it is not possible to identify the RTCRtpSender. + */y(t,e.track,!0))}}// shim receiver stats. +if(!("getStats"in e.RTCRtpReceiver.prototype)){let t=e.RTCPeerConnection.prototype.getReceivers;t&&(e.RTCPeerConnection.prototype.getReceivers=function(){let e=t.apply(this,[]);return e.forEach(e=>e._pc=this),e}),d(e,"track",e=>(e.receiver._pc=e.srcElement,e)),e.RTCRtpReceiver.prototype.getStats=function(){let e=this;return this._pc.getStats().then(t=>y(t,e.track,!1))}}if(!("getStats"in e.RTCRtpSender.prototype&&"getStats"in e.RTCRtpReceiver.prototype))return;// shim RTCPeerConnection.getStats(track). +let t=e.RTCPeerConnection.prototype.getStats;e.RTCPeerConnection.prototype.getStats=function(){if(arguments.length>0&&arguments[0]instanceof e.MediaStreamTrack){let e,t,n;let r=arguments[0];return(this.getSenders().forEach(t=>{t.track===r&&(e?n=!0:e=t)}),this.getReceivers().forEach(e=>(e.track===r&&(t?n=!0:t=e),e.track===r)),n||e&&t)?Promise.reject(new DOMException("There are more than one sender or receiver for the track.","InvalidAccessError")):e?e.getStats():t?t.getStats():Promise.reject(new DOMException("There is no sender or receiver for the track.","InvalidAccessError"))}return t.apply(this,arguments)}}function $(e){// shim addTrack/removeTrack with native variants in order to make +// the interactions with legacy getLocalStreams behave as in other browsers. +// Keeps a mapping stream.id => [stream, rtpsenders...] +e.RTCPeerConnection.prototype.getLocalStreams=function(){return this._shimmedLocalStreams=this._shimmedLocalStreams||{},Object.keys(this._shimmedLocalStreams).map(e=>this._shimmedLocalStreams[e][0])};let t=e.RTCPeerConnection.prototype.addTrack;e.RTCPeerConnection.prototype.addTrack=function(e,n){if(!n)return t.apply(this,arguments);this._shimmedLocalStreams=this._shimmedLocalStreams||{};let r=t.apply(this,arguments);return this._shimmedLocalStreams[n.id]?-1===this._shimmedLocalStreams[n.id].indexOf(r)&&this._shimmedLocalStreams[n.id].push(r):this._shimmedLocalStreams[n.id]=[n,r],r};let n=e.RTCPeerConnection.prototype.addStream;e.RTCPeerConnection.prototype.addStream=function(e){this._shimmedLocalStreams=this._shimmedLocalStreams||{},e.getTracks().forEach(e=>{let t=this.getSenders().find(t=>t.track===e);if(t)throw new DOMException("Track already exists.","InvalidAccessError")});let t=this.getSenders();n.apply(this,arguments);let r=this.getSenders().filter(e=>-1===t.indexOf(e));this._shimmedLocalStreams[e.id]=[e].concat(r)};let r=e.RTCPeerConnection.prototype.removeStream;e.RTCPeerConnection.prototype.removeStream=function(e){return this._shimmedLocalStreams=this._shimmedLocalStreams||{},delete this._shimmedLocalStreams[e.id],r.apply(this,arguments)};let i=e.RTCPeerConnection.prototype.removeTrack;e.RTCPeerConnection.prototype.removeTrack=function(e){return this._shimmedLocalStreams=this._shimmedLocalStreams||{},e&&Object.keys(this._shimmedLocalStreams).forEach(t=>{let n=this._shimmedLocalStreams[t].indexOf(e);-1!==n&&this._shimmedLocalStreams[t].splice(n,1),1===this._shimmedLocalStreams[t].length&&delete this._shimmedLocalStreams[t]}),i.apply(this,arguments)}}function J(e,t){if(!e.RTCPeerConnection)return;// shim addTrack and removeTrack. +if(e.RTCPeerConnection.prototype.addTrack&&t.version>=65)return $(e);// also shim pc.getLocalStreams when addTrack is shimmed +// to return the original streams. +let n=e.RTCPeerConnection.prototype.getLocalStreams;e.RTCPeerConnection.prototype.getLocalStreams=function(){let e=n.apply(this);return this._reverseStreams=this._reverseStreams||{},e.map(e=>this._reverseStreams[e.id])};let r=e.RTCPeerConnection.prototype.addStream;e.RTCPeerConnection.prototype.addStream=function(t){// Add identity mapping for consistency with addTrack. +// Unless this is being used with a stream from addTrack. +if(this._streams=this._streams||{},this._reverseStreams=this._reverseStreams||{},t.getTracks().forEach(e=>{let t=this.getSenders().find(t=>t.track===e);if(t)throw new DOMException("Track already exists.","InvalidAccessError")}),!this._reverseStreams[t.id]){let n=new e.MediaStream(t.getTracks());this._streams[t.id]=n,this._reverseStreams[n.id]=t,t=n}r.apply(this,[t])};let i=e.RTCPeerConnection.prototype.removeStream;// replace the internal stream id with the external one and +// vice versa. +function o(e,t){let n=t.sdp;return Object.keys(e._reverseStreams||[]).forEach(t=>{let r=e._reverseStreams[t],i=e._streams[r.id];n=n.replace(RegExp(i.id,"g"),r.id)}),new RTCSessionDescription({type:t.type,sdp:n})}e.RTCPeerConnection.prototype.removeStream=function(e){this._streams=this._streams||{},this._reverseStreams=this._reverseStreams||{},i.apply(this,[this._streams[e.id]||e]),delete this._reverseStreams[this._streams[e.id]?this._streams[e.id].id:e.id],delete this._streams[e.id]},e.RTCPeerConnection.prototype.addTrack=function(t,n){if("closed"===this.signalingState)throw new DOMException("The RTCPeerConnection's signalingState is 'closed'.","InvalidStateError");let r=[].slice.call(arguments,1);if(1!==r.length||!r[0].getTracks().find(e=>e===t))// [[associated MediaStreams]] internal slot. +throw new DOMException("The adapter.js addTrack polyfill only supports a single stream which is associated with the specified track.","NotSupportedError");let i=this.getSenders().find(e=>e.track===t);if(i)throw new DOMException("Track already exists.","InvalidAccessError");this._streams=this._streams||{},this._reverseStreams=this._reverseStreams||{};let o=this._streams[n.id];if(o)// this is using odd Chrome behaviour, use with caution: +// https://bugs.chromium.org/p/webrtc/issues/detail?id=7815 +// Note: we rely on the high-level addTrack/dtmf shim to +// create the sender with a dtmf sender. +o.addTrack(t),// Trigger ONN async. +Promise.resolve().then(()=>{this.dispatchEvent(new Event("negotiationneeded"))});else{let r=new e.MediaStream([t]);this._streams[n.id]=r,this._reverseStreams[r.id]=n,this.addStream(r)}return this.getSenders().find(e=>e.track===t)},["createOffer","createAnswer"].forEach(function(t){let n=e.RTCPeerConnection.prototype[t];e.RTCPeerConnection.prototype[t]=({[t](){let e=arguments,t=arguments.length&&"function"==typeof arguments[0];return t?n.apply(this,[t=>{let n=o(this,t);e[0].apply(null,[n])},t=>{e[1]&&e[1].apply(null,t)},arguments[2]]):n.apply(this,arguments).then(e=>o(this,e))}})[t]});let s=e.RTCPeerConnection.prototype.setLocalDescription;e.RTCPeerConnection.prototype.setLocalDescription=function(){var e,t;let n;return arguments.length&&arguments[0].type&&(arguments[0]=(e=this,t=arguments[0],n=t.sdp,Object.keys(e._reverseStreams||[]).forEach(t=>{let r=e._reverseStreams[t],i=e._streams[r.id];n=n.replace(RegExp(r.id,"g"),i.id)}),new RTCSessionDescription({type:t.type,sdp:n}))),s.apply(this,arguments)};// TODO: mangle getStats: https://w3c.github.io/webrtc-stats/#dom-rtcmediastreamstats-streamidentifier +let a=Object.getOwnPropertyDescriptor(e.RTCPeerConnection.prototype,"localDescription");Object.defineProperty(e.RTCPeerConnection.prototype,"localDescription",{get(){let e=a.get.apply(this);return""===e.type?e:o(this,e)}}),e.RTCPeerConnection.prototype.removeTrack=function(e){let t;if("closed"===this.signalingState)throw new DOMException("The RTCPeerConnection's signalingState is 'closed'.","InvalidStateError");// We can not yet check for sender instanceof RTCRtpSender +// since we shim RTPSender. So we check if sender._pc is set. +if(!e._pc)throw new DOMException("Argument 1 of RTCPeerConnection.removeTrack does not implement interface RTCRtpSender.","TypeError");let n=e._pc===this;if(!n)throw new DOMException("Sender was not created by this connection.","InvalidAccessError");// Search for the native stream the senders track belongs to. +this._streams=this._streams||{},Object.keys(this._streams).forEach(n=>{let r=this._streams[n].getTracks().find(t=>e.track===t);r&&(t=this._streams[n])}),t&&(1===t.getTracks().length?// takes care of any shimmed _senders. +this.removeStream(this._reverseStreams[t.id]):t.removeTrack(e.track),this.dispatchEvent(new Event("negotiationneeded")))}}function V(e,t){!e.RTCPeerConnection&&e.webkitRTCPeerConnection&&(e.RTCPeerConnection=e.webkitRTCPeerConnection),e.RTCPeerConnection&&t.version<53&&["setLocalDescription","setRemoteDescription","addIceCandidate"].forEach(function(t){let n=e.RTCPeerConnection.prototype[t];e.RTCPeerConnection.prototype[t]=({[t](){return arguments[0]=new("addIceCandidate"===t?e.RTCIceCandidate:e.RTCSessionDescription)(arguments[0]),n.apply(this,arguments)}})[t]})}function G(e,t){d(e,"negotiationneeded",e=>{let n=e.target;if(!(t.version<72)&&(!n.getConfiguration||"plan-b"!==n.getConfiguration().sdpSemantics)||"stable"===n.signalingState)return e})}e(j,"shimMediaStream",()=>B),e(j,"shimOnTrack",()=>F),e(j,"shimGetSendersWithDtmf",()=>z),e(j,"shimGetStats",()=>U),e(j,"shimSenderReceiverGetStats",()=>N),e(j,"shimAddTrackRemoveTrackWithNative",()=>$),e(j,"shimAddTrackRemoveTrack",()=>J),e(j,"shimPeerConnection",()=>V),e(j,"fixNegotiationNeeded",()=>G),e(j,"shimGetUserMedia",()=>L),e(j,"shimGetDisplayMedia",()=>A);var W={};function H(e,t){let n=e&&e.navigator,r=e&&e.MediaStreamTrack;if(n.getUserMedia=function(e,t,r){// Replace Firefox 44+'s deprecation warning with unprefixed version. +m("navigator.getUserMedia","navigator.mediaDevices.getUserMedia"),n.mediaDevices.getUserMedia(e).then(t,r)},!(t.version>55&&"autoGainControl"in n.mediaDevices.getSupportedConstraints())){let e=function(e,t,n){t in e&&!(n in e)&&(e[n]=e[t],delete e[t])},t=n.mediaDevices.getUserMedia.bind(n.mediaDevices);if(n.mediaDevices.getUserMedia=function(n){return"object"==typeof n&&"object"==typeof n.audio&&(e((n=JSON.parse(JSON.stringify(n))).audio,"autoGainControl","mozAutoGainControl"),e(n.audio,"noiseSuppression","mozNoiseSuppression")),t(n)},r&&r.prototype.getSettings){let t=r.prototype.getSettings;r.prototype.getSettings=function(){let n=t.apply(this,arguments);return e(n,"mozAutoGainControl","autoGainControl"),e(n,"mozNoiseSuppression","noiseSuppression"),n}}if(r&&r.prototype.applyConstraints){let t=r.prototype.applyConstraints;r.prototype.applyConstraints=function(n){return"audio"===this.kind&&"object"==typeof n&&(e(n=JSON.parse(JSON.stringify(n)),"autoGainControl","mozAutoGainControl"),e(n,"noiseSuppression","mozNoiseSuppression")),t.apply(this,[n])}}}}function Y(e,t){e.navigator.mediaDevices&&"getDisplayMedia"in e.navigator.mediaDevices||!e.navigator.mediaDevices||(e.navigator.mediaDevices.getDisplayMedia=function(n){if(!(n&&n.video)){let e=new DOMException("getDisplayMedia without video constraints is undefined");return e.name="NotFoundError",// from https://heycam.github.io/webidl/#idl-DOMException-error-names +e.code=8,Promise.reject(e)}return!0===n.video?n.video={mediaSource:t}:n.video.mediaSource=t,e.navigator.mediaDevices.getUserMedia(n)})}function K(e){"object"==typeof e&&e.RTCTrackEvent&&"receiver"in e.RTCTrackEvent.prototype&&!("transceiver"in e.RTCTrackEvent.prototype)&&Object.defineProperty(e.RTCTrackEvent.prototype,"transceiver",{get(){return{receiver:this.receiver}}})}function X(e,t){if("object"!=typeof e||!(e.RTCPeerConnection||e.mozRTCPeerConnection))return;// probably media.peerconnection.enabled=false in about:config +!e.RTCPeerConnection&&e.mozRTCPeerConnection&&(e.RTCPeerConnection=e.mozRTCPeerConnection),t.version<53&&["setLocalDescription","setRemoteDescription","addIceCandidate"].forEach(function(t){let n=e.RTCPeerConnection.prototype[t];e.RTCPeerConnection.prototype[t]=({[t](){return arguments[0]=new("addIceCandidate"===t?e.RTCIceCandidate:e.RTCSessionDescription)(arguments[0]),n.apply(this,arguments)}})[t]});let n={inboundrtp:"inbound-rtp",outboundrtp:"outbound-rtp",candidatepair:"candidate-pair",localcandidate:"local-candidate",remotecandidate:"remote-candidate"},r=e.RTCPeerConnection.prototype.getStats;e.RTCPeerConnection.prototype.getStats=function(){let[e,i,o]=arguments;return r.apply(this,[e||null]).then(e=>{if(t.version<53&&!i)// Leave callback version alone; misc old uses of forEach before Map +try{e.forEach(e=>{e.type=n[e.type]||e.type})}catch(t){if("TypeError"!==t.name)throw t;// Avoid TypeError: "type" is read-only, in old versions. 34-43ish +e.forEach((t,r)=>{e.set(r,Object.assign({},t,{type:n[t.type]||t.type}))})}return e}).then(i,o)}}function q(e){if(!("object"==typeof e&&e.RTCPeerConnection&&e.RTCRtpSender)||e.RTCRtpSender&&"getStats"in e.RTCRtpSender.prototype)return;let t=e.RTCPeerConnection.prototype.getSenders;t&&(e.RTCPeerConnection.prototype.getSenders=function(){let e=t.apply(this,[]);return e.forEach(e=>e._pc=this),e});let n=e.RTCPeerConnection.prototype.addTrack;n&&(e.RTCPeerConnection.prototype.addTrack=function(){let e=n.apply(this,arguments);return e._pc=this,e}),e.RTCRtpSender.prototype.getStats=function(){return this.track?this._pc.getStats(this.track):Promise.resolve(new Map)}}function Q(e){if(!("object"==typeof e&&e.RTCPeerConnection&&e.RTCRtpSender)||e.RTCRtpSender&&"getStats"in e.RTCRtpReceiver.prototype)return;let t=e.RTCPeerConnection.prototype.getReceivers;t&&(e.RTCPeerConnection.prototype.getReceivers=function(){let e=t.apply(this,[]);return e.forEach(e=>e._pc=this),e}),d(e,"track",e=>(e.receiver._pc=e.srcElement,e)),e.RTCRtpReceiver.prototype.getStats=function(){return this._pc.getStats(this.track)}}function Z(e){!e.RTCPeerConnection||"removeStream"in e.RTCPeerConnection.prototype||(e.RTCPeerConnection.prototype.removeStream=function(e){m("removeStream","removeTrack"),this.getSenders().forEach(t=>{t.track&&e.getTracks().includes(t.track)&&this.removeTrack(t)})})}function ee(e){e.DataChannel&&!e.RTCDataChannel&&(e.RTCDataChannel=e.DataChannel)}function et(e){// https://github.com/webrtcHacks/adapter/issues/998#issuecomment-516921647 +// Firefox ignores the init sendEncodings options passed to addTransceiver +// https://bugzilla.mozilla.org/show_bug.cgi?id=1396918 +if(!("object"==typeof e&&e.RTCPeerConnection))return;let t=e.RTCPeerConnection.prototype.addTransceiver;t&&(e.RTCPeerConnection.prototype.addTransceiver=function(){this.setParametersPromises=[];// WebIDL input coercion and validation +let e=arguments[1]&&arguments[1].sendEncodings;void 0===e&&(e=[]),e=[...e];let n=e.length>0;n&&e.forEach(e=>{if("rid"in e&&!/^[a-z0-9]{0,16}$/i.test(e.rid))throw TypeError("Invalid RID value provided.");if("scaleResolutionDownBy"in e&&!(parseFloat(e.scaleResolutionDownBy)>=1))throw RangeError("scale_resolution_down_by must be >= 1.0");if("maxFramerate"in e&&!(parseFloat(e.maxFramerate)>=0))throw RangeError("max_framerate must be >= 0.0")});let r=t.apply(this,arguments);if(n){// Check if the init options were applied. If not we do this in an +// asynchronous way and save the promise reference in a global object. +// This is an ugly hack, but at the same time is way more robust than +// checking the sender parameters before and after the createOffer +// Also note that after the createoffer we are not 100% sure that +// the params were asynchronously applied so we might miss the +// opportunity to recreate offer. +let{sender:t}=r,n=t.getParameters();"encodings"in n&&// Avoid being fooled by patched getParameters() below. +(1!==n.encodings.length||0!==Object.keys(n.encodings[0]).length)||(n.encodings=e,t.sendEncodings=e,this.setParametersPromises.push(t.setParameters(n).then(()=>{delete t.sendEncodings}).catch(()=>{delete t.sendEncodings})))}return r})}function en(e){if(!("object"==typeof e&&e.RTCRtpSender))return;let t=e.RTCRtpSender.prototype.getParameters;t&&(e.RTCRtpSender.prototype.getParameters=function(){let e=t.apply(this,arguments);return"encodings"in e||(e.encodings=[].concat(this.sendEncodings||[{}])),e})}function er(e){// https://github.com/webrtcHacks/adapter/issues/998#issuecomment-516921647 +// Firefox ignores the init sendEncodings options passed to addTransceiver +// https://bugzilla.mozilla.org/show_bug.cgi?id=1396918 +if(!("object"==typeof e&&e.RTCPeerConnection))return;let t=e.RTCPeerConnection.prototype.createOffer;e.RTCPeerConnection.prototype.createOffer=function(){return this.setParametersPromises&&this.setParametersPromises.length?Promise.all(this.setParametersPromises).then(()=>t.apply(this,arguments)).finally(()=>{this.setParametersPromises=[]}):t.apply(this,arguments)}}function ei(e){// https://github.com/webrtcHacks/adapter/issues/998#issuecomment-516921647 +// Firefox ignores the init sendEncodings options passed to addTransceiver +// https://bugzilla.mozilla.org/show_bug.cgi?id=1396918 +if(!("object"==typeof e&&e.RTCPeerConnection))return;let t=e.RTCPeerConnection.prototype.createAnswer;e.RTCPeerConnection.prototype.createAnswer=function(){return this.setParametersPromises&&this.setParametersPromises.length?Promise.all(this.setParametersPromises).then(()=>t.apply(this,arguments)).finally(()=>{this.setParametersPromises=[]}):t.apply(this,arguments)}}e(W,"shimOnTrack",()=>K),e(W,"shimPeerConnection",()=>X),e(W,"shimSenderGetStats",()=>q),e(W,"shimReceiverGetStats",()=>Q),e(W,"shimRemoveStream",()=>Z),e(W,"shimRTCDataChannel",()=>ee),e(W,"shimAddTransceiver",()=>et),e(W,"shimGetParameters",()=>en),e(W,"shimCreateOffer",()=>er),e(W,"shimCreateAnswer",()=>ei),e(W,"shimGetUserMedia",()=>H),e(W,"shimGetDisplayMedia",()=>Y);var eo={};function es(e){if("object"==typeof e&&e.RTCPeerConnection){if("getLocalStreams"in e.RTCPeerConnection.prototype||(e.RTCPeerConnection.prototype.getLocalStreams=function(){return this._localStreams||(this._localStreams=[]),this._localStreams}),!("addStream"in e.RTCPeerConnection.prototype)){let t=e.RTCPeerConnection.prototype.addTrack;e.RTCPeerConnection.prototype.addStream=function(e){this._localStreams||(this._localStreams=[]),this._localStreams.includes(e)||this._localStreams.push(e),// Try to emulate Chrome's behaviour of adding in audio-video order. +// Safari orders by track id. +e.getAudioTracks().forEach(n=>t.call(this,n,e)),e.getVideoTracks().forEach(n=>t.call(this,n,e))},e.RTCPeerConnection.prototype.addTrack=function(e,...n){return n&&n.forEach(e=>{this._localStreams?this._localStreams.includes(e)||this._localStreams.push(e):this._localStreams=[e]}),t.apply(this,arguments)}}"removeStream"in e.RTCPeerConnection.prototype||(e.RTCPeerConnection.prototype.removeStream=function(e){this._localStreams||(this._localStreams=[]);let t=this._localStreams.indexOf(e);if(-1===t)return;this._localStreams.splice(t,1);let n=e.getTracks();this.getSenders().forEach(e=>{n.includes(e.track)&&this.removeTrack(e)})})}}function ea(e){if("object"==typeof e&&e.RTCPeerConnection&&("getRemoteStreams"in e.RTCPeerConnection.prototype||(e.RTCPeerConnection.prototype.getRemoteStreams=function(){return this._remoteStreams?this._remoteStreams:[]}),!("onaddstream"in e.RTCPeerConnection.prototype))){Object.defineProperty(e.RTCPeerConnection.prototype,"onaddstream",{get(){return this._onaddstream},set(e){this._onaddstream&&(this.removeEventListener("addstream",this._onaddstream),this.removeEventListener("track",this._onaddstreampoly)),this.addEventListener("addstream",this._onaddstream=e),this.addEventListener("track",this._onaddstreampoly=e=>{e.streams.forEach(e=>{if(this._remoteStreams||(this._remoteStreams=[]),this._remoteStreams.includes(e))return;this._remoteStreams.push(e);let t=new Event("addstream");t.stream=e,this.dispatchEvent(t)})})}});let t=e.RTCPeerConnection.prototype.setRemoteDescription;e.RTCPeerConnection.prototype.setRemoteDescription=function(){let e=this;return this._onaddstreampoly||this.addEventListener("track",this._onaddstreampoly=function(t){t.streams.forEach(t=>{if(e._remoteStreams||(e._remoteStreams=[]),e._remoteStreams.indexOf(t)>=0)return;e._remoteStreams.push(t);let n=new Event("addstream");n.stream=t,e.dispatchEvent(n)})}),t.apply(e,arguments)}}}function ec(e){if("object"!=typeof e||!e.RTCPeerConnection)return;let t=e.RTCPeerConnection.prototype,n=t.createOffer,r=t.createAnswer,i=t.setLocalDescription,o=t.setRemoteDescription,s=t.addIceCandidate;t.createOffer=function(e,t){let r=arguments.length>=2?arguments[2]:arguments[0],i=n.apply(this,[r]);return t?(i.then(e,t),Promise.resolve()):i},t.createAnswer=function(e,t){let n=arguments.length>=2?arguments[2]:arguments[0],i=r.apply(this,[n]);return t?(i.then(e,t),Promise.resolve()):i};let a=function(e,t,n){let r=i.apply(this,[e]);return n?(r.then(t,n),Promise.resolve()):r};t.setLocalDescription=a,a=function(e,t,n){let r=o.apply(this,[e]);return n?(r.then(t,n),Promise.resolve()):r},t.setRemoteDescription=a,a=function(e,t,n){let r=s.apply(this,[e]);return n?(r.then(t,n),Promise.resolve()):r},t.addIceCandidate=a}function el(e){let t=e&&e.navigator;if(t.mediaDevices&&t.mediaDevices.getUserMedia){// shim not needed in Safari 12.1 +let e=t.mediaDevices,n=e.getUserMedia.bind(e);t.mediaDevices.getUserMedia=e=>n(ep(e))}!t.getUserMedia&&t.mediaDevices&&t.mediaDevices.getUserMedia&&(t.getUserMedia=(function(e,n,r){t.mediaDevices.getUserMedia(e).then(n,r)}).bind(t))}function ep(e){return e&&void 0!==e.video?Object.assign({},e,{video:function e(t){return g(t)?Object.keys(t).reduce(function(n,r){let i=g(t[r]),o=i?e(t[r]):t[r],s=i&&!Object.keys(o).length;return void 0===o||s?n:Object.assign(n,{[r]:o})},{}):t}(e.video)}):e}function ed(e){if(!e.RTCPeerConnection)return;// migrate from non-spec RTCIceServer.url to RTCIceServer.urls +let t=e.RTCPeerConnection;e.RTCPeerConnection=function(e,n){if(e&&e.iceServers){let t=[];for(let n=0;nt.generateCertificate})}function eh(e){"object"==typeof e&&e.RTCTrackEvent&&"receiver"in e.RTCTrackEvent.prototype&&!("transceiver"in e.RTCTrackEvent.prototype)&&Object.defineProperty(e.RTCTrackEvent.prototype,"transceiver",{get(){return{receiver:this.receiver}}})}function eu(e){let t=e.RTCPeerConnection.prototype.createOffer;e.RTCPeerConnection.prototype.createOffer=function(e){if(e){void 0!==e.offerToReceiveAudio&&(e.offerToReceiveAudio=!!e.offerToReceiveAudio);let t=this.getTransceivers().find(e=>"audio"===e.receiver.track.kind);!1===e.offerToReceiveAudio&&t?"sendrecv"===t.direction?t.setDirection?t.setDirection("sendonly"):t.direction="sendonly":"recvonly"===t.direction&&(t.setDirection?t.setDirection("inactive"):t.direction="inactive"):!0!==e.offerToReceiveAudio||t||this.addTransceiver("audio",{direction:"recvonly"}),void 0!==e.offerToReceiveVideo&&(e.offerToReceiveVideo=!!e.offerToReceiveVideo);let n=this.getTransceivers().find(e=>"video"===e.receiver.track.kind);!1===e.offerToReceiveVideo&&n?"sendrecv"===n.direction?n.setDirection?n.setDirection("sendonly"):n.direction="sendonly":"recvonly"===n.direction&&(n.setDirection?n.setDirection("inactive"):n.direction="inactive"):!0!==e.offerToReceiveVideo||n||this.addTransceiver("video",{direction:"recvonly"})}return t.apply(this,arguments)}}function ef(e){"object"!=typeof e||e.AudioContext||(e.AudioContext=e.webkitAudioContext)}e(eo,"shimLocalStreamsAPI",()=>es),e(eo,"shimRemoteStreamsAPI",()=>ea),e(eo,"shimCallbacksAPI",()=>ec),e(eo,"shimGetUserMedia",()=>el),e(eo,"shimConstraints",()=>ep),e(eo,"shimRTCIceServerUrls",()=>ed),e(eo,"shimTrackEventTransceiver",()=>eh),e(eo,"shimCreateOfferLegacy",()=>eu),e(eo,"shimAudioContext",()=>ef);var em={};e(em,"shimRTCIceCandidate",()=>e_),e(em,"shimRTCIceCandidateRelayProtocol",()=>eC),e(em,"shimMaxMessageSize",()=>ev),e(em,"shimSendThrowTypeError",()=>eb),e(em,"shimConnectionState",()=>ek),e(em,"removeExtmapAllowMixed",()=>eS),e(em,"shimAddIceCandidateNullOrEmpty",()=>eT),e(em,"shimParameterlessSetLocalDescription",()=>eR);/* + * Copyright (c) 2017 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. + *//* eslint-env node */var eg={};// SDP helpers. +let ey={};function e_(e){// foundation is arbitrarily chosen as an indicator for full support for +// https://w3c.github.io/webrtc-pc/#rtcicecandidate-interface +if(!e.RTCIceCandidate||e.RTCIceCandidate&&"foundation"in e.RTCIceCandidate.prototype)return;let n=e.RTCIceCandidate;e.RTCIceCandidate=function(e){if("object"==typeof e&&e.candidate&&0===e.candidate.indexOf("a=")&&((e=JSON.parse(JSON.stringify(e))).candidate=e.candidate.substring(2)),e.candidate&&e.candidate.length){// Augment the native candidate with the parsed fields. +let r=new n(e),i=/*@__PURE__*/t(eg).parseCandidate(e.candidate);for(let e in i)e in r||Object.defineProperty(r,e,{value:i[e]});return(// Override serializer to not serialize the extra attributes. +r.toJSON=function(){return{candidate:r.candidate,sdpMid:r.sdpMid,sdpMLineIndex:r.sdpMLineIndex,usernameFragment:r.usernameFragment}},r)}return new n(e)},e.RTCIceCandidate.prototype=n.prototype,// Hook up the augmented candidate in onicecandidate and +// addEventListener('icecandidate', ...) +d(e,"icecandidate",t=>(t.candidate&&Object.defineProperty(t,"candidate",{value:new e.RTCIceCandidate(t.candidate),writable:"false"}),t))}function eC(e){!e.RTCIceCandidate||e.RTCIceCandidate&&"relayProtocol"in e.RTCIceCandidate.prototype||// Hook up the augmented candidate in onicecandidate and +// addEventListener('icecandidate', ...) +d(e,"icecandidate",e=>{if(e.candidate){let n=/*@__PURE__*/t(eg).parseCandidate(e.candidate.candidate);"relay"===n.type&&// to relayProtocol. +(e.candidate.relayProtocol=({0:"tls",1:"tcp",2:"udp"})[n.priority>>24])}return e})}function ev(e,n){if(!e.RTCPeerConnection)return;"sctp"in e.RTCPeerConnection.prototype||Object.defineProperty(e.RTCPeerConnection.prototype,"sctp",{get(){return void 0===this._sctp?null:this._sctp}});let r=function(e){if(!e||!e.sdp)return!1;let n=/*@__PURE__*/t(eg).splitSections(e.sdp);return n.shift(),n.some(e=>{let n=/*@__PURE__*/t(eg).parseMLine(e);return n&&"application"===n.kind&&-1!==n.protocol.indexOf("SCTP")})},i=function(e){// TODO: Is there a better solution for detecting Firefox? +let t=e.sdp.match(/mozilla...THIS_IS_SDPARTA-(\d+)/);if(null===t||t.length<2)return -1;let n=parseInt(t[1],10);// Test for NaN (yes, this is ugly) +return n!=n?-1:n},o=function(e){// Every implementation we know can send at least 64 KiB. +// Note: Although Chrome is technically able to send up to 256 KiB, the +// data does not reach the other peer reliably. +// See: https://bugs.chromium.org/p/webrtc/issues/detail?id=8419 +let t=65536;return"firefox"===n.browser&&(// fragmentation. +t=n.version<57?-1===e?16384:2147483637:n.version<60?57===n.version?65535:65536:2147483637),t},s=function(e,r){// Note: 65536 bytes is the default value from the SDP spec. Also, +// every implementation we know supports receiving 65536 bytes. +let i=65536;"firefox"===n.browser&&57===n.version&&(i=65535);let o=/*@__PURE__*/t(eg).matchPrefix(e.sdp,"a=max-message-size:");return o.length>0?i=parseInt(o[0].substring(19),10):"firefox"===n.browser&&-1!==r&&// both local and remote are Firefox, the remote peer can receive +// ~2 GiB. +(i=2147483637),i},a=e.RTCPeerConnection.prototype.setRemoteDescription;e.RTCPeerConnection.prototype.setRemoteDescription=function(){// Chrome decided to not expose .sctp in plan-b mode. +// As usual, adapter.js has to do an 'ugly worakaround' +// to cover up the mess. +if(this._sctp=null,"chrome"===n.browser&&n.version>=76){let{sdpSemantics:e}=this.getConfiguration();"plan-b"===e&&Object.defineProperty(this,"sctp",{get(){return void 0===this._sctp?null:this._sctp},enumerable:!0,configurable:!0})}if(r(arguments[0])){let e;// Check if the remote is FF. +let t=i(arguments[0]),n=o(t),r=s(arguments[0],t);e=0===n&&0===r?Number.POSITIVE_INFINITY:0===n||0===r?Math.max(n,r):Math.min(n,r);// Create a dummy RTCSctpTransport object and the 'maxMessageSize' +// attribute. +let a={};Object.defineProperty(a,"maxMessageSize",{get:()=>e}),this._sctp=a}return a.apply(this,arguments)}}function eb(e){if(!(e.RTCPeerConnection&&"createDataChannel"in e.RTCPeerConnection.prototype))return;// Note: Although Firefox >= 57 has a native implementation, the maximum +// message size can be reset for all data channels at a later stage. +// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1426831 +function t(e,t){let n=e.send;e.send=function(){let r=arguments[0],i=r.length||r.size||r.byteLength;if("open"===e.readyState&&t.sctp&&i>t.sctp.maxMessageSize)throw TypeError("Message too large (can send a maximum of "+t.sctp.maxMessageSize+" bytes)");return n.apply(e,arguments)}}let n=e.RTCPeerConnection.prototype.createDataChannel;e.RTCPeerConnection.prototype.createDataChannel=function(){let e=n.apply(this,arguments);return t(e,this),e},d(e,"datachannel",e=>(t(e.channel,e.target),e))}function ek(e){if(!e.RTCPeerConnection||"connectionState"in e.RTCPeerConnection.prototype)return;let t=e.RTCPeerConnection.prototype;Object.defineProperty(t,"connectionState",{get(){return({completed:"connected",checking:"connecting"})[this.iceConnectionState]||this.iceConnectionState},enumerable:!0,configurable:!0}),Object.defineProperty(t,"onconnectionstatechange",{get(){return this._onconnectionstatechange||null},set(e){this._onconnectionstatechange&&(this.removeEventListener("connectionstatechange",this._onconnectionstatechange),delete this._onconnectionstatechange),e&&this.addEventListener("connectionstatechange",this._onconnectionstatechange=e)},enumerable:!0,configurable:!0}),["setLocalDescription","setRemoteDescription"].forEach(e=>{let n=t[e];t[e]=function(){return this._connectionstatechangepoly||(this._connectionstatechangepoly=e=>{let t=e.target;if(t._lastConnectionState!==t.connectionState){t._lastConnectionState=t.connectionState;let n=new Event("connectionstatechange",e);t.dispatchEvent(n)}return e},this.addEventListener("iceconnectionstatechange",this._connectionstatechangepoly)),n.apply(this,arguments)}})}function eS(e,t){/* remove a=extmap-allow-mixed for webrtc.org < M71 */if(!e.RTCPeerConnection||"chrome"===t.browser&&t.version>=71||"safari"===t.browser&&t.version>=605)return;let n=e.RTCPeerConnection.prototype.setRemoteDescription;e.RTCPeerConnection.prototype.setRemoteDescription=function(t){if(t&&t.sdp&&-1!==t.sdp.indexOf("\na=extmap-allow-mixed")){let n=t.sdp.split("\n").filter(e=>"a=extmap-allow-mixed"!==e.trim()).join("\n");e.RTCSessionDescription&&t instanceof e.RTCSessionDescription?arguments[0]=new e.RTCSessionDescription({type:t.type,sdp:n}):t.sdp=n}return n.apply(this,arguments)}}function eT(e,t){// Support for addIceCandidate(null or undefined) +// as well as addIceCandidate({candidate: "", ...}) +// https://bugs.chromium.org/p/chromium/issues/detail?id=978582 +// Note: must be called before other polyfills which change the signature. +if(!(e.RTCPeerConnection&&e.RTCPeerConnection.prototype))return;let n=e.RTCPeerConnection.prototype.addIceCandidate;n&&0!==n.length&&(e.RTCPeerConnection.prototype.addIceCandidate=function(){return arguments[0]?("chrome"===t.browser&&t.version<78||"firefox"===t.browser&&t.version<68||"safari"===t.browser)&&arguments[0]&&""===arguments[0].candidate?Promise.resolve():n.apply(this,arguments):(arguments[1]&&arguments[1].apply(null),Promise.resolve())})}function eR(e,t){if(!(e.RTCPeerConnection&&e.RTCPeerConnection.prototype))return;let n=e.RTCPeerConnection.prototype.setLocalDescription;n&&0!==n.length&&(e.RTCPeerConnection.prototype.setLocalDescription=function(){let e=arguments[0]||{};if("object"!=typeof e||e.type&&e.sdp)return n.apply(this,arguments);if(!// The remaining steps should technically happen when SLD comes off the +// RTCPeerConnection's operations chain (not ahead of going on it), but +// this is too difficult to shim. Instead, this shim only covers the +// common case where the operations chain is empty. This is imperfect, but +// should cover many cases. Rationale: Even if we can't reduce the glare +// window to zero on imperfect implementations, there's value in tapping +// into the perfect negotiation pattern that several browsers support. +(e={type:e.type,sdp:e.sdp}).type)switch(this.signalingState){case"stable":case"have-local-offer":case"have-remote-pranswer":e.type="offer";break;default:e.type="answer"}if(e.sdp||"offer"!==e.type&&"answer"!==e.type)return n.apply(this,[e]);let t="offer"===e.type?this.createOffer:this.createAnswer;return t.apply(this).then(e=>n.apply(this,[e]))})}// Generate an alphanumeric identifier for cname or mids. +// TODO: use UUIDs instead? https://gist.github.com/jed/982883 +ey.generateIdentifier=function(){return Math.random().toString(36).substring(2,12)},// The RTCP CNAME used by all peerconnections from the same JS. +ey.localCName=ey.generateIdentifier(),// Splits SDP into lines, dealing with both CRLF and LF. +ey.splitLines=function(e){return e.trim().split("\n").map(e=>e.trim())},// Splits SDP into sessionpart and mediasections. Ensures CRLF. +ey.splitSections=function(e){let t=e.split("\nm=");return t.map((e,t)=>(t>0?"m="+e:e).trim()+"\r\n")},// Returns the session description. +ey.getDescription=function(e){let t=ey.splitSections(e);return t&&t[0]},// Returns the individual media sections. +ey.getMediaSections=function(e){let t=ey.splitSections(e);return t.shift(),t},// Returns lines that start with a certain prefix. +ey.matchPrefix=function(e,t){return ey.splitLines(e).filter(e=>0===e.indexOf(t))},// Parses an ICE candidate line. Sample input: +// candidate:702786350 2 udp 41819902 8.8.8.8 60769 typ relay raddr 8.8.8.8 +// rport 55996" +// Input can be prefixed with a=. +ey.parseCandidate=function(e){let t;t=0===e.indexOf("a=candidate:")?e.substring(12).split(" "):e.substring(10).split(" ");let n={foundation:t[0],component:{1:"rtp",2:"rtcp"}[t[1]]||t[1],protocol:t[2].toLowerCase(),priority:parseInt(t[3],10),ip:t[4],address:t[4],port:parseInt(t[5],10),// skip parts[6] == 'typ' +type:t[7]};for(let e=8;e0?t[0].split("/")[1]:"sendrecv",uri:t[1],attributes:t.slice(2).join(" ")}},// Generates an extmap line from RTCRtpHeaderExtensionParameters or +// RTCRtpHeaderExtension. +ey.writeExtmap=function(e){return"a=extmap:"+(e.id||e.preferredId)+(e.direction&&"sendrecv"!==e.direction?"/"+e.direction:"")+" "+e.uri+(e.attributes?" "+e.attributes:"")+"\r\n"},// Parses a fmtp line, returns dictionary. Sample input: +// a=fmtp:96 vbr=on;cng=on +// Also deals with vbr=on; cng=on +ey.parseFmtp=function(e){let t;let n={},r=e.substring(e.indexOf(" ")+1).split(";");for(let e=0;e{void 0!==e.parameters[t]?r.push(t+"="+e.parameters[t]):r.push(t)}),t+="a=fmtp:"+n+" "+r.join(";")+"\r\n"}return t},// Parses a rtcp-fb line, returns RTCPRtcpFeedback object. Sample input: +// a=rtcp-fb:98 nack rpsi +ey.parseRtcpFb=function(e){let t=e.substring(e.indexOf(" ")+1).split(" ");return{type:t.shift(),parameter:t.join(" ")}},// Generate a=rtcp-fb lines from RTCRtpCodecCapability or RTCRtpCodecParameters. +ey.writeRtcpFb=function(e){let t="",n=e.payloadType;return void 0!==e.preferredPayloadType&&(n=e.preferredPayloadType),e.rtcpFeedback&&e.rtcpFeedback.length&&e.rtcpFeedback.forEach(e=>{t+="a=rtcp-fb:"+n+" "+e.type+(e.parameter&&e.parameter.length?" "+e.parameter:"")+"\r\n"}),t},// Parses a RFC 5576 ssrc media attribute. Sample input: +// a=ssrc:3735928559 cname:something +ey.parseSsrcMedia=function(e){let t=e.indexOf(" "),n={ssrc:parseInt(e.substring(7,t),10)},r=e.indexOf(":",t);return r>-1?(n.attribute=e.substring(t+1,r),n.value=e.substring(r+1)):n.attribute=e.substring(t+1),n},// Parse a ssrc-group line (see RFC 5576). Sample input: +// a=ssrc-group:semantics 12 34 +ey.parseSsrcGroup=function(e){let t=e.substring(13).split(" ");return{semantics:t.shift(),ssrcs:t.map(e=>parseInt(e,10))}},// Extracts the MID (RFC 5888) from a media section. +// Returns the MID or undefined if no mid line was found. +ey.getMid=function(e){let t=ey.matchPrefix(e,"a=mid:")[0];if(t)return t.substring(6)},// Parses a fingerprint line for DTLS-SRTP. +ey.parseFingerprint=function(e){let t=e.substring(14).split(" ");return{algorithm:t[0].toLowerCase(),value:t[1].toUpperCase()}},// Extracts DTLS parameters from SDP media section or sessionpart. +// FIXME: for consistency with other functions this should only +// get the fingerprint line as input. See also getIceParameters. +ey.getDtlsParameters=function(e,t){let n=ey.matchPrefix(e+t,"a=fingerprint:");// Note: a=setup line is ignored since we use the 'auto' role in Edge. +return{role:"auto",fingerprints:n.map(ey.parseFingerprint)}},// Serializes DTLS parameters to SDP. +ey.writeDtlsParameters=function(e,t){let n="a=setup:"+t+"\r\n";return e.fingerprints.forEach(e=>{n+="a=fingerprint:"+e.algorithm+" "+e.value+"\r\n"}),n},// Parses a=crypto lines into +// https://rawgit.com/aboba/edgertc/master/msortc-rs4.html#dictionary-rtcsrtpsdesparameters-members +ey.parseCryptoLine=function(e){let t=e.substring(9).split(" ");return{tag:parseInt(t[0],10),cryptoSuite:t[1],keyParams:t[2],sessionParams:t.slice(3)}},ey.writeCryptoLine=function(e){return"a=crypto:"+e.tag+" "+e.cryptoSuite+" "+("object"==typeof e.keyParams?ey.writeCryptoKeyParams(e.keyParams):e.keyParams)+(e.sessionParams?" "+e.sessionParams.join(" "):"")+"\r\n"},// Parses the crypto key parameters into +// https://rawgit.com/aboba/edgertc/master/msortc-rs4.html#rtcsrtpkeyparam* +ey.parseCryptoKeyParams=function(e){if(0!==e.indexOf("inline:"))return null;let t=e.substring(7).split("|");return{keyMethod:"inline",keySalt:t[0],lifeTime:t[1],mkiValue:t[2]?t[2].split(":")[0]:void 0,mkiLength:t[2]?t[2].split(":")[1]:void 0}},ey.writeCryptoKeyParams=function(e){return e.keyMethod+":"+e.keySalt+(e.lifeTime?"|"+e.lifeTime:"")+(e.mkiValue&&e.mkiLength?"|"+e.mkiValue+":"+e.mkiLength:"")},// Extracts all SDES parameters. +ey.getCryptoParameters=function(e,t){let n=ey.matchPrefix(e+t,"a=crypto:");return n.map(ey.parseCryptoLine)},// Parses ICE information from SDP media section or sessionpart. +// FIXME: for consistency with other functions this should only +// get the ice-ufrag and ice-pwd lines as input. +ey.getIceParameters=function(e,t){let n=ey.matchPrefix(e+t,"a=ice-ufrag:")[0],r=ey.matchPrefix(e+t,"a=ice-pwd:")[0];return n&&r?{usernameFragment:n.substring(12),password:r.substring(10)}:null},// Serializes ICE parameters to SDP. +ey.writeIceParameters=function(e){let t="a=ice-ufrag:"+e.usernameFragment+"\r\na=ice-pwd:"+e.password+"\r\n";return e.iceLite&&(t+="a=ice-lite\r\n"),t},// Parses the SDP media section and returns RTCRtpParameters. +ey.parseRtpParameters=function(e){let t={codecs:[],headerExtensions:[],fecMechanisms:[],rtcp:[]},n=ey.splitLines(e),r=n[0].split(" ");t.profile=r[2];for(let n=3;n is considered. +n.parameters=r.length?ey.parseFmtp(r[0]):{},n.rtcpFeedback=ey.matchPrefix(e,"a=rtcp-fb:"+i+" ").map(ey.parseRtcpFb),t.codecs.push(n),n.name.toUpperCase()){case"RED":case"ULPFEC":t.fecMechanisms.push(n.name.toUpperCase())}}}ey.matchPrefix(e,"a=extmap:").forEach(e=>{t.headerExtensions.push(ey.parseExtmap(e))});let i=ey.matchPrefix(e,"a=rtcp-fb:* ").map(ey.parseRtcpFb);// FIXME: parse rtcp. +return t.codecs.forEach(e=>{i.forEach(t=>{let n=e.rtcpFeedback.find(e=>e.type===t.type&&e.parameter===t.parameter);n||e.rtcpFeedback.push(t)})}),t},// Generates parts of the SDP media section describing the capabilities / +// parameters. +ey.writeRtpDescription=function(e,t){let n="";n+="m="+e+" "+(t.codecs.length>0?"9":"0")+" "+(t.profile||"UDP/TLS/RTP/SAVPF")+" "+t.codecs.map(e=>void 0!==e.preferredPayloadType?e.preferredPayloadType:e.payloadType).join(" ")+"\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\n",// Add a=rtpmap lines for each codec. Also fmtp and rtcp-fb. +t.codecs.forEach(e=>{n+=ey.writeRtpMap(e)+ey.writeFmtp(e)+ey.writeRtcpFb(e)});let r=0;// FIXME: write fecMechanisms. +return t.codecs.forEach(e=>{e.maxptime>r&&(r=e.maxptime)}),r>0&&(n+="a=maxptime:"+r+"\r\n"),t.headerExtensions&&t.headerExtensions.forEach(e=>{n+=ey.writeExtmap(e)}),n},// Parses the SDP media section and returns an array of +// RTCRtpEncodingParameters. +ey.parseRtpEncodingParameters=function(e){let t;let n=[],r=ey.parseRtpParameters(e),i=-1!==r.fecMechanisms.indexOf("RED"),o=-1!==r.fecMechanisms.indexOf("ULPFEC"),s=ey.matchPrefix(e,"a=ssrc:").map(e=>ey.parseSsrcMedia(e)).filter(e=>"cname"===e.attribute),a=s.length>0&&s[0].ssrc,c=ey.matchPrefix(e,"a=ssrc-group:FID").map(e=>{let t=e.substring(17).split(" ");return t.map(e=>parseInt(e,10))});c.length>0&&c[0].length>1&&c[0][0]===a&&(t=c[0][1]),r.codecs.forEach(e=>{if("RTX"===e.name.toUpperCase()&&e.parameters.apt){let r={ssrc:a,codecPayloadType:parseInt(e.parameters.apt,10)};a&&t&&(r.rtx={ssrc:t}),n.push(r),i&&((r=JSON.parse(JSON.stringify(r))).fec={ssrc:a,mechanism:o?"red+ulpfec":"red"},n.push(r))}}),0===n.length&&a&&n.push({ssrc:a});// we support both b=AS and b=TIAS but interpret AS as TIAS. +let l=ey.matchPrefix(e,"b=");return l.length&&(l=0===l[0].indexOf("b=TIAS:")?parseInt(l[0].substring(7),10):0===l[0].indexOf("b=AS:")?950*parseInt(l[0].substring(5),10)-16e3:void 0,n.forEach(e=>{e.maxBitrate=l})),n},// parses http://draft.ortc.org/#rtcrtcpparameters* +ey.parseRtcpParameters=function(e){let t={},n=ey.matchPrefix(e,"a=ssrc:").map(e=>ey.parseSsrcMedia(e)).filter(e=>"cname"===e.attribute)[0];n&&(t.cname=n.value,t.ssrc=n.ssrc);// Edge uses the compound attribute instead of reducedSize +// compound is !reducedSize +let r=ey.matchPrefix(e,"a=rtcp-rsize");t.reducedSize=r.length>0,t.compound=0===r.length;// parses the rtcp-mux attrіbute. +// Note that Edge does not support unmuxed RTCP. +let i=ey.matchPrefix(e,"a=rtcp-mux");return t.mux=i.length>0,t},ey.writeRtcpParameters=function(e){let t="";return e.reducedSize&&(t+="a=rtcp-rsize\r\n"),e.mux&&(t+="a=rtcp-mux\r\n"),void 0!==e.ssrc&&e.cname&&(t+="a=ssrc:"+e.ssrc+" cname:"+e.cname+"\r\n"),t},// parses either a=msid: or a=ssrc:... msid lines and returns +// the id of the MediaStream and MediaStreamTrack. +ey.parseMsid=function(e){let t;let n=ey.matchPrefix(e,"a=msid:");if(1===n.length)return{stream:(t=n[0].substring(7).split(" "))[0],track:t[1]};let r=ey.matchPrefix(e,"a=ssrc:").map(e=>ey.parseSsrcMedia(e)).filter(e=>"msid"===e.attribute);if(r.length>0)return{stream:(t=r[0].value.split(" "))[0],track:t[1]}},// SCTP +// parses draft-ietf-mmusic-sctp-sdp-26 first and falls back +// to draft-ietf-mmusic-sctp-sdp-05 +ey.parseSctpDescription=function(e){let t;let n=ey.parseMLine(e),r=ey.matchPrefix(e,"a=max-message-size:");r.length>0&&(t=parseInt(r[0].substring(19),10)),isNaN(t)&&(t=65536);let i=ey.matchPrefix(e,"a=sctp-port:");if(i.length>0)return{port:parseInt(i[0].substring(12),10),protocol:n.fmt,maxMessageSize:t};let o=ey.matchPrefix(e,"a=sctpmap:");if(o.length>0){let e=o[0].substring(10).split(" ");return{port:parseInt(e[0],10),protocol:e[1],maxMessageSize:t}}},// SCTP +// outputs the draft-ietf-mmusic-sctp-sdp-26 version that all browsers +// support by now receiving in this format, unless we originally parsed +// as the draft-ietf-mmusic-sctp-sdp-05 format (indicated by the m-line +// protocol of DTLS/SCTP -- without UDP/ or TCP/) +ey.writeSctpDescription=function(e,t){let n=[];return n="DTLS/SCTP"!==e.protocol?["m="+e.kind+" 9 "+e.protocol+" "+t.protocol+"\r\n","c=IN IP4 0.0.0.0\r\n","a=sctp-port:"+t.port+"\r\n"]:["m="+e.kind+" 9 "+e.protocol+" "+t.port+"\r\n","c=IN IP4 0.0.0.0\r\n","a=sctpmap:"+t.port+" "+t.protocol+" 65535\r\n"],void 0!==t.maxMessageSize&&n.push("a=max-message-size:"+t.maxMessageSize+"\r\n"),n.join("")},// Generate a session ID for SDP. +// https://tools.ietf.org/html/draft-ietf-rtcweb-jsep-20#section-5.2.1 +// recommends using a cryptographically random +ve 64-bit value +// but right now this should be acceptable and within the right range +ey.generateSessionId=function(){return Math.random().toString().substr(2,22)},// Write boiler plate for start of SDP +// sessId argument is optional - if not supplied it will +// be generated randomly +// sessVersion is optional and defaults to 2 +// sessUser is optional and defaults to 'thisisadapterortc' +ey.writeSessionBoilerplate=function(e,t,n){return"v=0\r\no="+(n||"thisisadapterortc")+" "+(e||ey.generateSessionId())+" "+(void 0!==t?t:2)+" IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n"},// Gets the direction from the mediaSection or the sessionpart. +ey.getDirection=function(e,t){// Look for sendrecv, sendonly, recvonly, inactive, default to sendrecv. +let n=ey.splitLines(e);for(let e=0;e=this.minChromeVersion:"firefox"===e?t>=this.minFirefoxVersion:"safari"===e&&!this.isIOS&&t>=this.minSafariVersion)}getBrowser(){return eP.browserDetails.browser}getVersion(){return eP.browserDetails.version||0}isUnifiedPlanSupported(){let e;let t=this.getBrowser(),n=eP.browserDetails.version||0;if("chrome"===t&&n=this.minFirefoxVersion)return!0;if(!window.RTCRtpTransceiver||!("currentDirection"in RTCRtpTransceiver.prototype))return!1;let r=!1;try{(e=new RTCPeerConnection).addTransceiver("audio"),r=!0}catch(e){}finally{e&&e.close()}return r}toString(){return`Supports: + browser:${this.getBrowser()} + version:${this.getVersion()} + isIOS:${this.isIOS} + isWebRTCSupported:${this.isWebRTCSupported()} + isBrowserSupported:${this.isBrowserSupported()} + isUnifiedPlanSupported:${this.isUnifiedPlanSupported()}`}constructor(){this.isIOS=["iPad","iPhone","iPod"].includes(navigator.platform),this.supportedBrowsers=["firefox","chrome","safari"],this.minFirefoxVersion=59,this.minChromeVersion=72,this.minSafariVersion=605}},eD=e=>!e||/^[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*$/.test(e),ex=()=>Math.random().toString(36).slice(2),eI={iceServers:[{urls:"stun:stun.l.google.com:19302"},{urls:["turn:eu-0.turn.peerjs.com:3478","turn:us-0.turn.peerjs.com:3478"],username:"peerjs",credential:"peerjsp"}],sdpSemantics:"unified-plan"},eM=new class extends n{noop(){}blobToArrayBuffer(e,t){let n=new FileReader;return n.onload=function(e){e.target&&t(e.target.result)},n.readAsArrayBuffer(e),n}binaryStringToArrayBuffer(e){let t=new Uint8Array(e.length);for(let n=0;n=w.All&&this._print(w.All,...e)}warn(...e){this._logLevel>=w.Warnings&&this._print(w.Warnings,...e)}error(...e){this._logLevel>=w.Errors&&this._print(w.Errors,...e)}setLogFunction(e){this._print=e}_print(e,...t){let n=["PeerJS: ",...t];for(let e in n)n[e]instanceof Error&&(n[e]="("+n[e].name+") "+n[e].message);e>=w.All?console.log(...n):e>=w.Warnings?console.warn("WARNING",...n):e>=w.Errors&&console.error("ERROR",...n)}constructor(){this._logLevel=w.Disabled}},ej={},eL=Object.prototype.hasOwnProperty,eA="~";/** + * Constructor to create a storage for our `EE` objects. + * An `Events` instance is a plain object whose properties are event names. + * + * @constructor + * @private + */function eB(){}/** + * Representation of a single event listener. + * + * @param {Function} fn The listener function. + * @param {*} context The context to invoke the listener with. + * @param {Boolean} [once=false] Specify if the listener is a one-time listener. + * @constructor + * @private + */function eF(e,t,n){this.fn=e,this.context=t,this.once=n||!1}/** + * Add a listener for a given event. + * + * @param {EventEmitter} emitter Reference to the `EventEmitter` instance. + * @param {(String|Symbol)} event The event name. + * @param {Function} fn The listener function. + * @param {*} context The context to invoke the listener with. + * @param {Boolean} once Specify if the listener is a one-time listener. + * @returns {EventEmitter} + * @private + */function ez(e,t,n,r,i){if("function"!=typeof n)throw TypeError("The listener must be a function");var o=new eF(n,r||e,i),s=eA?eA+t:t;return e._events[s]?e._events[s].fn?e._events[s]=[e._events[s],o]:e._events[s].push(o):(e._events[s]=o,e._eventsCount++),e}/** + * Clear event by name. + * + * @param {EventEmitter} emitter Reference to the `EventEmitter` instance. + * @param {(String|Symbol)} evt The Event name. + * @private + */function eU(e,t){0==--e._eventsCount?e._events=new eB:delete e._events[t]}/** + * Minimal `EventEmitter` interface that is molded against the Node.js + * `EventEmitter` interface. + * + * @constructor + * @public + */function eN(){this._events=new eB,this._eventsCount=0}Object.create&&(eB.prototype=Object.create(null),new eB().__proto__||(eA=!1)),/** + * Return an array listing the events for which the emitter has registered + * listeners. + * + * @returns {Array} + * @public + */eN.prototype.eventNames=function(){var e,t,n=[];if(0===this._eventsCount)return n;for(t in e=this._events)eL.call(e,t)&&n.push(eA?t.slice(1):t);return Object.getOwnPropertySymbols?n.concat(Object.getOwnPropertySymbols(e)):n},/** + * Return the listeners registered for a given event. + * + * @param {(String|Symbol)} event The event name. + * @returns {Array} The registered listeners. + * @public + */eN.prototype.listeners=function(e){var t=eA?eA+e:e,n=this._events[t];if(!n)return[];if(n.fn)return[n.fn];for(var r=0,i=n.length,o=Array(i);r","afrokick ","ericz ","Jairo ","Jonas Gloning <34194370+jonasgloning@users.noreply.github.com>","Jairo Caro-Accino Viciana ","Carlos Caballero ","hc ","Muhammad Asif ","PrashoonB ","Harsh Bardhan Mishra <47351025+HarshCasper@users.noreply.github.com>","akotynski ","lmb ","Jairooo ","Moritz St\xfcckler ","Simon ","Denis Lukov ","Philipp Hancke ","Hans Oksendahl ","Jess ","khankuan ","DUODVK ","XiZhao ","Matthias Lohr ","=frank tree <=frnktrb@googlemail.com>","Andre Eckardt ","Chris Cowan ","Alex Chuev ","alxnull ","Yemel Jardi ","Ben Parnell ","Benny Lichtner ","fresheneesz ","bob.barstead@exaptive.com ","chandika ","emersion ","Christopher Van ","eddieherm ","Eduardo Pinho ","Evandro Zanatta ","Gardner Bickford ","Gian Luca ","PatrickJS ","jonnyf ","Hizkia Felix ","Hristo Oskov ","Isaac Madwed ","Ilya Konanykhin ","jasonbarry ","Jonathan Burke ","Josh Hamit ","Jordan Austin ","Joel Wetzell ","xizhao ","Alberto Torres ","Jonathan Mayol ","Jefferson Felix ","Rolf Erik Lekang ","Kevin Mai-Husan Chia ","Pepijn de Vos ","JooYoung ","Tobias Speicher ","Steve Blaurock ","Kyrylo Shegeda ","Diwank Singh Tomer ","Sören Balko ","Arpit Solanki ","Yuki Ito ","Artur Zayats "],"funding":{"type":"opencollective","url":"https://opencollective.com/peer"},"collective":{"type":"opencollective","url":"https://opencollective.com/peer"},"files":["dist/*"],"sideEffects":["lib/global.ts","lib/supports.ts"],"main":"dist/bundler.cjs","module":"dist/bundler.mjs","browser-minified":"dist/peerjs.min.js","browser-unminified":"dist/peerjs.js","browser-minified-cbor":"dist/serializer.cbor.mjs","browser-minified-msgpack":"dist/serializer.msgpack.mjs","types":"dist/types.d.ts","engines":{"node":">= 14"},"targets":{"types":{"source":"lib/exports.ts"},"main":{"source":"lib/exports.ts","sourceMap":{"inlineSources":true}},"module":{"source":"lib/exports.ts","includeNodeModules":["eventemitter3"],"sourceMap":{"inlineSources":true}},"browser-minified":{"context":"browser","outputFormat":"global","optimize":true,"engines":{"browsers":"chrome >= 83, edge >= 83, firefox >= 80, safari >= 15"},"source":"lib/global.ts"},"browser-unminified":{"context":"browser","outputFormat":"global","optimize":false,"engines":{"browsers":"chrome >= 83, edge >= 83, firefox >= 80, safari >= 15"},"source":"lib/global.ts"},"browser-minified-cbor":{"context":"browser","outputFormat":"esmodule","isLibrary":true,"optimize":true,"engines":{"browsers":"chrome >= 83, edge >= 83, firefox >= 102, safari >= 15"},"source":"lib/dataconnection/StreamConnection/Cbor.ts"},"browser-minified-msgpack":{"context":"browser","outputFormat":"esmodule","isLibrary":true,"optimize":true,"engines":{"browsers":"chrome >= 83, edge >= 83, firefox >= 102, safari >= 15"},"source":"lib/dataconnection/StreamConnection/MsgPack.ts"}},"scripts":{"contributors":"git-authors-cli --print=false && prettier --write package.json && git add package.json package-lock.json && git commit -m \\"chore(contributors): update and sort contributors list\\"","check":"tsc --noEmit && tsc -p e2e/tsconfig.json --noEmit","watch":"parcel watch","build":"rm -rf dist && parcel build","prepublishOnly":"npm run build","test":"jest","test:watch":"jest --watch","coverage":"jest --coverage --collectCoverageFrom=\\"./lib/**\\"","format":"prettier --write .","format:check":"prettier --check .","semantic-release":"semantic-release","e2e":"wdio run e2e/wdio.local.conf.ts","e2e:bstack":"wdio run e2e/wdio.bstack.conf.ts"},"devDependencies":{"@parcel/config-default":"^2.9.3","@parcel/packager-ts":"^2.9.3","@parcel/transformer-typescript-tsc":"^2.9.3","@parcel/transformer-typescript-types":"^2.9.3","@semantic-release/changelog":"^6.0.1","@semantic-release/git":"^10.0.1","@swc/core":"^1.3.27","@swc/jest":"^0.2.24","@types/jasmine":"^4.3.4","@wdio/browserstack-service":"^8.11.2","@wdio/cli":"^8.11.2","@wdio/globals":"^8.11.2","@wdio/jasmine-framework":"^8.11.2","@wdio/local-runner":"^8.11.2","@wdio/spec-reporter":"^8.11.2","@wdio/types":"^8.10.4","http-server":"^14.1.1","jest":"^29.3.1","jest-environment-jsdom":"^29.3.1","mock-socket":"^9.0.0","parcel":"^2.9.3","prettier":"^3.0.0","semantic-release":"^21.0.0","ts-node":"^10.9.1","typescript":"^5.0.0","wdio-geckodriver-service":"^5.0.1"},"dependencies":{"@msgpack/msgpack":"^2.8.0","cbor-x":"^1.5.3","eventemitter3":"^4.0.7","peerjs-js-binarypack":"^2.0.0","webrtc-adapter":"^8.0.0"},"alias":{"process":false,"buffer":false}}');class eJ extends ej.EventEmitter{start(e,t){this._id=e;let n=`${this._baseUrl}&id=${e}&token=${t}`;!this._socket&&this._disconnected&&(this._socket=new WebSocket(n+"&version="+e$.version),this._disconnected=!1,this._socket.onmessage=e=>{let t;try{t=JSON.parse(e.data),eO.log("Server message received:",t)}catch(t){eO.log("Invalid server message",e.data);return}this.emit(M.Message,t)},this._socket.onclose=e=>{this._disconnected||(eO.log("Socket closed.",e),this._cleanup(),this._disconnected=!0,this.emit(M.Disconnected))},// Take care of the queue of connections if necessary and make sure Peer knows +// socket is open. +this._socket.onopen=()=>{this._disconnected||(this._sendQueuedMessages(),eO.log("Socket open"),this._scheduleHeartbeat())})}_scheduleHeartbeat(){this._wsPingTimer=setTimeout(()=>{this._sendHeartbeat()},this.pingInterval)}_sendHeartbeat(){if(!this._wsOpen()){eO.log("Cannot send heartbeat, because socket closed");return}let e=JSON.stringify({type:O.Heartbeat});this._socket.send(e),this._scheduleHeartbeat()}/** Is the websocket currently open? */_wsOpen(){return!!this._socket&&1===this._socket.readyState}/** Send queued messages. */_sendQueuedMessages(){//Create copy of queue and clear it, +//because send method push the message back to queue if smth will go wrong +let e=[...this._messagesQueue];for(let t of(this._messagesQueue=[],e))this.send(t)}/** Exposed send for DC & Peer. */send(e){if(this._disconnected)return;// If we didn't get an ID yet, we can't yet send anything so we should queue +// up these messages. +if(!this._id){this._messagesQueue.push(e);return}if(!e.type){this.emit(M.Error,"Invalid message");return}if(!this._wsOpen())return;let t=JSON.stringify(e);this._socket.send(t)}close(){this._disconnected||(this._cleanup(),this._disconnected=!0)}_cleanup(){this._socket&&(this._socket.onopen=this._socket.onmessage=this._socket.onclose=null,this._socket.close(),this._socket=void 0),clearTimeout(this._wsPingTimer)}constructor(e,t,n,r,i,o=5e3){super(),this.pingInterval=o,this._disconnected=!0,this._messagesQueue=[],this._baseUrl=(e?"wss://":"ws://")+t+":"+n+r+"peerjs?key="+i}}class eV{/** Returns a PeerConnection object set up correctly (for data, media). */startConnection(e){let t=this._startPeerConnection();// What do we need to do now? +if(// Set the connection's PC. +this.connection.peerConnection=t,this.connection.type===P.Media&&e._stream&&this._addTracksToConnection(e._stream,t),e.originator){let n=this.connection,r={ordered:!!e.reliable},i=t.createDataChannel(n.label,r);n._initializeDataChannel(i),this._makeOffer()}else this.handleSDP("OFFER",e.sdp)}/** Start a PC. */_startPeerConnection(){eO.log("Creating RTCPeerConnection.");let e=new RTCPeerConnection(this.connection.provider.options.config);return this._setupListeners(e),e}/** Set up various WebRTC listeners. */_setupListeners(e){let t=this.connection.peer,n=this.connection.connectionId,r=this.connection.type,i=this.connection.provider;eO.log("Listening for ICE candidates."),e.onicecandidate=e=>{e.candidate&&e.candidate.candidate&&(eO.log(`Received ICE candidates for ${t}:`,e.candidate),i.socket.send({type:O.Candidate,payload:{candidate:e.candidate,type:r,connectionId:n},dst:t}))},e.oniceconnectionstatechange=()=>{switch(e.iceConnectionState){case"failed":eO.log("iceConnectionState is failed, closing connections to "+t),this.connection.emitError(D.NegotiationFailed,"Negotiation of connection to "+t+" failed."),this.connection.close();break;case"closed":eO.log("iceConnectionState is closed, closing connections to "+t),this.connection.emitError(D.ConnectionClosed,"Connection to "+t+" closed."),this.connection.close();break;case"disconnected":eO.log("iceConnectionState changed to disconnected on the connection with "+t);break;case"completed":e.onicecandidate=()=>{}}this.connection.emit("iceStateChanged",e.iceConnectionState)},eO.log("Listening for data channel"),// Fired between offer and answer, so options should already be saved +// in the options hash. +e.ondatachannel=e=>{eO.log("Received data channel");let r=e.channel,o=i.getConnection(t,n);o._initializeDataChannel(r)},eO.log("Listening for remote stream"),e.ontrack=e=>{eO.log("Received remote stream");let r=e.streams[0],o=i.getConnection(t,n);o.type===P.Media&&this._addStreamToMediaConnection(r,o)}}cleanup(){eO.log("Cleaning up PeerConnection to "+this.connection.peer);let e=this.connection.peerConnection;if(!e)return;this.connection.peerConnection=null,//unsubscribe from all PeerConnection's events +e.onicecandidate=e.oniceconnectionstatechange=e.ondatachannel=e.ontrack=()=>{};let t="closed"!==e.signalingState,n=!1,r=this.connection.dataChannel;r&&(n=!!r.readyState&&"closed"!==r.readyState),(t||n)&&e.close()}async _makeOffer(){let e=this.connection.peerConnection,t=this.connection.provider;try{let n=await e.createOffer(this.connection.options.constraints);eO.log("Created offer."),this.connection.options.sdpTransform&&"function"==typeof this.connection.options.sdpTransform&&(n.sdp=this.connection.options.sdpTransform(n.sdp)||n.sdp);try{await e.setLocalDescription(n),eO.log("Set localDescription:",n,`for:${this.connection.peer}`);let r={sdp:n,type:this.connection.type,connectionId:this.connection.connectionId,metadata:this.connection.metadata};if(this.connection.type===P.Data){let e=this.connection;r={...r,label:e.label,reliable:e.reliable,serialization:e.serialization}}t.socket.send({type:O.Offer,payload:r,dst:this.connection.peer})}catch(e){"OperationError: Failed to set local offer sdp: Called in wrong state: kHaveRemoteOffer"!=e&&(t.emitError(E.WebRTC,e),eO.log("Failed to setLocalDescription, ",e))}}catch(e){t.emitError(E.WebRTC,e),eO.log("Failed to createOffer, ",e)}}async _makeAnswer(){let e=this.connection.peerConnection,t=this.connection.provider;try{let n=await e.createAnswer();eO.log("Created answer."),this.connection.options.sdpTransform&&"function"==typeof this.connection.options.sdpTransform&&(n.sdp=this.connection.options.sdpTransform(n.sdp)||n.sdp);try{await e.setLocalDescription(n),eO.log("Set localDescription:",n,`for:${this.connection.peer}`),t.socket.send({type:O.Answer,payload:{sdp:n,type:this.connection.type,connectionId:this.connection.connectionId},dst:this.connection.peer})}catch(e){t.emitError(E.WebRTC,e),eO.log("Failed to setLocalDescription, ",e)}}catch(e){t.emitError(E.WebRTC,e),eO.log("Failed to create answer, ",e)}}/** Handle an SDP. */async handleSDP(e,t){t=new RTCSessionDescription(t);let n=this.connection.peerConnection,r=this.connection.provider;eO.log("Setting remote description",t);try{await n.setRemoteDescription(t),eO.log(`Set remoteDescription:${e} for:${this.connection.peer}`),"OFFER"===e&&await this._makeAnswer()}catch(e){r.emitError(E.WebRTC,e),eO.log("Failed to setRemoteDescription, ",e)}}/** Handle a candidate. */async handleCandidate(e){eO.log("handleCandidate:",e);try{await this.connection.peerConnection.addIceCandidate(e),eO.log(`Added ICE candidate for:${this.connection.peer}`)}catch(e){this.connection.provider.emitError(E.WebRTC,e),eO.log("Failed to handleCandidate, ",e)}}_addTracksToConnection(e,t){if(eO.log(`add tracks from stream ${e.id} to peer connection`),!t.addTrack)return eO.error("Your browser does't support RTCPeerConnection#addTrack. Ignored.");e.getTracks().forEach(n=>{t.addTrack(n,e)})}_addStreamToMediaConnection(e,t){eO.log(`add stream ${e.id} to media connection ${t.connectionId}`),t.addStream(e)}constructor(e){this.connection=e}}class eG extends ej.EventEmitter{/** + * Emits a typed error message. + * + * @internal + */emitError(e,t){eO.error("Error:",t),// @ts-ignore +this.emit("error",new eW(`${e}`,t))}}class eW extends Error{/** + * @internal + */constructor(e,t){"string"==typeof t?super(t):(super(),Object.assign(this,t)),this.type=e}}class eH extends eG{/** + * Whether the media connection is active (e.g. your call has been answered). + * You can check this if you want to set a maximum wait time for a one-sided call. + */get open(){return this._open}constructor(e,t,n){super(),this.peer=e,this.provider=t,this.options=n,this._open=!1,this.metadata=n.metadata}}class eY extends eH{/** + * For media connections, this is always 'media'. + */get type(){return P.Media}get localStream(){return this._localStream}get remoteStream(){return this._remoteStream}/** Called by the Negotiator when the DataChannel is ready. */_initializeDataChannel(e){this.dataChannel=e,this.dataChannel.onopen=()=>{eO.log(`DC#${this.connectionId} dc connection success`),this.emit("willCloseOnRemote")},this.dataChannel.onclose=()=>{eO.log(`DC#${this.connectionId} dc closed for:`,this.peer),this.close()}}addStream(e){eO.log("Receiving stream",e),this._remoteStream=e,super.emit("stream",e)}/** + * @internal + */handleMessage(e){let t=e.type,n=e.payload;switch(e.type){case O.Answer:this._negotiator.handleSDP(t,n.sdp),this._open=!0;break;case O.Candidate:this._negotiator.handleCandidate(n.candidate);break;default:eO.warn(`Unrecognized message type:${t} from peer:${this.peer}`)}}/** + * When receiving a {@apilink PeerEvents | `call`} event on a peer, you can call + * `answer` on the media connection provided by the callback to accept the call + * and optionally send your own media stream. + + * + * @param stream A WebRTC media stream. + * @param options + * @returns + */answer(e,t={}){if(this._localStream){eO.warn("Local stream already exists on this MediaConnection. Are you answering a call twice?");return}this._localStream=e,t&&t.sdpTransform&&(this.options.sdpTransform=t.sdpTransform),this._negotiator.startConnection({...this.options._payload,_stream:e});// Retrieve lost messages stored because PeerConnection not set up. +let n=this.provider._getMessages(this.connectionId);for(let e of n)this.handleMessage(e);this._open=!0}/** + * Exposed functionality for users. + *//** + * Closes the media connection. + */close(){this._negotiator&&(this._negotiator.cleanup(),this._negotiator=null),this._localStream=null,this._remoteStream=null,this.provider&&(this.provider._removeConnection(this),this.provider=null),this.options&&this.options._stream&&(this.options._stream=null),this.open&&(this._open=!1,super.emit("close"))}constructor(e,t,n){super(e,t,n),this._localStream=this.options._stream,this.connectionId=this.options.connectionId||eY.ID_PREFIX+eM.randomToken(),this._negotiator=new eV(this),this._localStream&&this._negotiator.startConnection({_stream:this._localStream,originator:!0})}}eY.ID_PREFIX="mc_";class eK{_buildRequest(e){let t=this._options.secure?"https":"http",{host:n,port:r,path:i,key:o}=this._options,s=new URL(`${t}://${n}:${r}${i}${o}/${e}`);return(// TODO: Why timestamp, why random? +s.searchParams.set("ts",`${Date.now()}${Math.random()}`),s.searchParams.set("version",e$.version),fetch(s.href,{referrerPolicy:this._options.referrerPolicy}))}/** Get a unique ID from the server via XHR and initialize with it. */async retrieveId(){try{let e=await this._buildRequest("id");if(200!==e.status)throw Error(`Error. Status:${e.status}`);return e.text()}catch(t){eO.error("Error retrieving ID",t);let e="";throw"/"===this._options.path&&this._options.host!==eM.CLOUD_HOST&&(e=" If you passed in a `path` to your self-hosted PeerServer, you'll also need to pass in that same path when creating a new Peer."),Error("Could not get an ID from the server."+e)}}/** @deprecated */async listAllPeers(){try{let e=await this._buildRequest("peers");if(200!==e.status){if(401===e.status){let e="";throw e=this._options.host===eM.CLOUD_HOST?"It looks like you're using the cloud server. You can email team@peerjs.com to enable peer listing for your API key.":"You need to enable `allow_discovery` on your self-hosted PeerServer to use this feature.",Error("It doesn't look like you have permission to list peers IDs. "+e)}throw Error(`Error. Status:${e.status}`)}return e.json()}catch(e){throw eO.error("Error retrieving list peers",e),Error("Could not get list peers from the server."+e)}}constructor(e){this._options=e}}class eX extends eH{get type(){return P.Data}/** Called by the Negotiator when the DataChannel is ready. */_initializeDataChannel(e){this.dataChannel=e,this.dataChannel.onopen=()=>{eO.log(`DC#${this.connectionId} dc connection success`),this._open=!0,this.emit("open")},this.dataChannel.onmessage=e=>{eO.log(`DC#${this.connectionId} dc onmessage:`,e.data);// this._handleDataMessage(e); +},this.dataChannel.onclose=()=>{eO.log(`DC#${this.connectionId} dc closed for:`,this.peer),this.close()}}/** + * Exposed functionality for users. + *//** Allows user to close connection. */close(e){if(e?.flush){this.send({__peerData:{type:"close"}});return}this._negotiator&&(this._negotiator.cleanup(),this._negotiator=null),this.provider&&(this.provider._removeConnection(this),this.provider=null),this.dataChannel&&(this.dataChannel.onopen=null,this.dataChannel.onmessage=null,this.dataChannel.onclose=null,this.dataChannel=null),this.open&&(this._open=!1,super.emit("close"))}/** Allows user to send data. */send(e,t=!1){if(!this.open){this.emitError(x.NotOpenYet,"Connection is not open. You should listen for the `open` event before sending messages.");return}return this._send(e,t)}async handleMessage(e){let t=e.payload;switch(e.type){case O.Answer:await this._negotiator.handleSDP(e.type,t.sdp);break;case O.Candidate:await this._negotiator.handleCandidate(t.candidate);break;default:eO.warn("Unrecognized message type:",e.type,"from peer:",this.peer)}}constructor(e,t,n){super(e,t,n),this.connectionId=this.options.connectionId||eX.ID_PREFIX+ex(),this.label=this.options.label||this.connectionId,this.reliable=!!this.options.reliable,this._negotiator=new eV(this),this._negotiator.startConnection(this.options._payload||{originator:!0,reliable:this.reliable})}}eX.ID_PREFIX="dc_",eX.MAX_BUFFERED_AMOUNT=8388608;class eq extends eX{get bufferSize(){return this._bufferSize}_initializeDataChannel(e){super._initializeDataChannel(e),this.dataChannel.binaryType="arraybuffer",this.dataChannel.addEventListener("message",e=>this._handleDataMessage(e))}_bufferedSend(e){(this._buffering||!this._trySend(e))&&(this._buffer.push(e),this._bufferSize=this._buffer.length)}// Returns true if the send succeeds. +_trySend(e){if(!this.open)return!1;if(this.dataChannel.bufferedAmount>eX.MAX_BUFFERED_AMOUNT)return this._buffering=!0,setTimeout(()=>{this._buffering=!1,this._tryBuffer()},50),!1;try{this.dataChannel.send(e)}catch(e){return eO.error(`DC#:${this.connectionId} Error when sending:`,e),this._buffering=!0,this.close(),!1}return!0}// Try to send the first message in the buffer. +_tryBuffer(){if(!this.open||0===this._buffer.length)return;let e=this._buffer[0];this._trySend(e)&&(this._buffer.shift(),this._bufferSize=this._buffer.length,this._tryBuffer())}close(e){if(e?.flush){this.send({__peerData:{type:"close"}});return}this._buffer=[],this._bufferSize=0,super.close()}constructor(...e){super(...e),this._buffer=[],this._bufferSize=0,this._buffering=!1}}class eQ extends eq{close(e){super.close(e),this._chunkedData={}}// Handles a DataChannel message. +_handleDataMessage({data:e}){let t=i(e),n=t.__peerData;if(n){if("close"===n.type){this.close();return}// Chunked data -- piece things back together. +// @ts-ignore +this._handleChunk(t);return}this.emit("data",t)}_handleChunk(e){let t=e.__peerData,n=this._chunkedData[t]||{data:[],count:0,total:e.total};if(n.data[e.n]=new Uint8Array(e.data),n.count++,this._chunkedData[t]=n,n.total===n.count){// Clean up before making the recursive call to `_handleDataMessage`. +delete this._chunkedData[t];// We've received all the chunks--time to construct the complete data. +// const data = new Blob(chunkInfo.data); +let e=function(e){let t=0;for(let n of e)t+=n.byteLength;let n=new Uint8Array(t),r=0;for(let t of e)n.set(t,r),r+=t.byteLength;return n}(n.data);this._handleDataMessage({data:e})}}_send(e,t){let n=o(e);if(!t&&n.byteLength>this.chunker.chunkedMTU){this._sendChunks(n);return}this._bufferedSend(n)}_sendChunks(e){let t=this.chunker.chunk(e);for(let e of(eO.log(`DC#${this.connectionId} Try to send ${t.length} chunks...`),t))this.send(e,!0)}constructor(e,t,r){super(e,t,r),this.chunker=new n,this.serialization=I.Binary,this._chunkedData={}}}class eZ extends eq{_handleDataMessage({data:e}){super.emit("data",e)}_send(e,t){this._bufferedSend(e)}constructor(...e){super(...e),this.serialization=I.None}}class e0 extends eq{// Handles a DataChannel message. +_handleDataMessage({data:e}){let t=this.parse(this.decoder.decode(e)),n=t.__peerData;if(n&&"close"===n.type){this.close();return}this.emit("data",t)}_send(e,t){let n=this.encoder.encode(this.stringify(e));if(n.byteLength>=eM.chunkedMTU){this.emitError(x.MessageToBig,"Message too big for JSON channel");return}this._bufferedSend(n)}constructor(...e){super(...e),this.serialization=I.JSON,this.encoder=new TextEncoder,this.decoder=new TextDecoder,this.stringify=JSON.stringify,this.parse=JSON.parse}}class e1 extends eG{/** + * The brokering ID of this peer + * + * If no ID was specified in {@apilink Peer | the constructor}, + * this will be `undefined` until the {@apilink PeerEvents | `open`} event is emitted. + */get id(){return this._id}get options(){return this._options}get open(){return this._open}/** + * @internal + */get socket(){return this._socket}/** + * A hash of all connections associated with this peer, keyed by the remote peer's ID. + * @deprecated + * Return type will change from Object to Map + */get connections(){let e=Object.create(null);for(let[t,n]of this._connections)e[t]=n;return e}/** + * true if this peer and all of its connections can no longer be used. + */get destroyed(){return this._destroyed}/** + * false if there is an active connection to the PeerServer. + */get disconnected(){return this._disconnected}_createServerConnection(){let e=new eJ(this._options.secure,this._options.host,this._options.port,this._options.path,this._options.key,this._options.pingInterval);return e.on(M.Message,e=>{this._handleMessage(e)}),e.on(M.Error,e=>{this._abort(E.SocketError,e)}),e.on(M.Disconnected,()=>{this.disconnected||(this.emitError(E.Network,"Lost connection to server."),this.disconnect())}),e.on(M.Close,()=>{this.disconnected||this._abort(E.SocketClosed,"Underlying socket is already closed.")}),e}/** Initialize a connection with the server. */_initialize(e){this._id=e,this.socket.start(e,this._options.token)}/** Handles messages from the server. */_handleMessage(e){let t=e.type,n=e.payload,r=e.src;switch(t){case O.Open:this._lastServerId=this.id,this._open=!0,this.emit("open",this.id);break;case O.Error:this._abort(E.ServerError,n.msg);break;case O.IdTaken:this._abort(E.UnavailableID,`ID "${this.id}" is taken`);break;case O.InvalidKey:this._abort(E.InvalidKey,`API KEY "${this._options.key}" is invalid`);break;case O.Leave:eO.log(`Received leave message from ${r}`),this._cleanupPeer(r),this._connections.delete(r);break;case O.Expire:this.emitError(E.PeerUnavailable,`Could not connect to peer ${r}`);break;case O.Offer:{// we should consider switching this to CALL/CONNECT, but this is the least breaking option. +let e=n.connectionId,t=this.getConnection(r,e);// Create a new connection. +if(t&&(t.close(),eO.warn(`Offer received for existing Connection ID:${e}`)),n.type===P.Media){let i=new eY(r,this,{connectionId:e,_payload:n,metadata:n.metadata});t=i,this._addConnection(r,t),this.emit("call",i)}else if(n.type===P.Data){let i=new this._serializers[n.serialization](r,this,{connectionId:e,_payload:n,metadata:n.metadata,label:n.label,serialization:n.serialization,reliable:n.reliable});t=i,this._addConnection(r,t),this.emit("connection",i)}else{eO.warn(`Received malformed connection type:${n.type}`);return}// Find messages. +let i=this._getMessages(e);for(let e of i)t.handleMessage(e);break}default:{if(!n){eO.warn(`You received a malformed message from ${r} of type ${t}`);return}let i=n.connectionId,o=this.getConnection(r,i);o&&o.peerConnection?o.handleMessage(e):i?this._storeMessage(i,e):eO.warn("You received an unrecognized message:",e)}}}/** Stores messages without a set up connection, to be claimed later. */_storeMessage(e,t){this._lostMessages.has(e)||this._lostMessages.set(e,[]),this._lostMessages.get(e).push(t)}/** + * Retrieve messages from lost message store + * @internal + *///TODO Change it to private +_getMessages(e){let t=this._lostMessages.get(e);return t?(this._lostMessages.delete(e),t):[]}/** + * Connects to the remote peer specified by id and returns a data connection. + * @param peer The brokering ID of the remote peer (their {@apilink Peer.id}). + * @param options for specifying details about Peer Connection + */connect(e,t={}){if(t={serialization:"default",...t},this.disconnected){eO.warn("You cannot connect to a new Peer because you called .disconnect() on this Peer and ended your connection with the server. You can create a new Peer to reconnect, or call reconnect on this peer if you believe its ID to still be available."),this.emitError(E.Disconnected,"Cannot connect to new Peer after disconnecting from server.");return}let n=new this._serializers[t.serialization](e,this,t);return this._addConnection(e,n),n}/** + * Calls the remote peer specified by id and returns a media connection. + * @param peer The brokering ID of the remote peer (their peer.id). + * @param stream The caller's media stream + * @param options Metadata associated with the connection, passed in by whoever initiated the connection. + */call(e,t,n={}){if(this.disconnected){eO.warn("You cannot connect to a new Peer because you called .disconnect() on this Peer and ended your connection with the server. You can create a new Peer to reconnect."),this.emitError(E.Disconnected,"Cannot connect to new Peer after disconnecting from server.");return}if(!t){eO.error("To call a peer, you must provide a stream from your browser's `getUserMedia`.");return}let r=new eY(e,this,{...n,_stream:t});return this._addConnection(e,r),r}/** Add a data/media connection to this peer. */_addConnection(e,t){eO.log(`add connection ${t.type}:${t.connectionId} to peerId:${e}`),this._connections.has(e)||this._connections.set(e,[]),this._connections.get(e).push(t)}//TODO should be private +_removeConnection(e){let t=this._connections.get(e.peer);if(t){let n=t.indexOf(e);-1!==n&&t.splice(n,1)}//remove from lost messages +this._lostMessages.delete(e.connectionId)}/** Retrieve a data/media connection for this peer. */getConnection(e,t){let n=this._connections.get(e);if(!n)return null;for(let e of n)if(e.connectionId===t)return e;return null}_delayedAbort(e,t){setTimeout(()=>{this._abort(e,t)},0)}/** + * Emits an error message and destroys the Peer. + * The Peer is not destroyed if it's in a disconnected state, in which case + * it retains its disconnected state and its existing connections. + */_abort(e,t){eO.error("Aborting!"),this.emitError(e,t),this._lastServerId?this.disconnect():this.destroy()}/** + * Destroys the Peer: closes all active connections as well as the connection + * to the server. + * + * :::caution + * This cannot be undone; the respective peer object will no longer be able + * to create or receive any connections, its ID will be forfeited on the server, + * and all of its data and media connections will be closed. + * ::: + */destroy(){this.destroyed||(eO.log(`Destroy peer with ID:${this.id}`),this.disconnect(),this._cleanup(),this._destroyed=!0,this.emit("close"))}/** Disconnects every connection on this peer. */_cleanup(){for(let e of this._connections.keys())this._cleanupPeer(e),this._connections.delete(e);this.socket.removeAllListeners()}/** Closes all connections to this peer. */_cleanupPeer(e){let t=this._connections.get(e);if(t)for(let e of t)e.close()}/** + * Disconnects the Peer's connection to the PeerServer. Does not close any + * active connections. + * Warning: The peer can no longer create or accept connections after being + * disconnected. It also cannot reconnect to the server. + */disconnect(){if(this.disconnected)return;let e=this.id;eO.log(`Disconnect peer with ID:${e}`),this._disconnected=!0,this._open=!1,this.socket.close(),this._lastServerId=e,this._id=null,this.emit("disconnected",e)}/** Attempts to reconnect with the same ID. + * + * Only {@apilink Peer.disconnect | disconnected peers} can be reconnected. + * Destroyed peers cannot be reconnected. + * If the connection fails (as an example, if the peer's old ID is now taken), + * the peer's existing connections will not close, but any associated errors events will fire. + */reconnect(){if(this.disconnected&&!this.destroyed)eO.log(`Attempting reconnection to server with ID ${this._lastServerId}`),this._disconnected=!1,this._initialize(this._lastServerId);else if(this.destroyed)throw Error("This peer cannot reconnect to the server. It has already been destroyed.");else if(this.disconnected||this.open)throw Error(`Peer ${this.id} cannot reconnect because it is not disconnected from the server!`);else eO.error("In a hurry? We're still trying to make the initial connection!")}/** + * Get a list of available peer IDs. If you're running your own server, you'll + * want to set allow_discovery: true in the PeerServer options. If you're using + * the cloud server, email team@peerjs.com to get the functionality enabled for + * your key. + */listAllPeers(e=e=>{}){this._api.listAllPeers().then(t=>e(t)).catch(e=>this._abort(E.ServerError,e))}constructor(e,t){let n;// Sanity checks +// Ensure WebRTC supported +if(super(),this._serializers={raw:eZ,json:e0,binary:eQ,"binary-utf8":eQ,default:eQ},this._id=null,this._lastServerId=null,// States. +this._destroyed=!1// Connections have been killed +,this._disconnected=!1// Connection to PeerServer killed but P2P connections still active +,this._open=!1// Sockets and such are not yet open. +,this._connections=new Map// All connections for this peer. +,this._lostMessages=new Map// src => [list of messages] +,e&&e.constructor==Object?t=e:e&&(n=e.toString()),// Configurize options +t={debug:0,host:eM.CLOUD_HOST,port:eM.CLOUD_PORT,path:"/",key:e1.DEFAULT_KEY,token:eM.randomToken(),config:eM.defaultConfig,referrerPolicy:"strict-origin-when-cross-origin",serializers:{},...t},this._options=t,this._serializers={...this._serializers,...this.options.serializers},"/"===this._options.host&&(this._options.host=window.location.hostname),this._options.path&&("/"!==this._options.path[0]&&(this._options.path="/"+this._options.path),"/"!==this._options.path[this._options.path.length-1]&&(this._options.path+="/")),void 0===this._options.secure&&this._options.host!==eM.CLOUD_HOST?this._options.secure=eM.isSecure():this._options.host==eM.CLOUD_HOST&&(this._options.secure=!0),this._options.logFunction&&eO.setLogFunction(this._options.logFunction),eO.logLevel=this._options.debug||0,this._api=new eK(t),this._socket=this._createServerConnection(),!eM.supports.audioVideo&&!eM.supports.data){this._delayedAbort(E.BrowserIncompatible,"The current browser does not support WebRTC");return}// Ensure alphanumeric id +if(n&&!eM.validateId(n)){this._delayedAbort(E.InvalidID,`ID "${n}" is invalid`);return}n?this._initialize(n):this._api.retrieveId().then(e=>this._initialize(e)).catch(e=>this._abort(E.ServerError,e))}}e1.DEFAULT_KEY="peerjs",window.peerjs={Peer:e1,util:eM},/** @deprecated Should use peerjs namespace */window.Peer=e1})();//# sourceMappingURL=peerjs.min.js.map + +//# sourceMappingURL=peerjs.min.js.map diff --git a/examples/bun-stream-server/container/src/server.ts b/examples/bun-stream-server/container/src/server.ts index 0105a70..f7e8110 100644 --- a/examples/bun-stream-server/container/src/server.ts +++ b/examples/bun-stream-server/container/src/server.ts @@ -1,4 +1,10 @@ import puppeteer from 'puppeteer'; +import type { PuppeteerLaunchOptions } from 'puppeteer'; // Import the type +import crypto from 'crypto'; // Needed for generating peer IDs + +const EXTENSION_PATH = '/app/extension'; // Path inside the container +// Use the ID from the old library code +const EXTENSION_ID = 'jjndjgheafjngoipoacpjgeicjeomjli'; // Simple hello world server to test container setup const server = Bun.serve({ @@ -7,46 +13,88 @@ const server = Bun.serve({ const url = new URL(req.url); const pathname = url.pathname; + // Function to launch browser with extension, adapted from old library logic + async function launchBrowserWithExtension() { + console.log(`Launching browser with extension ID: ${EXTENSION_ID}...`); + + const launchOptions: PuppeteerLaunchOptions = { + headless: "new", // Use "new" headless mode which supports extensions + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, + args: [ + // Standard container args + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + // Args from old library for extension loading + `--disable-extensions-except=${EXTENSION_PATH}`, + `--load-extension=${EXTENSION_PATH}`, + `--allowlisted-extension-id=${EXTENSION_ID}`, // Use the specific ID + '--autoplay-policy=no-user-gesture-required', + // Optional: Window size from old library (uncomment if needed) + // '--window-size=1920,1080', + ], + }; + + console.log("Puppeteer Launch Options:", launchOptions); + + const browser = await puppeteer.launch(launchOptions); + console.log('Browser launched successfully.'); + return browser; + } + // Health check endpoint if (pathname === '/health') { return new Response(JSON.stringify({ status: 'healthy', puppeteer: 'imported', location: process.env.CLOUDFLARE_LOCATION || 'local', - region: process.env.CLOUDFLARE_REGION || 'dev' + region: process.env.CLOUDFLARE_REGION || 'dev', + expectedExtensionId: EXTENSION_ID // Indicate the expected ID }), { headers: { 'Content-Type': 'application/json' } }); } - // Test Puppeteer endpoint + // Test Puppeteer endpoint to check specific extension ID if (pathname === '/test-puppeteer') { + let browser; try { - console.log('Launching browser...'); - const browser = await puppeteer.launch({ - headless: "new", - executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage' - ] - }); - + console.log(`Testing Puppeteer: checking for extension ID ${EXTENSION_ID}...`); + browser = await launchBrowserWithExtension(); const version = await browser.version(); - const wsEndpoint = browser.wsEndpoint(); - + + // Verify the specific extension loaded by finding its target using the known ID + // Extensions can run as background pages (Manifest V2) or service workers (Manifest V3) + console.log(`Waiting for extension target with ID ${EXTENSION_ID}...`); + const extensionTarget = await browser.waitForTarget( + (target) => (target.type() === 'background_page' || target.type() === 'service_worker') && + target.url().startsWith(`chrome-extension://${EXTENSION_ID}/`), + { timeout: 15000 } // Add timeout for waiting + ); + + if (!extensionTarget) { + // If not found, list available targets for debugging + const targets = browser.targets(); + const targetInfo = targets.map(t => ({ type: t.type(), url: t.url() })); + console.error("Available targets:", targetInfo); + throw new Error(`Extension target with ID ${EXTENSION_ID} not found within timeout.`); + } + console.log(`Extension ${EXTENSION_ID} loaded successfully: URL=${extensionTarget.url()}`); + await browser.close(); - + return new Response(JSON.stringify({ status: 'success', browserVersion: version, - wsEndpoint: wsEndpoint + extensionFound: true, + extensionId: EXTENSION_ID, + message: `Puppeteer launched and extension ${EXTENSION_ID} target found.` }), { headers: { 'Content-Type': 'application/json' } }); } catch (error: any) { - console.error('Puppeteer test failed:', error); + console.error('Puppeteer test with extension failed:', error); + if (browser) await browser.close(); // Ensure browser is closed on error return new Response(JSON.stringify({ status: 'error', message: error.message || 'Unknown error', @@ -58,8 +106,110 @@ const server = Bun.serve({ } } + // New endpoint for starting the stream + if (pathname === '/start-stream' && req.method === 'POST') { + let browser; + try { + const { url: targetUrl, peerId: destPeerId } = await req.json(); + if (!targetUrl || !destPeerId) { + return new Response(JSON.stringify({ error: 'Missing targetUrl or peerId (destPeerId)' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); + } + + // --- Browser Management (Placeholder) --- + console.log(`Received /start-stream request for URL: ${targetUrl}, Dest Peer: ${destPeerId}`); + browser = await launchBrowserWithExtension(); + // --- End Browser Management --- + + const page = await browser.newPage(); + console.log(`Navigating page to: ${targetUrl}`); + await page.goto(targetUrl); + console.log(`Page navigated successfully.`); + + // Find the extension's background target using the known ID + console.log(`Waiting for extension target with ID ${EXTENSION_ID} for streaming...`); + const extensionTarget = await browser.waitForTarget( + (target) => (target.type() === 'background_page' || target.type() === 'service_worker') && + target.url().startsWith(`chrome-extension://${EXTENSION_ID}/`), + { timeout: 15000 } // Add timeout + ); + if (!extensionTarget) throw new Error(`Could not find extension's background target with ID ${EXTENSION_ID} within timeout.`); + console.log(`Found extension target: ${extensionTarget.url()}`); + + // Get the execution context (background page or service worker) + let context; + const targetType = extensionTarget.type(); + console.log(`Extension target type: ${targetType}`); + if (targetType === 'background_page') { // Manifest V2 + context = await extensionTarget.page(); + } else if (targetType === 'service_worker') { // Manifest V3 + context = await extensionTarget.worker(); + } + if (!context) throw new Error(`Could not get execution context for extension target type: ${targetType}`); + + // Generate the source peer ID for the server-side (extension) peer + const srcPeerId = crypto.randomUUID(); + const peers = { srcPeerId, destPeerId }; + + console.log(`Calling INITIALIZE in extension context (${EXTENSION_ID}) with peers:`, peers); + + // Execute the INITIALIZE function within the extension's context. + await Promise.race([ + context.evaluate( + async (passedPeers) => { + // @ts-ignore - INITIALIZE is defined in the extension's global scope (window/self) + if (typeof INITIALIZE !== 'function') { + console.error('INITIALIZE function not found in extension context global scope!'); + throw new Error('INITIALIZE function not found in extension context'); + } + console.log('[Extension Context] Calling INITIALIZE with:', passedPeers); + // @ts-ignore + await INITIALIZE(passedPeers); + console.log('[Extension Context] INITIALIZE finished.'); + return { success: true }; // Ensure evaluate resolves + }, + peers + ), + new Promise((_, reject) => setTimeout(() => reject(new Error('context.evaluate timed out after 30 seconds')), 30000)) + ]); + + console.log('INITIALIZE called in extension successfully via evaluate.'); + + // --- Browser Lifecycle Management (Placeholder) --- + // Keep browser open for streaming + // Need logic to close it later (e.g., /stop-stream endpoint) + + return new Response(JSON.stringify({ + status: 'success', + message: 'Stream initialization requested.', + srcPeerId: srcPeerId, + browserWSEndpoint: browser.wsEndpoint() + }), { + headers: { 'Content-Type': 'application/json' }, + }); + + } catch (error: any) { + console.error('Stream initialization failed:', error); + if (browser) { + try { await browser.close(); } catch (closeErr) { console.error("Failed to close browser on error:", closeErr); } + } + return new Response(JSON.stringify({ + status: 'error', + message: error.message || 'Unknown error during stream initialization', + stack: error.stack || '' + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + } + + // Default 404 return new Response('Not Found', { status: 404 }); }, + error(error: Error) { + console.error("Server Error:", error); + return new Response("Internal Server Error", { status: 500 }); + }, }); console.log(`Container server running at http://localhost:${server.port}`); \ No newline at end of file From cb04b3e55d42215c5f8fd59ee7c3ae81425e1423 Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Wed, 23 Apr 2025 09:06:32 -0700 Subject: [PATCH 02/13] refactor --- .../bun-stream-server/container/src/server.ts | 335 +++++++----------- 1 file changed, 132 insertions(+), 203 deletions(-) diff --git a/examples/bun-stream-server/container/src/server.ts b/examples/bun-stream-server/container/src/server.ts index f7e8110..7ce8da6 100644 --- a/examples/bun-stream-server/container/src/server.ts +++ b/examples/bun-stream-server/container/src/server.ts @@ -1,214 +1,143 @@ import puppeteer from 'puppeteer'; -import type { PuppeteerLaunchOptions } from 'puppeteer'; // Import the type -import crypto from 'crypto'; // Needed for generating peer IDs +import type { PuppeteerLaunchOptions, Browser, Target } from 'puppeteer'; +import crypto from 'crypto'; -const EXTENSION_PATH = '/app/extension'; // Path inside the container -// Use the ID from the old library code +const EXTENSION_PATH = '/app/extension'; const EXTENSION_ID = 'jjndjgheafjngoipoacpjgeicjeomjli'; - -// Simple hello world server to test container setup +const EXTENSION_URL_PREFIX = `chrome-extension://${EXTENSION_ID}/`; + +/** Utility: Create JSON response */ +function jsonResponse(data: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json', ...(init.headers || {}) }, + ...init, + }); +} + +/** Build Puppeteer launch options */ +function buildLaunchOptions(): PuppeteerLaunchOptions { + return { + headless: 'new', + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + `--disable-extensions-except=${EXTENSION_PATH}`, + `--load-extension=${EXTENSION_PATH}`, + `--allowlisted-extension-id=${EXTENSION_ID}`, + '--autoplay-policy=no-user-gesture-required', + ], + }; +} + +/** Launch browser, ensuring the extension is loaded */ +async function launchBrowserWithExtension(): Promise { + const options = buildLaunchOptions(); + console.log('Launching browser with options:', options); + const browser = await puppeteer.launch(options); + console.log('Browser launched.'); + return browser; +} + +/** Wait for the extension target (background/service worker) */ +async function findExtensionTarget(browser: Browser, timeout = 15000): Promise { + const target = await browser.waitForTarget( + t => (t.type() === 'background_page' || t.type() === 'service_worker') && + t.url().startsWith(EXTENSION_URL_PREFIX), + { timeout } + ); + if (!target) { + const debugTargets = browser.targets().map(t => ({ type: t.type(), url: t.url() })); + console.error('Extension target not found. Existing targets:', debugTargets); + throw new Error(`Extension with ID ${EXTENSION_ID} not found within timeout.`); + } + return target; +} + +/* ---------- Route Handlers ---------- */ +async function handleHealth(): Promise { + return jsonResponse({ + status: 'healthy', + puppeteer: 'imported', + location: process.env.CLOUDFLARE_LOCATION || 'local', + region: process.env.CLOUDFLARE_REGION || 'dev', + expectedExtensionId: EXTENSION_ID, + }); +} + +async function handleTest(): Promise { + let browser: Browser | undefined; + try { + browser = await launchBrowserWithExtension(); + const version = await browser.version(); + await findExtensionTarget(browser); + await browser.close(); + return jsonResponse({ + status: 'success', + browserVersion: version, + extensionFound: true, + extensionId: EXTENSION_ID, + }); + } catch (err: any) { + console.error('handleTest error:', err); + if (browser) await browser.close(); + return jsonResponse({ status: 'error', message: err.message }, { status: 500 }); + } +} + +async function handleStartStream(req: Request): Promise { + let browser: Browser | undefined; + try { + const { url: targetUrl, peerId: destPeerId } = await req.json(); + if (!targetUrl || !destPeerId) return jsonResponse({ error: 'Missing targetUrl or peerId' }, { status: 400 }); + + browser = await launchBrowserWithExtension(); + const page = await browser.newPage(); + await page.goto(targetUrl); + + const extTarget = await findExtensionTarget(browser); + let context: any; + context = extTarget.type() === 'service_worker' ? await extTarget.worker() : await extTarget.page(); + if (!context) throw new Error('Failed to obtain extension execution context'); + + const srcPeerId = crypto.randomUUID(); + const peers = { srcPeerId, destPeerId }; + + await Promise.race([ + context.evaluate(async (p: any) => { + // @ts-ignore + if (typeof INITIALIZE !== 'function') throw new Error('INITIALIZE not found'); + // @ts-ignore + await INITIALIZE(p); + }, peers), + new Promise((_, reject) => setTimeout(() => reject(new Error('INITIALIZE timeout')), 30000)), + ]); + + return jsonResponse({ status: 'success', srcPeerId, browserWSEndpoint: browser.wsEndpoint() }); + } catch (err: any) { + console.error('handleStartStream error:', err); + if (browser) await browser.close(); + return jsonResponse({ status: 'error', message: err.message }, { status: 500 }); + } +} + +/* ---------- Bun Server ---------- */ const server = Bun.serve({ port: 8080, async fetch(req) { const url = new URL(req.url); - const pathname = url.pathname; - - // Function to launch browser with extension, adapted from old library logic - async function launchBrowserWithExtension() { - console.log(`Launching browser with extension ID: ${EXTENSION_ID}...`); - - const launchOptions: PuppeteerLaunchOptions = { - headless: "new", // Use "new" headless mode which supports extensions - executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, - args: [ - // Standard container args - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - // Args from old library for extension loading - `--disable-extensions-except=${EXTENSION_PATH}`, - `--load-extension=${EXTENSION_PATH}`, - `--allowlisted-extension-id=${EXTENSION_ID}`, // Use the specific ID - '--autoplay-policy=no-user-gesture-required', - // Optional: Window size from old library (uncomment if needed) - // '--window-size=1920,1080', - ], - }; - - console.log("Puppeteer Launch Options:", launchOptions); - - const browser = await puppeteer.launch(launchOptions); - console.log('Browser launched successfully.'); - return browser; - } - - // Health check endpoint - if (pathname === '/health') { - return new Response(JSON.stringify({ - status: 'healthy', - puppeteer: 'imported', - location: process.env.CLOUDFLARE_LOCATION || 'local', - region: process.env.CLOUDFLARE_REGION || 'dev', - expectedExtensionId: EXTENSION_ID // Indicate the expected ID - }), { - headers: { 'Content-Type': 'application/json' } - }); - } - - // Test Puppeteer endpoint to check specific extension ID - if (pathname === '/test-puppeteer') { - let browser; - try { - console.log(`Testing Puppeteer: checking for extension ID ${EXTENSION_ID}...`); - browser = await launchBrowserWithExtension(); - const version = await browser.version(); - - // Verify the specific extension loaded by finding its target using the known ID - // Extensions can run as background pages (Manifest V2) or service workers (Manifest V3) - console.log(`Waiting for extension target with ID ${EXTENSION_ID}...`); - const extensionTarget = await browser.waitForTarget( - (target) => (target.type() === 'background_page' || target.type() === 'service_worker') && - target.url().startsWith(`chrome-extension://${EXTENSION_ID}/`), - { timeout: 15000 } // Add timeout for waiting - ); - - if (!extensionTarget) { - // If not found, list available targets for debugging - const targets = browser.targets(); - const targetInfo = targets.map(t => ({ type: t.type(), url: t.url() })); - console.error("Available targets:", targetInfo); - throw new Error(`Extension target with ID ${EXTENSION_ID} not found within timeout.`); - } - console.log(`Extension ${EXTENSION_ID} loaded successfully: URL=${extensionTarget.url()}`); - - await browser.close(); - - return new Response(JSON.stringify({ - status: 'success', - browserVersion: version, - extensionFound: true, - extensionId: EXTENSION_ID, - message: `Puppeteer launched and extension ${EXTENSION_ID} target found.` - }), { - headers: { 'Content-Type': 'application/json' } - }); - } catch (error: any) { - console.error('Puppeteer test with extension failed:', error); - if (browser) await browser.close(); // Ensure browser is closed on error - return new Response(JSON.stringify({ - status: 'error', - message: error.message || 'Unknown error', - stack: error.stack || '' - }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } + const { pathname } = url; + + try { + if (pathname === '/health') return await handleHealth(); + if (pathname === '/test-puppeteer') return await handleTest(); + if (pathname === '/start-stream' && req.method === 'POST') return await handleStartStream(req); + return new Response('Not Found', { status: 404 }); + } catch (err: any) { + console.error('Unhandled error:', err); + return jsonResponse({ error: err.message }, { status: 500 }); } - - // New endpoint for starting the stream - if (pathname === '/start-stream' && req.method === 'POST') { - let browser; - try { - const { url: targetUrl, peerId: destPeerId } = await req.json(); - if (!targetUrl || !destPeerId) { - return new Response(JSON.stringify({ error: 'Missing targetUrl or peerId (destPeerId)' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - } - - // --- Browser Management (Placeholder) --- - console.log(`Received /start-stream request for URL: ${targetUrl}, Dest Peer: ${destPeerId}`); - browser = await launchBrowserWithExtension(); - // --- End Browser Management --- - - const page = await browser.newPage(); - console.log(`Navigating page to: ${targetUrl}`); - await page.goto(targetUrl); - console.log(`Page navigated successfully.`); - - // Find the extension's background target using the known ID - console.log(`Waiting for extension target with ID ${EXTENSION_ID} for streaming...`); - const extensionTarget = await browser.waitForTarget( - (target) => (target.type() === 'background_page' || target.type() === 'service_worker') && - target.url().startsWith(`chrome-extension://${EXTENSION_ID}/`), - { timeout: 15000 } // Add timeout - ); - if (!extensionTarget) throw new Error(`Could not find extension's background target with ID ${EXTENSION_ID} within timeout.`); - console.log(`Found extension target: ${extensionTarget.url()}`); - - // Get the execution context (background page or service worker) - let context; - const targetType = extensionTarget.type(); - console.log(`Extension target type: ${targetType}`); - if (targetType === 'background_page') { // Manifest V2 - context = await extensionTarget.page(); - } else if (targetType === 'service_worker') { // Manifest V3 - context = await extensionTarget.worker(); - } - if (!context) throw new Error(`Could not get execution context for extension target type: ${targetType}`); - - // Generate the source peer ID for the server-side (extension) peer - const srcPeerId = crypto.randomUUID(); - const peers = { srcPeerId, destPeerId }; - - console.log(`Calling INITIALIZE in extension context (${EXTENSION_ID}) with peers:`, peers); - - // Execute the INITIALIZE function within the extension's context. - await Promise.race([ - context.evaluate( - async (passedPeers) => { - // @ts-ignore - INITIALIZE is defined in the extension's global scope (window/self) - if (typeof INITIALIZE !== 'function') { - console.error('INITIALIZE function not found in extension context global scope!'); - throw new Error('INITIALIZE function not found in extension context'); - } - console.log('[Extension Context] Calling INITIALIZE with:', passedPeers); - // @ts-ignore - await INITIALIZE(passedPeers); - console.log('[Extension Context] INITIALIZE finished.'); - return { success: true }; // Ensure evaluate resolves - }, - peers - ), - new Promise((_, reject) => setTimeout(() => reject(new Error('context.evaluate timed out after 30 seconds')), 30000)) - ]); - - console.log('INITIALIZE called in extension successfully via evaluate.'); - - // --- Browser Lifecycle Management (Placeholder) --- - // Keep browser open for streaming - // Need logic to close it later (e.g., /stop-stream endpoint) - - return new Response(JSON.stringify({ - status: 'success', - message: 'Stream initialization requested.', - srcPeerId: srcPeerId, - browserWSEndpoint: browser.wsEndpoint() - }), { - headers: { 'Content-Type': 'application/json' }, - }); - - } catch (error: any) { - console.error('Stream initialization failed:', error); - if (browser) { - try { await browser.close(); } catch (closeErr) { console.error("Failed to close browser on error:", closeErr); } - } - return new Response(JSON.stringify({ - status: 'error', - message: error.message || 'Unknown error during stream initialization', - stack: error.stack || '' - }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } - } - - // Default 404 - return new Response('Not Found', { status: 404 }); - }, - error(error: Error) { - console.error("Server Error:", error); - return new Response("Internal Server Error", { status: 500 }); }, }); From e6f10efa2691a31ff66f47651545aa7ac5a7d757 Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Mon, 2 Jun 2025 15:14:08 -0700 Subject: [PATCH 03/13] working state not in docker --- .dockerignore | 31 + .gitignore | 11 +- examples/basic-react-demo/README.md | 136 ++++ examples/bun-stream-server/README.md | 4 +- .../bun-stream-server/container/.dockerignore | 17 + .../bun-stream-server/container/Dockerfile | 34 + .../bun-stream-server/container/bun.lockb | Bin 39927 -> 42426 bytes .../container/extension/background.js | 23 +- .../container/extension/options.html | 2 - .../container/extension/options.js | 28 - .../container/extension/streaming.html | 13 + .../container/extension/streaming.js | 307 +++++++++ .../bun-stream-server/container/package.json | 4 +- .../bun-stream-server/container/src/server.ts | 448 +++++++++++-- examples/bun-stream-server/receiver.html | 627 ++++++++++++++++++ examples/bun-stream-server/src/index.ts | 78 ++- examples/bun-stream-server/test-end-to-end.js | 79 +++ examples/bun-stream-server/test-streaming.js | 85 +++ .../worker-configuration.d.ts | 6 +- packages/stream-kit-react/README.md | 2 + packages/stream-kit-server/PLAN.md | 205 ++++++ packages/stream-kit-server/src/client.ts | 117 ++++ .../src/container-manager.ts | 139 ++++ packages/stream-kit-server/src/index.ts | 11 +- packages/stream-kit-server/src/server.ts | 123 ++++ packages/stream-kit-server/src/types.ts | 37 ++ packages/stream-kit-web/README.md | 5 +- 27 files changed, 2442 insertions(+), 130 deletions(-) create mode 100644 .dockerignore create mode 100644 examples/basic-react-demo/README.md create mode 100644 examples/bun-stream-server/container/.dockerignore create mode 100644 examples/bun-stream-server/container/Dockerfile delete mode 100644 examples/bun-stream-server/container/extension/options.html delete mode 100644 examples/bun-stream-server/container/extension/options.js create mode 100644 examples/bun-stream-server/container/extension/streaming.html create mode 100644 examples/bun-stream-server/container/extension/streaming.js create mode 100644 examples/bun-stream-server/receiver.html create mode 100644 examples/bun-stream-server/test-end-to-end.js create mode 100644 examples/bun-stream-server/test-streaming.js create mode 100644 packages/stream-kit-server/PLAN.md create mode 100644 packages/stream-kit-server/src/client.ts create mode 100644 packages/stream-kit-server/src/container-manager.ts create mode 100644 packages/stream-kit-server/src/server.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5e3f262 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# VCS +.git + +# Node +node_modules + +# Build artifacts / Logs +.turbo +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# OS / IDE generated +.DS_Store +*.pem +*.key + +# Examples (ignore all except the one we are building) +examples/* +!examples/bun-stream-server + +# Packages (ignore node_modules within packages, keep source) +packages/*/node_modules +packages/*/dist +packages/*/tsconfig.tsbuildinfo + +# Specific files +.env +.env.* +!.env.example \ No newline at end of file diff --git a/.gitignore b/.gitignore index 39dd837..2a14f5f 100644 --- a/.gitignore +++ b/.gitignore @@ -52,11 +52,8 @@ junit.xml # Logs *.log -# Docker -Dockerfile -.dockerignore -docker-compose.yml -*.dockerfile - # Wrangler -.wrangler/ \ No newline at end of file +.wrangler/ + +# LLM context docs +.llms \ No newline at end of file diff --git a/examples/basic-react-demo/README.md b/examples/basic-react-demo/README.md new file mode 100644 index 0000000..1e7a0b8 --- /dev/null +++ b/examples/basic-react-demo/README.md @@ -0,0 +1,136 @@ +# Stream Kit React Demo + +A minimal React application demonstrating how to integrate cloud-rendered streams into a web application using the Stream Kit libraries. + +## Overview + +This demo showcases: +- Setting up the Stream Kit client in a React application +- Rendering WebRTC video streams with `StreamCanvas` +- Managing stream state and quality options +- Handling user interactions with streamed content + +## Getting Started + +### Prerequisites + +- Node.js 16+ +- npm or pnpm + +### Installation + +From the project root directory: + +```bash +# Install dependencies +pnpm install + +# Start the development server +pnpm --filter basic-react-demo dev +``` + +Visit http://localhost:5173 to see the demo in action. + +## Key Features + +- **Stream Provider Setup**: Demonstrates how to initialize and provide the Stream Kit client throughout your React application +- **Video Stream Component**: Shows how to embed the cloud-rendered stream using the `StreamCanvas` component +- **State Management**: Displays connection status, quality metrics, and error handling +- **Interaction Handling**: Example of sending user inputs to the streamed application + +## How It Works + +The demo connects to a Stream Kit server endpoint that hosts a rendered application. The video output is streamed to the client via WebRTC while user interactions are sent back to the server. + +### Key Components + +```jsx +// Initialize the client +const streamClient = createStreamClient({ + brokerUrl: 'api.example.com' +}); + +// Provide the client to your app +function App() { + return ( + + + + ); +} + +// Use the StreamCanvas component to display the stream +function DemoContent() { + const [streamState, setStreamState] = useState({ status: 'initializing' }); + + return ( +
+ + +
+ Connection status: {streamState.status} +
+
+ ); +} +``` + +## Configuration + +The demo can be configured by editing the following files: + +- `src/App.tsx`: Main application component with Stream Kit integration +- `src/main.tsx`: Entry point that renders the App component +- `vite.config.ts`: Build and development server configuration + +## Testing + +The demo includes tests demonstrating how to test Stream Kit components: + +```bash +# Run tests +pnpm --filter basic-react-demo test +``` + +## Using with the Bun Stream Server + +This demo can be connected to the `bun-stream-server` example in this repo to create a full end-to-end streaming demo: + +1. Start the Bun Stream Server: + ```bash + cd ../bun-stream-server/container + docker build -t stream-server-test . + docker run -p 8080:8080 --rm stream-server-test + ``` + +2. Configure the React demo to connect to this server by setting the appropriate broker URL in `App.tsx`. You don't need to configure a custom PeerJS server as the extension uses the default PeerJS server automatically. + +3. Start the React app with `pnpm dev` + +The WebRTC connection will be established through the default PeerJS server, so no additional configuration is needed for signaling or peer discovery. + +## Customization + +To adapt this demo for your own projects: + +1. Update the broker URL to point to your Stream Kit server +2. Modify the stream URL to target your own cloud-rendered application +3. Adjust render options based on your performance requirements +4. Implement custom interaction handlers specific to your application + +## Related Resources + +- [Stream Kit Documentation](../../README.md) +- [React Component API](../../packages/stream-kit-react/README.md) +- [Client Library API](../../packages/stream-kit-web/README.md) + +## License + +MIT License \ No newline at end of file diff --git a/examples/bun-stream-server/README.md b/examples/bun-stream-server/README.md index d072c5f..3489805 100644 --- a/examples/bun-stream-server/README.md +++ b/examples/bun-stream-server/README.md @@ -86,7 +86,9 @@ The container runs: - Bun for the server runtime - Puppeteer for browser automation - Chrome/Chromium for page rendering -- WebRTC for streaming +- WebRTC for streaming with PeerJS + +The included browser extension uses the default PeerJS server for WebRTC signaling, so no additional configuration is needed to establish peer connections. This is suitable for development and testing environments. For production deployments, you may want to configure a custom PeerJS server for better reliability and control. ## Environment Variables diff --git a/examples/bun-stream-server/container/.dockerignore b/examples/bun-stream-server/container/.dockerignore new file mode 100644 index 0000000..414a7c1 --- /dev/null +++ b/examples/bun-stream-server/container/.dockerignore @@ -0,0 +1,17 @@ +# Node dependencies +node_modules/ + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build output +dist/ +build/ + +# Env files +.env + +# Other +.DS_Store \ No newline at end of file diff --git a/examples/bun-stream-server/container/Dockerfile b/examples/bun-stream-server/container/Dockerfile new file mode 100644 index 0000000..19b99d8 --- /dev/null +++ b/examples/bun-stream-server/container/Dockerfile @@ -0,0 +1,34 @@ +# Use Bun as base image +FROM oven/bun:1 as base + +# We don't need the standalone Chromium +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true + +# Install Chromium browser (arm64-compatible) and dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + chromium \ + fonts-liberation \ + libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 \ + libcups2 libdbus-1-3 libdrm2 libgbm1 libgtk-3-0 \ + libnspr4 libnss3 libxcomposite1 libxdamage1 libxfixes3 \ + libxkbcommon0 libxrandr2 xdg-utils \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install dependencies (puppeteer-core doesn't download Chromium) +RUN bun install + +# Copy source +COPY . . + +# Set environment variable to use system Chrome +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + +# Run the app +EXPOSE 8080 +CMD ["bun", "run", "src/server.ts"] \ No newline at end of file diff --git a/examples/bun-stream-server/container/bun.lockb b/examples/bun-stream-server/container/bun.lockb index de0afaad4fab6c27321f286072d39898da6ac526..bb73c296f32164deaea9a928ae0dc4be948c9fc8 100755 GIT binary patch literal 42426 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 NI0WA@{LlOQzW{h9#b5vc literal 39927 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 Ae*gdg diff --git a/examples/bun-stream-server/container/extension/background.js b/examples/bun-stream-server/container/extension/background.js index 699590d..ae6709e 100644 --- a/examples/bun-stream-server/container/extension/background.js +++ b/examples/bun-stream-server/container/extension/background.js @@ -1,7 +1,24 @@ +/** + * Chrome Extension Background Script + * + * This background script runs when the extension loads and creates a tab + * that loads our streaming interface. This tab becomes the target that + * Puppeteer will interact with to initiate screen streaming. + * + * FLOW: + * 1. Extension loads → background.js runs + * 2. Creates hidden tab with streaming.html + * 3. streaming.html loads PeerJS and streaming.js + * 4. Puppeteer calls INITIALIZE() function in streaming.js context + * 5. Screen capture and streaming begins + */ + chrome.tabs.create( { - active: false, - url: `chrome-extension://${chrome.runtime.id}/options.html`, + active: false, // Hidden tab - user doesn't need to see it + url: `chrome-extension://${chrome.runtime.id}/streaming.html`, }, - (tab) => {} + (tab) => { + console.log('[Background] Created streaming tab:', tab.id); + } ); diff --git a/examples/bun-stream-server/container/extension/options.html b/examples/bun-stream-server/container/extension/options.html deleted file mode 100644 index 07afad4..0000000 --- a/examples/bun-stream-server/container/extension/options.html +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/examples/bun-stream-server/container/extension/options.js b/examples/bun-stream-server/container/extension/options.js deleted file mode 100644 index 9d648c6..0000000 --- a/examples/bun-stream-server/container/extension/options.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * INITIALIZE get's called within the context of the chrome extension - * by puppeteer calling evaluate on the extension's background page (options.html) - * - * captures the active puppeteer tab in to a media stream that we use - * to call the chromecast receiver using peerjs - */ -async function INITIALIZE({ srcPeerId, destPeerId }) { - const stream = await new Promise((resolve, reject) => { - chrome.tabCapture.capture( - { video: true, audio: true }, - (capturedStream) => { - if (capturedStream) resolve(capturedStream); - else reject(new Error('Failed to capture the tab.')); - } - ); - }); - - const peer = new Peer(srcPeerId); - await new Promise((resolve, reject) => { - peer.once('open', resolve); - peer.on('error', (error) => { - reject(new Error(`Peer error: ${error.message}`)); - }); - }); - - peer.call(destPeerId, stream); -} diff --git a/examples/bun-stream-server/container/extension/streaming.html b/examples/bun-stream-server/container/extension/streaming.html new file mode 100644 index 0000000..c732c25 --- /dev/null +++ b/examples/bun-stream-server/container/extension/streaming.html @@ -0,0 +1,13 @@ + + + + + diff --git a/examples/bun-stream-server/container/extension/streaming.js b/examples/bun-stream-server/container/extension/streaming.js new file mode 100644 index 0000000..9dc8ec7 --- /dev/null +++ b/examples/bun-stream-server/container/extension/streaming.js @@ -0,0 +1,307 @@ +/** + * Chrome Extension Streaming Handler (streaming.js) + * + * FILE STRUCTURE: + * - background.js: Creates hidden tab with streaming.html when extension loads + * - streaming.html: Loads PeerJS library and this streaming.js file + * - streaming.js: Contains INITIALIZE() function and connection tracking logic + * + * HOW THE SYSTEM WORKS: + * + * 1. CONTAINER STARTUP: + * - Container server starts Puppeteer browser with this Chrome extension + * - background.js creates a hidden tab loading streaming.html + * - streaming.html loads peer.js (PeerJS library) and this streaming.js file + * + * 2. PUPPETEER INTEGRATION: + * - Container server calls page.evaluate() to execute INITIALIZE() function + * - This function captures the current tab's video/audio stream + * - Creates PeerJS connection to stream to remote peer (like a receiver device) + * + * 3. CONNECTION TRACKING SYSTEM: + * - window.activeConnections tracks all active streaming connections + * - Container server polls this every 15 seconds via page.evaluate() + * - When connections drop to 0, starts 60-second shutdown timer + * - If no new connections within grace period, browser shuts down automatically + * + * 4. LIFECYCLE MANAGEMENT: + * - Browser stays alive as long as streams are active + * - Automatically shuts down when unused (resource efficient) + * - Can handle multiple simultaneous streams to different peers + * + * ARCHITECTURE FLOW: + * Stream Request → Container Server → Puppeteer → Chrome Extension → PeerJS → Remote Peer + * ↘ Connection Monitoring ↙ + * (Polls window.activeConnections) + * + * IMPORTANT NOTES: + * - This runs in the Chrome extension context (isolated from normal web pages) + * - Has special permissions for tab capture (see manifest.json) + * - INITIALIZE function must be globally accessible for Puppeteer's page.evaluate() + * - Connection tracking is critical for automatic resource management + */ + +/** + * INITIALIZE gets called within the context of the chrome extension + * by puppeteer calling evaluate on the extension's background page (streaming.html) + * + * captures the active puppeteer tab into a media stream that we use + * to call the remote peer using peerjs + * + * @param {Object} params - Parameters from container server + * @param {string} params.srcPeerId - This browser's PeerJS ID (UUID) + * @param {string} params.destPeerId - Remote peer's PeerJS ID (receiver) + */ + +// Initialize connection tracking for container server monitoring +// The container server polls this Set to determine when to shut down +console.log('[INIT] Initializing window.activeConnections Set'); +window.activeConnections = new Set(); + +// Debug tracking that container server can read +console.log('[INIT] Initializing window.streamingDebug object'); +window.streamingDebug = { + initializeCalled: false, + addConnectionCalled: false, + lastPeerId: null, + lastError: null, + callCount: 0, + scriptLoadTime: new Date().toISOString(), + peerJsAvailable: typeof Peer !== 'undefined', + chromeTabCaptureAvailable: typeof chrome !== 'undefined' && typeof chrome.tabCapture !== 'undefined' +}; + +console.log('[INIT] streaming.js loaded successfully'); +console.log('[INIT] PeerJS available:', window.streamingDebug.peerJsAvailable); +console.log('[INIT] Chrome tabCapture available:', window.streamingDebug.chromeTabCaptureAvailable); +console.log('[INIT] Initial activeConnections size:', window.activeConnections.size); + +/** + * Add a peer connection to the tracking set + * Container server monitors window.activeConnections.size via page.evaluate() + */ +function addConnection(peerId) { + console.log(`[CONNECTION] Adding connection for peer: ${peerId}`); + console.log(`[CONNECTION] activeConnections before add:`, Array.from(window.activeConnections)); + console.log(`[CONNECTION] activeConnections size before add:`, window.activeConnections.size); + + window.activeConnections.add(peerId); + + console.log(`[CONNECTION] activeConnections after add:`, Array.from(window.activeConnections)); + console.log(`[CONNECTION] activeConnections size after add:`, window.activeConnections.size); + console.log(`[CONNECTION] Connection opened to ${peerId}. Active: ${window.activeConnections.size}`); + + // Update debug info + window.streamingDebug.addConnectionCalled = true; + window.streamingDebug.lastPeerId = peerId; +} + +/** + * Remove a peer connection from tracking set + * When this reaches 0, container server starts shutdown grace period + */ +function removeConnection(peerId) { + console.log(`[CONNECTION] Removing connection for peer: ${peerId}`); + console.log(`[CONNECTION] activeConnections before remove:`, Array.from(window.activeConnections)); + console.log(`[CONNECTION] activeConnections size before remove:`, window.activeConnections.size); + + const wasPresent = window.activeConnections.has(peerId); + window.activeConnections.delete(peerId); + + console.log(`[CONNECTION] Was peer present before removal: ${wasPresent}`); + console.log(`[CONNECTION] activeConnections after remove:`, Array.from(window.activeConnections)); + console.log(`[CONNECTION] activeConnections size after remove:`, window.activeConnections.size); + console.log(`[CONNECTION] Connection closed to ${peerId}. Active: ${window.activeConnections.size}`); +} + +async function INITIALIZE({ srcPeerId, destPeerId }) { + console.log(`[INITIALIZE] ========== STARTING INITIALIZATION ==========`); + console.log(`[INITIALIZE] Function called with params:`, { srcPeerId, destPeerId }); + console.log(`[INITIALIZE] srcPeerId type: ${typeof srcPeerId}, value: "${srcPeerId}"`); + console.log(`[INITIALIZE] destPeerId type: ${typeof destPeerId}, value: "${destPeerId}"`); + + // Mark that INITIALIZE was called + window.streamingDebug.initializeCalled = true; + window.streamingDebug.callCount++; + window.streamingDebug.lastPeerId = destPeerId; + window.streamingDebug.lastError = null; // Reset error state + + console.log(`[INITIALIZE] Updated debug info - call count: ${window.streamingDebug.callCount}`); + console.log(`[INITIALIZE] Current activeConnections:`, Array.from(window.activeConnections)); + console.log(`[INITIALIZE] Current activeConnections size:`, window.activeConnections.size); + + try { + // Capture the current tab's video and audio + console.log(`[TAB_CAPTURE] Starting tab capture...`); + console.log(`[TAB_CAPTURE] chrome object available:`, typeof chrome !== 'undefined'); + console.log(`[TAB_CAPTURE] chrome.tabCapture available:`, typeof chrome !== 'undefined' && typeof chrome.tabCapture !== 'undefined'); + + const stream = await new Promise((resolve, reject) => { + chrome.tabCapture.capture( + { video: true, audio: true }, + (capturedStream) => { + if (capturedStream) { + console.log(`[TAB_CAPTURE] ✅ Successfully captured tab stream`); + console.log(`[TAB_CAPTURE] Stream has ${capturedStream.getTracks().length} tracks`); + capturedStream.getTracks().forEach((track, index) => { + console.log(`[TAB_CAPTURE] Track ${index}: ${track.kind} - ${track.label} - enabled: ${track.enabled}`); + }); + resolve(capturedStream); + } else { + const error = chrome.runtime.lastError; + console.error(`[TAB_CAPTURE] ❌ Failed to capture tab`); + console.error(`[TAB_CAPTURE] chrome.runtime.lastError:`, error); + reject(new Error(`Failed to capture the tab: ${error ? error.message : 'Unknown error'}`)); + } + } + ); + }); + + // Create PeerJS peer with our assigned ID + console.log(`[PEERJS] Creating peer with ID: "${srcPeerId}"`); + console.log(`[PEERJS] Peer constructor available:`, typeof Peer !== 'undefined'); + + const peer = new Peer(srcPeerId); + console.log(`[PEERJS] Peer object created:`, peer); + + // Wait for peer to connect to PeerJS signaling server + console.log(`[PEERJS] Waiting for peer to connect to signaling server...`); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + console.error(`[PEERJS] ❌ Timeout waiting for peer to open`); + reject(new Error('Timeout waiting for peer to connect')); + }, 30000); // 30 second timeout + + peer.once('open', (id) => { + clearTimeout(timeout); + console.log(`[PEERJS] ✅ Connected to signaling server with ID: "${id}"`); + console.log(`[PEERJS] Peer ID matches expected: ${id === srcPeerId}`); + resolve(); + }); + + peer.on('error', (error) => { + clearTimeout(timeout); + console.error(`[PEERJS] ❌ Peer error during connection:`, error); + console.error(`[PEERJS] Error type: ${error.type}, message: ${error.message}`); + reject(new Error(`Peer error: ${error.message}`)); + }); + }); + + // Make the call to the destination peer and start connection tracking + console.log(`[CALL] Starting call to destination peer: "${destPeerId}"`); + console.log(`[CALL] Stream object for call:`, stream); + console.log(`[CALL] Stream tracks for call:`, stream.getTracks().map(t => `${t.kind}:${t.label}`)); + + const call = peer.call(destPeerId, stream); + + if (!call) { + console.error(`[CALL] ❌ Failed to create call to ${destPeerId} - call object is null/undefined`); + window.streamingDebug.lastError = 'Failed to create call object'; + return; + } + + console.log(`[CALL] ✅ Call object created successfully:`, call); + console.log(`[CALL] Call peer ID: "${call.peer}"`); + console.log(`[CALL] Call type: "${call.type}"`); + + // Track the connection immediately since we're initiating the call + console.log(`[CALL] Adding connection to tracking before call events...`); + console.log(`[CALL] About to call addConnection with destPeerId: "${destPeerId}"`); + console.log(`[CALL] activeConnections before addConnection:`, Array.from(window.activeConnections)); + + try { + addConnection(destPeerId); + console.log(`[CALL] ✅ addConnection completed successfully`); + console.log(`[CALL] activeConnections after addConnection:`, Array.from(window.activeConnections)); + console.log(`[CALL] activeConnections size after addConnection:`, window.activeConnections.size); + } catch (error) { + console.error(`[CALL] ❌ addConnection failed:`, error); + window.streamingDebug.lastError = `addConnection failed: ${error.message}`; + } + + // Handle call lifecycle events + call.on('stream', (remoteStream) => { + console.log(`[CALL_EVENT] 'stream' event - Received remote stream from ${destPeerId}`); + console.log(`[CALL_EVENT] Remote stream tracks:`, remoteStream.getTracks().map(t => `${t.kind}:${t.label}`)); + // Connection already tracked above, just log that it's bidirectional + }); + + // The 'open' event fires when the call is answered + call.on('open', () => { + console.log(`[CALL_EVENT] 'open' event - Call to ${destPeerId} opened successfully`); + console.log(`[CALL_EVENT] Call is now active and streaming`); + // Connection already tracked above, this is just confirmation + }); + + call.on('close', () => { + console.log(`[CALL_EVENT] 'close' event - Call to ${destPeerId} ended`); + removeConnection(destPeerId); + }); + + call.on('error', (error) => { + console.error(`[CALL_EVENT] 'error' event - Call error with ${destPeerId}:`, error); + console.error(`[CALL_EVENT] Error type: ${error.type}, message: ${error.message}`); + removeConnection(destPeerId); + window.streamingDebug.lastError = `Call error: ${error.message}`; + }); + + // Handle incoming calls (though this extension typically just calls out) + console.log(`[PEER_EVENTS] Setting up peer event handlers...`); + peer.on('call', (incomingCall) => { + console.log(`[PEER_EVENT] 'call' event - Received incoming call from ${incomingCall.peer}`); + addConnection(incomingCall.peer); + + // Auto-answer incoming calls with the same captured stream + console.log(`[PEER_EVENT] Auto-answering incoming call with captured stream`); + incomingCall.answer(stream); + + incomingCall.on('stream', (remoteStream) => { + console.log(`[PEER_EVENT] Incoming call 'stream' event - received remote stream from ${incomingCall.peer}`); + }); + + incomingCall.on('close', () => { + console.log(`[PEER_EVENT] Incoming call 'close' event - call from ${incomingCall.peer} ended`); + removeConnection(incomingCall.peer); + }); + + incomingCall.on('error', (error) => { + console.error(`[PEER_EVENT] Incoming call 'error' event - error from ${incomingCall.peer}:`, error); + removeConnection(incomingCall.peer); + }); + }); + + // Handle peer-level events + peer.on('disconnected', () => { + console.log('[PEER_EVENT] Peer disconnected from signaling server'); + console.log('[PEER_EVENT] Note: existing calls may still be active'); + }); + + peer.on('close', () => { + console.log('[PEER_EVENT] Peer connection closed completely'); + console.log('[PEER_EVENT] Clearing all tracked connections'); + console.log('[PEER_EVENT] activeConnections before clear:', Array.from(window.activeConnections)); + window.activeConnections.clear(); + console.log('[PEER_EVENT] activeConnections after clear:', Array.from(window.activeConnections)); + }); + + console.log(`[INITIALIZE] ✅ Initialization complete. Ready for streaming.`); + console.log(`[INITIALIZE] Final activeConnections:`, Array.from(window.activeConnections)); + console.log(`[INITIALIZE] Final activeConnections size:`, window.activeConnections.size); + console.log(`[INITIALIZE] ========== INITIALIZATION COMPLETE ==========`); + + } catch (error) { + console.error(`[INITIALIZE] ❌ INITIALIZATION FAILED:`, error); + console.error(`[INITIALIZE] Error stack:`, error.stack); + window.streamingDebug.lastError = `Initialization failed: ${error.message}`; + throw error; // Re-throw so container server knows it failed + } +} + +// Log when the script finishes loading +console.log('[INIT] streaming.js script execution complete'); +console.log('[INIT] INITIALIZE function available:', typeof INITIALIZE === 'function'); +console.log('[INIT] Global objects available:', { + Peer: typeof Peer !== 'undefined', + chrome: typeof chrome !== 'undefined', + 'chrome.tabCapture': typeof chrome !== 'undefined' && typeof chrome.tabCapture !== 'undefined' +}); diff --git a/examples/bun-stream-server/container/package.json b/examples/bun-stream-server/container/package.json index 070f1c3..bbe46c9 100644 --- a/examples/bun-stream-server/container/package.json +++ b/examples/bun-stream-server/container/package.json @@ -8,10 +8,10 @@ "dev": "tsx watch src/server.ts" }, "dependencies": { - "puppeteer": "^21.0.0" + "puppeteer-core": "^24.9.0" }, "devDependencies": { "tsx": "^4.7.1", "typescript": "^5.0.0" } -} \ No newline at end of file +} \ No newline at end of file diff --git a/examples/bun-stream-server/container/src/server.ts b/examples/bun-stream-server/container/src/server.ts index 7ce8da6..6a51657 100644 --- a/examples/bun-stream-server/container/src/server.ts +++ b/examples/bun-stream-server/container/src/server.ts @@ -1,24 +1,75 @@ -import puppeteer from 'puppeteer'; -import type { PuppeteerLaunchOptions, Browser, Target } from 'puppeteer'; +/** + * Stream Container Server + * + * This server manages a single browser instance with Puppeteer and monitors + * active WebRTC connections to automatically shut down when no longer needed. + * + * Connection Monitoring Strategy: + * - Chrome extension maintains window.activeConnections Set with peer IDs + * - Container server polls this state every 15 seconds via page.evaluate() + * - When connections drop to 0, starts 60-second grace period + * - If no connections return within grace period, shuts down browser + * - If new connections appear during grace period, cancels shutdown + * + * Browser Lifecycle: + * - Browser launches on first /start-stream request + * - Stays alive as long as connections are active + * - Automatically shuts down after grace period with no connections + * - Can be manually restarted with new /start-stream requests + */ + +import puppeteer from 'puppeteer-core'; +import type { Browser, Page } from 'puppeteer-core'; import crypto from 'crypto'; -const EXTENSION_PATH = '/app/extension'; +// TypeScript declaration for browser window extensions +declare global { + interface Window { + activeConnections?: Set; + streamingDebug?: { + initializeCalled: boolean; + addConnectionCalled: boolean; + lastPeerId: string | null; + lastError: string | null; + callCount: number; + }; + INITIALIZE?: (params: { srcPeerId: string; destPeerId: string }) => Promise; + } +} + +const EXTENSION_PATH = './extension'; const EXTENSION_ID = 'jjndjgheafjngoipoacpjgeicjeomjli'; -const EXTENSION_URL_PREFIX = `chrome-extension://${EXTENSION_ID}/`; + +// Connection monitoring configuration +const GRACE_PERIOD_MS = 60000; // 60 seconds +const POLL_INTERVAL_MS = 15000; // 15 seconds + +// Module-level state for persistent browser instance +let browser: Browser | undefined; +let activePage: Page | undefined; +let streamingPage: Page | undefined; +let connectionCheckInterval: Timer | null = null; +let shutdownTimer: Timer | null = null; /** Utility: Create JSON response */ function jsonResponse(data: unknown, init: ResponseInit = {}) { return new Response(JSON.stringify(data), { - headers: { 'Content-Type': 'application/json', ...(init.headers || {}) }, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + ...(init.headers || {}) + }, ...init, }); } /** Build Puppeteer launch options */ -function buildLaunchOptions(): PuppeteerLaunchOptions { +function buildLaunchOptions() { return { - headless: 'new', - executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, + headless: true, // Use headless mode + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', args: [ '--no-sandbox', '--disable-setuid-sandbox', @@ -27,7 +78,13 @@ function buildLaunchOptions(): PuppeteerLaunchOptions { `--load-extension=${EXTENSION_PATH}`, `--allowlisted-extension-id=${EXTENSION_ID}`, '--autoplay-policy=no-user-gesture-required', + '--window-size=1920,1080', // Set specific window size + '--window-position=0,0', // Position at top-left + '--disable-web-security', // Allow cross-origin requests for streaming + '--disable-features=VizDisplayCompositor', // Better for screen capture + '--headless=new', // Use new headless mode via command line arg ], + defaultViewport: null, // Use full available viewport }; } @@ -35,24 +92,184 @@ function buildLaunchOptions(): PuppeteerLaunchOptions { async function launchBrowserWithExtension(): Promise { const options = buildLaunchOptions(); console.log('Launching browser with options:', options); - const browser = await puppeteer.launch(options); + const browserInstance = await puppeteer.launch(options); console.log('Browser launched.'); - return browser; + return browserInstance; +} + +/** Wait for the extension streaming page (not background script) */ +async function findStreamingPage(browser: Browser, timeout = 15000): Promise { + console.log(`Looking for streaming page: chrome-extension://${EXTENSION_ID}/streaming.html`); + + // First, let's see what targets are available + const initialTargets = browser.targets().map(t => ({ + type: t.type(), + url: t.url() + })); + console.log('Available targets before waiting:', initialTargets); + + try { + const target = await browser.waitForTarget( + t => { + const isMatch = t.type() === 'page' && + t.url() === `chrome-extension://${EXTENSION_ID}/streaming.html`; + if (t.url().includes('chrome-extension')) { + console.log(`Checking target: ${t.type()} - ${t.url()} - Match: ${isMatch}`); + } + return isMatch; + }, + { timeout } + ); + + if (!target) { + const debugTargets = browser.targets().map(t => ({ + type: t.type(), + url: t.url() + })); + console.error('Streaming page not found. Existing targets:', debugTargets); + throw new Error(`Streaming page chrome-extension://${EXTENSION_ID}/streaming.html not found within timeout.`); + } + + const page = await target.page(); + if (!page) { + throw new Error('Failed to get page from streaming target'); + } + + console.log('Found streaming page successfully'); + return page; + } catch (error) { + // If timeout, let's see what targets we have at the end + const finalTargets = browser.targets().map(t => ({ + type: t.type(), + url: t.url() + })); + console.error('Failed to find streaming page. Final targets:', finalTargets); + throw error; + } +} + +/** Check if INITIALIZE function exists in the page */ +async function assertExtensionLoaded(page: Page, maxRetries = 3) { + const wait = (ms: number) => new Promise(res => setTimeout(res, ms)); + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const hasInitialize = await page.evaluate(() => typeof (globalThis as any).INITIALIZE === 'function'); + if (hasInitialize) { + console.log('INITIALIZE function found in extension page'); + return; + } + } catch (error) { + console.log(`Attempt ${attempt + 1}: INITIALIZE not ready yet`); + } + await wait(Math.pow(100, attempt)); // 100ms, 1s, 10s + } + throw new Error('Could not find INITIALIZE function in the browser context after retries'); +} + +/** Start monitoring active connections via Puppeteer polling */ +async function startConnectionMonitoring() { + if (!streamingPage) { + console.warn('Cannot start connection monitoring: no active streaming page'); + return; + } + + console.log('Starting connection monitoring...'); + + connectionCheckInterval = setInterval(async () => { + try { + if (!streamingPage) { + console.log('Streaming page no longer available, stopping monitoring'); + stopConnectionMonitoring(); + return; + } + + const activeCount = await streamingPage.evaluate(() => { + return window.activeConnections ? window.activeConnections.size : 0; + }); + + // Enhanced debugging - show what's actually in the set + const activeConnectionsDebug = await streamingPage.evaluate(() => { + if (!window.activeConnections) return { size: 0, connections: [] }; + return { + size: window.activeConnections.size, + connections: Array.from(window.activeConnections) + }; + }); + + // Check streaming debug info + const streamingDebug = await streamingPage.evaluate(() => { + return window.streamingDebug || { debug: 'not available' }; + }); + + // Get more detailed extension state + const extensionState = await streamingPage.evaluate(() => { + return { + hasStreamingDebug: typeof window.streamingDebug !== 'undefined', + hasActiveConnections: typeof window.activeConnections !== 'undefined', + hasInitializeFunction: typeof window.INITIALIZE === 'function', + windowKeys: Object.keys(window).filter(key => key.includes('streaming') || key.includes('active') || key.includes('INITIALIZE')), + location: window.location.href, + userAgent: navigator.userAgent, + timestamp: new Date().toISOString() + }; + }); + + console.log(`Active connections: ${activeCount}`); + console.log(`Connection details:`, activeConnectionsDebug); + console.log(`Streaming debug:`, streamingDebug); + console.log(`Extension state:`, extensionState); + + if (activeCount === 0 && !shutdownTimer) { + console.log(`No active connections, starting ${GRACE_PERIOD_MS}ms shutdown timer...`); + shutdownTimer = setTimeout(() => { + console.log('Grace period expired, shutting down browser...'); + shutdownBrowser(); + }, GRACE_PERIOD_MS); + } else if (activeCount > 0 && shutdownTimer) { + console.log('Active connections detected, cancelling shutdown timer'); + clearTimeout(shutdownTimer); + shutdownTimer = null; + } + } catch (error) { + console.error('Failed to check connection status:', error); + // Continue monitoring even if one check fails + } + }, POLL_INTERVAL_MS); +} + +/** Stop connection monitoring */ +function stopConnectionMonitoring() { + if (connectionCheckInterval) { + clearInterval(connectionCheckInterval); + connectionCheckInterval = null; + console.log('Connection monitoring stopped'); + } + + if (shutdownTimer) { + clearTimeout(shutdownTimer); + shutdownTimer = null; + console.log('Shutdown timer cancelled'); + } } -/** Wait for the extension target (background/service worker) */ -async function findExtensionTarget(browser: Browser, timeout = 15000): Promise { - const target = await browser.waitForTarget( - t => (t.type() === 'background_page' || t.type() === 'service_worker') && - t.url().startsWith(EXTENSION_URL_PREFIX), - { timeout } - ); - if (!target) { - const debugTargets = browser.targets().map(t => ({ type: t.type(), url: t.url() })); - console.error('Extension target not found. Existing targets:', debugTargets); - throw new Error(`Extension with ID ${EXTENSION_ID} not found within timeout.`); +/** Gracefully shutdown the browser and clean up resources */ +async function shutdownBrowser() { + console.log('Shutting down browser...'); + + stopConnectionMonitoring(); + + if (browser) { + try { + await browser.close(); + console.log('Browser closed successfully'); + } catch (error) { + console.error('Error closing browser:', error); + } + browser = undefined; + activePage = undefined; + streamingPage = undefined; } - return target; } /* ---------- Route Handlers ---------- */ @@ -63,16 +280,26 @@ async function handleHealth(): Promise { location: process.env.CLOUDFLARE_LOCATION || 'local', region: process.env.CLOUDFLARE_REGION || 'dev', expectedExtensionId: EXTENSION_ID, + browserActive: !!browser, + monitoringActive: !!connectionCheckInterval, + }); +} + +async function handlePing(): Promise { + return jsonResponse({ + status: 'pong', + timestamp: Date.now(), + browserActive: !!browser, }); } async function handleTest(): Promise { - let browser: Browser | undefined; + let testBrowser: Browser | undefined; try { - browser = await launchBrowserWithExtension(); - const version = await browser.version(); - await findExtensionTarget(browser); - await browser.close(); + testBrowser = await launchBrowserWithExtension(); + const version = await testBrowser.version(); + streamingPage = await findStreamingPage(testBrowser); + await testBrowser.close(); return jsonResponse({ status: 'success', browserVersion: version, @@ -81,43 +308,144 @@ async function handleTest(): Promise { }); } catch (err: any) { console.error('handleTest error:', err); - if (browser) await browser.close(); + if (testBrowser) await testBrowser.close(); return jsonResponse({ status: 'error', message: err.message }, { status: 500 }); } } async function handleStartStream(req: Request): Promise { - let browser: Browser | undefined; try { const { url: targetUrl, peerId: destPeerId } = await req.json(); - if (!targetUrl || !destPeerId) return jsonResponse({ error: 'Missing targetUrl or peerId' }, { status: 400 }); + if (!targetUrl || !destPeerId) { + return jsonResponse({ error: 'Missing targetUrl or peerId' }, { status: 400 }); + } + + // Use existing browser or launch new one + if (!browser) { + console.log('No existing browser, launching new instance...'); + browser = await launchBrowserWithExtension(); + } else { + console.log('Using existing browser instance'); + } - browser = await launchBrowserWithExtension(); + // Create new page for this stream const page = await browser.newPage(); + activePage = page; // Set as active page for monitoring + + // Navigate to target URL await page.goto(targetUrl); + + // Set page to full screen + await page.setViewport({ width: 1920, height: 1080 }); // Set a large viewport - const extTarget = await findExtensionTarget(browser); - let context: any; - context = extTarget.type() === 'service_worker' ? await extTarget.worker() : await extTarget.page(); - if (!context) throw new Error('Failed to obtain extension execution context'); + // Get extension streaming page and initialize streaming + streamingPage = await findStreamingPage(browser); + + // Set up console log monitoring for the extension page + streamingPage.on('console', (msg) => { + const type = msg.type(); + const text = msg.text(); + console.log(`[EXTENSION-${type.toUpperCase()}] ${text}`); + }); + + streamingPage.on('pageerror', (error) => { + console.error('[EXTENSION-ERROR] Page error:', error.message); + }); + + // Test if we can execute code in the extension context + console.log('🧪 Testing extension context execution...'); + try { + const testResult = await streamingPage.evaluate(() => { + console.log('[TEST] This is a test log from extension context'); + return { + location: window.location.href, + hasWindow: typeof window !== 'undefined', + hasPeer: typeof (globalThis as any).Peer !== 'undefined', + hasChrome: typeof (globalThis as any).chrome !== 'undefined', + hasTabCapture: typeof (globalThis as any).chrome !== 'undefined' && typeof (globalThis as any).chrome.tabCapture !== 'undefined', + windowKeys: Object.keys(window).filter(key => + key.includes('streaming') || + key.includes('active') || + key.includes('INITIALIZE') || + key.includes('Peer') + ) + }; + }); + console.log('🧪 Extension context test result:', testResult); + } catch (error) { + console.error('🧪 Extension context test failed:', error); + } + + // Ensure INITIALIZE function is loaded + await assertExtensionLoaded(streamingPage); + + // Force a simple log to test console monitoring + console.log('🔍 Forcing a test log in extension...'); + await streamingPage.evaluate(() => { + console.log('[FORCED-TEST] This should appear in container logs if console monitoring works'); + console.error('[FORCED-ERROR] This is a test error'); + console.warn('[FORCED-WARN] This is a test warning'); + }); + + console.log('🔍 Test logs sent, checking if they appeared above...'); const srcPeerId = crypto.randomUUID(); const peers = { srcPeerId, destPeerId }; - await Promise.race([ - context.evaluate(async (p: any) => { - // @ts-ignore - if (typeof INITIALIZE !== 'function') throw new Error('INITIALIZE not found'); - // @ts-ignore - await INITIALIZE(p); - }, peers), - new Promise((_, reject) => setTimeout(() => reject(new Error('INITIALIZE timeout')), 30000)), - ]); - - return jsonResponse({ status: 'success', srcPeerId, browserWSEndpoint: browser.wsEndpoint() }); + // Initialize streaming in extension page + console.log('🚀 About to call INITIALIZE function with params:', peers); + console.log('🚀 Extension page URL:', streamingPage.url()); + + try { + await Promise.race([ + streamingPage.evaluate(async (p: any) => { + console.log('[PUPPETEER] INITIALIZE call starting with params:', p); + // @ts-ignore + const result = await INITIALIZE(p); + console.log('[PUPPETEER] INITIALIZE call completed, result:', result); + return result; + }, peers), + new Promise((_, reject) => setTimeout(() => reject(new Error('INITIALIZE timeout')), 30000)), + ]); + + console.log('✅ INITIALIZE function completed successfully'); + + // Check the state immediately after INITIALIZE + const postInitState = await streamingPage.evaluate(() => { + return { + activeConnections: window.activeConnections ? Array.from(window.activeConnections) : null, + activeConnectionsSize: window.activeConnections ? window.activeConnections.size : 0, + streamingDebug: window.streamingDebug || null, + hasInitialize: typeof window.INITIALIZE === 'function' + }; + }); + + console.log('📊 Post-INITIALIZE state:', postInitState); + + } catch (error) { + console.error('❌ INITIALIZE function failed:', error); + console.error('❌ Error details:', { + name: (error as Error).name, + message: (error as Error).message, + stack: (error as Error).stack + }); + throw error; // Re-throw to propagate the error + } + + // Start connection monitoring if not already running + if (!connectionCheckInterval) { + startConnectionMonitoring(); + } + + return jsonResponse({ + status: 'success', + srcPeerId, + browserWSEndpoint: browser.wsEndpoint(), + monitoringActive: !!connectionCheckInterval + }); } catch (err: any) { console.error('handleStartStream error:', err); - if (browser) await browser.close(); + // Don't close browser on error - let monitoring handle lifecycle return jsonResponse({ status: 'error', message: err.message }, { status: 500 }); } } @@ -130,7 +458,20 @@ const server = Bun.serve({ const { pathname } = url; try { + // Handle CORS preflight requests + if (req.method === 'OPTIONS') { + return new Response(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); + } + if (pathname === '/health') return await handleHealth(); + if (pathname === '/ping') return await handlePing(); if (pathname === '/test-puppeteer') return await handleTest(); if (pathname === '/start-stream' && req.method === 'POST') return await handleStartStream(req); return new Response('Not Found', { status: 404 }); @@ -141,4 +482,17 @@ const server = Bun.serve({ }, }); +// Graceful shutdown on process termination +process.on('SIGTERM', async () => { + console.log('Received SIGTERM, shutting down gracefully...'); + await shutdownBrowser(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('Received SIGINT, shutting down gracefully...'); + await shutdownBrowser(); + process.exit(0); +}); + console.log(`Container server running at http://localhost:${server.port}`); \ No newline at end of file diff --git a/examples/bun-stream-server/receiver.html b/examples/bun-stream-server/receiver.html new file mode 100644 index 0000000..9f6e17c --- /dev/null +++ b/examples/bun-stream-server/receiver.html @@ -0,0 +1,627 @@ + + + + + + Stream Kit - Video Receiver + + + +
+

🎥 Stream Kit Video Receiver

+

Connect to a Puppeteer-launched stream to receive video

+ +
+

How to use:

+
    +
  1. Start the container server: cd container && bun run src/server.ts
  2. +
  3. Enter a URL below that you want to stream
  4. +
  5. Click Start Stream & Listen to begin!
  6. +
  7. The page will automatically start the stream and connect you
  8. +
  9. You'll see the live content from the Puppeteer browser!
  10. +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + + +
+ Ready - Enter a URL above and click "🚀 Start Stream & Listen" to begin +
+ + +
+ + + + + + + \ No newline at end of file diff --git a/examples/bun-stream-server/src/index.ts b/examples/bun-stream-server/src/index.ts index 96dcb56..4b69ede 100644 --- a/examples/bun-stream-server/src/index.ts +++ b/examples/bun-stream-server/src/index.ts @@ -36,8 +36,23 @@ async function startAndWaitForPort( ); } +async function waitForLocalhost(port: number, maxTries = 10) { + for (let i = 0; i < maxTries; i++) { + try { + await fetch(`http://localhost:${port}/ping`); + return; + } catch (err: any) { + console.error("Error connecting to localhost on", i, "try", err); + await new Promise((res) => setTimeout(res, 300)); + } + } + throw new Error( + `could not connect to localhost:${port} after ${maxTries} tries` + ); +} + async function proxyFetch( - container: any, + container: Container, request: Request, portNumber: number ): Promise { @@ -52,9 +67,7 @@ async function proxyFetch( } function createJsonResponse(data: unknown, init?: ResponseInit): Response { - // Convert data to string first const jsonString = JSON.stringify(data); - // Create response with explicit headers return new Response(jsonString, { ...init, headers: { @@ -71,27 +84,40 @@ export class MyContainer implements DurableObject { private readonly env: Env ) { ctx.blockConcurrencyWhile(async () => { - const container = ctx.container; - if (!container) { - throw new Error("Container is not available"); + if (!ctx.container) { + // No container available, wait for localhost to be available + console.log("No container binding found, using localhost:8080"); + await waitForLocalhost(OPEN_CONTAINER_PORT); + } else { + // Container available, start and wait for it + console.log("Container binding found, starting container"); + await startAndWaitForPort(ctx.container, OPEN_CONTAINER_PORT); } - await startAndWaitForPort(container, OPEN_CONTAINER_PORT); }); } async fetch(request: Request): Promise { try { if (!this.ctx.container) { - throw new Error("Container is not available"); + // No container, proxy to localhost:8080 + const localUrl = request.url.replace(new URL(request.url).origin, 'http://localhost:8080'); + const response = await fetch(localUrl, { + method: request.method, + headers: request.headers, + body: request.body, + }); + return response; + } else { + // Use the container + return await proxyFetch( + this.ctx.container, + request, + OPEN_CONTAINER_PORT + ); } - return await proxyFetch( - this.ctx.container, - request, - OPEN_CONTAINER_PORT - ); } catch (error) { return createJsonResponse( - { error: "Container error: " + (error as Error).message }, + { error: (!this.ctx.container ? "Local server" : "Container") + " error: " + (error as Error).message }, { status: 500 } ); } @@ -104,25 +130,9 @@ export default { const url = new URL(request.url); const pathname = url.pathname; - if ( - pathname.startsWith("/stream") || - pathname === "/health" || - pathname === "/test-puppeteer" - ) { - const id = env.MY_CONTAINER.idFromName(pathname); - const stub = env.MY_CONTAINER.get(id); - return await stub.fetch(request); - } - - return createJsonResponse({ - message: "Stream Server Worker", - endpoints: [ - "GET /health", - "POST /stream", - "GET /stream/:id", - "DELETE /stream/:id", - "GET /test-puppeteer", - ], - }); + // Always route through Durable Objects + const id = env.MY_CONTAINER.idFromName(pathname); + const stub = env.MY_CONTAINER.get(id); + return await stub.fetch(request); }, }; diff --git a/examples/bun-stream-server/test-end-to-end.js b/examples/bun-stream-server/test-end-to-end.js new file mode 100644 index 0000000..b6045eb --- /dev/null +++ b/examples/bun-stream-server/test-end-to-end.js @@ -0,0 +1,79 @@ +/** + * End-to-End Stream Testing Script + * + * This script helps coordinate testing between the container server + * and the receiver page by providing the receiver ID. + */ + +const crypto = require('crypto'); + +async function testEndToEnd() { + console.log('🧪 Stream Kit End-to-End Test\n'); + + // Generate a consistent receiver ID for this test + const receiverId = 'test-receiver-' + Date.now(); + console.log('📱 Generated Receiver ID:', receiverId); + + // Test 1: Check if container server is running + console.log('\n1️⃣ Checking Container Server...'); + try { + const healthResponse = await fetch('http://localhost:8080/health'); + const health = await healthResponse.json(); + console.log('✅ Container Server:', health.status); + + if (!health.browserActive) { + console.log('⚠️ Browser not active - will be started with stream request'); + } + } catch (error) { + console.log('❌ Container Server not running:', error.message); + console.log(' Run: cd container && bun run src/server.ts'); + return; + } + + // Test 2: Start streaming with our receiver ID + console.log('\n2️⃣ Starting Stream...'); + try { + const streamResponse = await fetch('http://localhost:8080/start-stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: 'https://www.nytimes.com', // More interesting than example.com + peerId: receiverId + }) + }); + + const result = await streamResponse.json(); + + if (result.status === 'success') { + console.log('✅ Stream started successfully!'); + console.log(' 📡 Source Peer ID:', result.srcPeerId); + console.log(' 🎯 Target Peer ID:', receiverId); + console.log(' 📺 Streaming URL: https://www.nytimes.com'); + + console.log('\n🎬 Ready for Testing!'); + console.log('====================='); + console.log('1. Open receiver.html in Chrome'); + console.log('2. The receiver ID should auto-fill as:', receiverId); + console.log('3. Copy this Source Peer ID:', result.srcPeerId); + console.log('4. Paste it in the receiver page and click Connect'); + console.log('5. You should see the NY Times page streaming!'); + + console.log('\n📊 Connection Monitoring:'); + console.log('• Container polls connections every 15 seconds'); + console.log('• When you connect, activeConnections will show: 1'); + console.log('• When you disconnect, browser shuts down after 60s grace period'); + + console.log('\n🔧 Quick Commands:'); + console.log('• Open receiver: open receiver.html'); + console.log('• Check connections: curl http://localhost:8080/health'); + + } else { + console.log('❌ Stream failed:', result.message); + } + } catch (error) { + console.log('❌ Stream request failed:', error.message); + } +} + +// Run the test +testEndToEnd().catch(console.error); \ No newline at end of file diff --git a/examples/bun-stream-server/test-streaming.js b/examples/bun-stream-server/test-streaming.js new file mode 100644 index 0000000..091dc37 --- /dev/null +++ b/examples/bun-stream-server/test-streaming.js @@ -0,0 +1,85 @@ +/** + * Stream Kit Testing Script + * + * Tests the complete streaming pipeline: + * 1. Container server browser lifecycle + * 2. Stream server routing (dev mode uses localhost:8080) + * 3. Connection tracking and automatic shutdown + */ + +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +async function test() { + console.log('🧪 Testing Stream Kit Components...\n'); + + // Test 1: Container Server Health + console.log('1️⃣ Testing Container Server Health...'); + try { + const response = await fetch('http://localhost:8080/health'); + const health = await response.json(); + console.log('✅ Container Server:', health.status); + console.log(` Browser Active: ${health.browserActive || false}`); + console.log(` Monitoring Active: ${health.monitoringActive || false}\n`); + } catch (error) { + console.log('❌ Container Server not running:', error.message); + console.log(' Run: cd container && bun run src/server.ts\n'); + return; + } + + // Test 2: Stream Server Health + console.log('2️⃣ Testing Stream Server Health...'); + try { + const response = await fetch('http://localhost:8787/health'); + const health = await response.json(); + console.log('✅ Stream Server:', health.status); + console.log(' Mode: Development (routing to localhost:8080)\n'); + } catch (error) { + console.log('❌ Stream Server not running:', error.message); + console.log(' Run: wrangler dev --env dev\n'); + return; + } + + // Test 3: Browser Lifecycle Test + console.log('3️⃣ Testing Browser Lifecycle Management...'); + console.log(' Starting streaming request...'); + + try { + const streamResponse = await fetch('http://localhost:8080/start-stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: 'https://www.example.com', + peerId: 'test-receiver-' + Date.now() + }) + }); + + const result = await streamResponse.json(); + + if (result.status === 'success') { + console.log('✅ Streaming started successfully'); + console.log(` Source Peer ID: ${result.srcPeerId}`); + console.log(` Monitoring Active: ${result.monitoringActive}`); + console.log('\n🔍 Connection monitoring will now check every 15 seconds...'); + console.log(' Since there\'s no real receiver, connections will be 0'); + console.log(' Browser should shut down in ~60 seconds of grace period'); + } else { + console.log('❌ Streaming failed:', result.error || result.message); + } + } catch (error) { + console.log('❌ Streaming request failed:', error.message); + } + + console.log('\n📋 Manual Testing Options:'); + console.log(' • Check logs in both terminal windows'); + console.log(' • Browser should start, navigate to example.com'); + console.log(' • After ~75 seconds, browser should auto-shutdown'); + console.log(' • Container server polls window.activeConnections every 15s'); + + console.log('\n🔧 Advanced Testing:'); + console.log(' • Create a real PeerJS receiver to test actual streaming'); + console.log(' • Monitor connection count: window.activeConnections.size'); + console.log(' • Test multiple concurrent streams'); +} + +// Run the tests +test().catch(console.error); \ No newline at end of file diff --git a/examples/bun-stream-server/worker-configuration.d.ts b/examples/bun-stream-server/worker-configuration.d.ts index d181a4c..59b9119 100644 --- a/examples/bun-stream-server/worker-configuration.d.ts +++ b/examples/bun-stream-server/worker-configuration.d.ts @@ -1024,7 +1024,7 @@ declare class DigestStream extends WritableStream get bytesWritten(): number | bigint; } /** - * A decoder for a specific method, that is a specific character encoding, like utf-8, iso-8859-2, koi8, cp1261, gbk, etc. A decoder takes a stream of bytes as input and emits a stream of code points. For a more scalable, non-native library, see StringView – a C-like representation of strings based on typed arrays. + * A decoder for a specific method, that is a specific character encoding, like utf-8, iso-8859-2, koi8, cp1261, gbk, etc. A decoder takes a stream of bytes as input and emits a stream of code points. For a more scalable, non-native library, see StringView – a C-like representation of strings based on typed arrays. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) */ @@ -1211,7 +1211,7 @@ interface DocumentEnd { append(content: string, options?: ContentOptions): DocumentEnd; } /** - * This is the event type for fetch events dispatched on the service worker global scope. It contains information about the fetch, including the request and how the receiver will treat the response. It provides the event.respondWith() method, which allows us to provide a response to this fetch. + * This is the event type for fetch events dispatched on the service worker global scope. It contains information about the fetch, including the request and how the receiver will treat the response. It provides the event.respondWith() method, which allows us to provide a response to this fetch. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent) */ @@ -1224,7 +1224,7 @@ declare abstract class FetchEvent extends ExtendableEvent { } type HeadersInit = Headers | Iterable> | Record; /** - * This Fetch API interface allows you to perform various actions on HTTP request and response headers. These actions include retrieving, setting, adding to, and removing. A Headers object has an associated header list, which is initially empty and consists of zero or more name and value pairs.  You can add to this using methods like append() (see Examples.) In all methods of this interface, header names are matched by case-insensitive byte sequence. + * This Fetch API interface allows you to perform various actions on HTTP request and response headers. These actions include retrieving, setting, adding to, and removing. A Headers object has an associated header list, which is initially empty and consists of zero or more name and value pairs. You can add to this using methods like append() (see Examples.) In all methods of this interface, header names are matched by case-insensitive byte sequence. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers) */ diff --git a/packages/stream-kit-react/README.md b/packages/stream-kit-react/README.md index 8b5268f..61132c0 100644 --- a/packages/stream-kit-react/README.md +++ b/packages/stream-kit-react/README.md @@ -40,6 +40,8 @@ function App() { ); } + +> **Note:** The StreamCanvas component uses PeerJS for WebRTC signaling, which connects to the default PeerJS server automatically. No additional configuration is needed for development and testing purposes. For production environments, you may want to set up your own PeerJS server. ``` ## Components diff --git a/packages/stream-kit-server/PLAN.md b/packages/stream-kit-server/PLAN.md new file mode 100644 index 0000000..d1cb850 --- /dev/null +++ b/packages/stream-kit-server/PLAN.md @@ -0,0 +1,205 @@ +# Plan: Refactor State Subscription to WebSockets for Cloudflare Workers + +**Goal:** Modify the `@open-game-system/stream-kit-server` package to use WebSockets for real-time state change notifications to clients, replacing the current Server-Sent Events (SSE) implementation. This implementation will be tailored for the Cloudflare Workers runtime, using KV for state persistence and Workers/Durable Objects for WebSocket management. + +**Key Decisions:** + +1. **WebSocket Only:** SSE will be completely removed. +2. **Endpoint:** WebSocket upgrades will be handled on `GET /stream/:streamId` requests containing an `Upgrade: websocket` header. +3. **No Generic Hooks Abstraction (Initially):** The `StreamKitHooks` abstraction will be removed. State persistence will directly use Cloudflare KV. WebSocket connection management will be handled within the Worker, with a recommendation for Durable Objects for scalability. +4. **Cloudflare-Specific Implementation:** The router will be designed to work with Cloudflare Worker environment bindings (`env` object typically containing KV namespaces, and enabling WebSocket upgrades). + +## Changes Required: + +1. **Update `src/types.ts` (or create new type definitions): + * Remove the `StreamKitHooks` interface. + * Define the structure for WebSocket messages if needed (e.g., `StateChange` for snapshots/patches, though sending full snapshots might be simpler initially). + * Define the expected structure of the environment object `TEnv` passed to the router, specifically that it should contain a KV namespace binding (e.g., `STATE_KV: KVNamespace`). + +2. **Refactor Router Logic (`src/router.ts`): + * **Remove SSE Logic:** All code related to `/sse` endpoints and `AsyncIterable` will be removed. + * **Cloudflare KV Integration:** + * Replace `hooks.loadStreamState` with `env.STATE_KV.get(streamId, "json")`. + * Replace `hooks.saveStreamState` with `env.STATE_KV.put(streamId, JSON.stringify(state))`. + * Replace `hooks.deleteStreamState` with `env.STATE_KV.delete(streamId)`. + * **WebSocket Connection Management (In-Worker for V1, DO recommended for V2):** + * An in-memory `Map>` (e.g., `streamSubscribers`) will be used to store active WebSocket connections per `streamId`. This is suitable for a single Worker instance or for routing to a Durable Object. + * **Upgrade Handling:** When a `GET /stream/:streamId` request with an `Upgrade: websocket` header is received: + 1. Use Cloudflare's `WebSocketPair`: `const { 0: client, 1: server } = new WebSocketPair();`. + 2. Call `server.accept()`. + 3. Send the current full state (snapshot from KV) to the `server` WebSocket. + 4. Store the `server` WebSocket in `streamSubscribers` for the given `streamId`. + 5. Handle `server.onmessage`, `server.onclose`, `server.onerror`. + 6. Return `new Response(null, { status: 101, webSocket: client });`. + * **Broadcasting State Changes:** + * After a successful `env.STATE_KV.put()` (from a `POST /stream/:streamId` request), iterate over `streamSubscribers.get(streamId)` and send the updated state (full snapshot) to each connected WebSocket. + +3. **Update Mocking and Tests (`src/mock-hooks.ts` will be removed, `src/router.test.ts` refactored): + * Remove `src/mock-hooks.ts`. + * `router.test.ts` will need to: + * Mock the Cloudflare KV namespace (`env.STATE_KV`). + * Mock the `WebSocketPair` and WebSocket server/client behavior for testing connection upgrades and message exchanges. Vitest or Jest provide ways to mock global objects or classes. + * Verify direct KV interactions. + * Test WebSocket upgrade, initial snapshot sending, and broadcast on state change. + +4. **API Documentation (`README.md`): + * Reflect the removal of SSE and the hook-based architecture. + * Specify that the server is designed for Cloudflare Workers. + * Detail the WebSocket endpoint (`GET /stream/:streamId` with `Upgrade` header). + * Document the direct dependency on a KV namespace in the Worker environment. + * Recommend Durable Objects for scalable WebSocket management. + +## Detailed Implementation Steps & Code Examples (Illustrative for Cloudflare Workers): + +### Step 1: Modify `src/types.ts` + +```typescript +// src/types.ts + +// Example: Define the type for messages sent over WebSocket +export interface WebSocketMessage { + type: 'snapshot'; // Initially, only send full snapshots + data: any; // The stream state +} + +// Define the expected Cloudflare environment structure for the router +export interface StreamKitEnv { + STATE_KV: KVNamespace; // KV namespace for storing stream states + // Potentially a Durable Object binding for WebSocket handling in a scalable setup + // STREAM_WEBSOCKET_DO?: DurableObjectNamespace; +} +``` + +### Step 2: Refactor `src/router.ts` + +```typescript +// src/router.ts +import type { StreamKitEnv, WebSocketMessage } from "./types"; + +// This Map is instance-local. For multi-instance, use Durable Objects. +const streamSubscribers = new Map>(); + +export function createStreamKitRouter() { + return async (request: Request, env: TEnv): Promise => { + const url = new URL(request.url); + const pathSegments = url.pathname.split("/").filter(Boolean); + + if (pathSegments.length < 2 || pathSegments[0] !== "stream") { + return new Response("Not Found", { status: 404 }); + } + const streamId = pathSegments[1]; + + // Handle WebSocket Upgrade for GET /stream/:streamId + if (request.method === "GET" && request.headers.get("Upgrade")?.toLowerCase() === "websocket") { + if (pathSegments.length !== 2) { // Ensure it's exactly /stream/:streamId + return new Response("Invalid WebSocket path", { status: 400 }); + } + + const pair = new WebSocketPair(); + const clientWs = pair[0]; + const serverWs = pair[1]; + + serverWs.accept(); + + const subscribers = streamSubscribers.get(streamId) || new Set(); + subscribers.add(serverWs); + streamSubscribers.set(streamId, subscribers); + + // Send initial state snapshot + try { + const initialState = await env.STATE_KV.get(streamId, "json"); + if (initialState) { + const message: WebSocketMessage = { type: 'snapshot', data: initialState }; + serverWs.send(JSON.stringify(message)); + } else { + // Optionally send an error or close if state must exist + // serverWs.send(JSON.stringify({ type: 'error', data: 'Stream state not found' })); + } + } catch (e) { + console.error(`Failed to send initial state for ${streamId}:`, e); + // serverWs.close(1011, "Error fetching initial state"); // Optional: close with error + } + + serverWs.addEventListener("message", event => { + // Handle incoming messages from client (e.g., keep-alive, client-side commands) + // console.log(`Received message from ${streamId}:`, event.data); + }); + + serverWs.addEventListener("close", () => { + subscribers.delete(serverWs); + if (subscribers.size === 0) { + streamSubscribers.delete(streamId); + } + console.log(`WebSocket closed for stream ${streamId}`); + }); + + serverWs.addEventListener("error", err => { + subscribers.delete(serverWs); + if (subscribers.size === 0) { + streamSubscribers.delete(streamId); + } + console.error(`WebSocket error for stream ${streamId}:`, err); + }); + + return new Response(null, { status: 101, webSocket: clientWs }); + } + + // RESTful API for state management + try { + switch (request.method) { + case "GET": // Get current state (non-WebSocket) + const state = await env.STATE_KV.get(streamId, "json"); + if (state === null) return new Response("Stream Not Found", { status: 404 }); + return new Response(JSON.stringify(state), { headers: { "Content-Type": "application/json" } }); + + case "POST": + const body = await request.json(); + await env.STATE_KV.put(streamId, JSON.stringify(body)); + + // Broadcast the new state to WebSocket subscribers + const subscribers = streamSubscribers.get(streamId); + if (subscribers) { + const message: WebSocketMessage = { type: 'snapshot', data: body }; + const serializedMessage = JSON.stringify(message); + subscribers.forEach(ws => { + if (ws.readyState === WebSocket.OPEN) { // Check if WebSocket is open + ws.send(serializedMessage); + } + }); + } + return new Response("Stream state saved", { status: 200 }); + + case "DELETE": + await env.STATE_KV.delete(streamId); + // Optionally notify subscribers of deletion, or just close connections + const activeSubscribers = streamSubscribers.get(streamId); + if (activeSubscribers) { + activeSubscribers.forEach(ws => ws.close(1000, "Stream deleted")); + streamSubscribers.delete(streamId); + } + return new Response("Stream deleted", { status: 200 }); + + default: + return new Response("Method Not Allowed", { status: 405 }); + } + } catch (error) { + console.error(`API Error for stream ${streamId}:`, error); + return new Response("Internal Server Error", { status: 500 }); + } + }; +} +``` + +## Considerations for Scalability (Durable Objects): + +While the above in-Worker `streamSubscribers` Map works for a single instance, it does not scale across multiple Cloudflare Worker instances. For a production system, each `streamId` should ideally be managed by a **Durable Object (DO)**. + +* **DO Responsibilities:** + * Store the state for its `streamId` (internally, could still use KV or its own storage API). + * Manage all WebSocket connections for its `streamId`. + * Handle broadcasts to its connected WebSockets when its state changes. +* **Main Worker Router:** + * HTTP GET/POST/DELETE requests for `/stream/:streamId` would be routed to the `fetch` handler of the corresponding DO instance (e.g., `env.STREAM_WEBSOCKET_DO.get(DO_ID).fetch(request)`). + * WebSocket upgrade requests for `/stream/:streamId` would also be forwarded to the DO, which would then accept and manage the WebSocket session. + +This DO-based architecture is the standard way to handle stateful WebSockets at scale on Cloudflare Workers. The initial implementation can start with the in-Worker map for simplicity, with a clear path to refactor towards Durable Objects for production readiness. \ No newline at end of file diff --git a/packages/stream-kit-server/src/client.ts b/packages/stream-kit-server/src/client.ts new file mode 100644 index 0000000..ccbaa93 --- /dev/null +++ b/packages/stream-kit-server/src/client.ts @@ -0,0 +1,117 @@ +import { RenderStreamConfig } from './types'; + +export interface StreamClientConfig { + host: string; + port?: number; +} + +export interface RenderStream { + id: string; + status: 'starting' | 'running' | 'stopping' | 'stopped' | 'error'; + connect(peerId: string): Promise; + disconnect(): Promise; + getStatus(): Promise<{ status: string; peerId?: string }>; +} + +export class StreamClient { + private baseUrl: string; + + constructor(private config: StreamClientConfig) { + const port = config.port ? `:${config.port}` : ''; + this.baseUrl = `https://${config.host}${port}`; + } + + async createRenderStream(config: RenderStreamConfig): Promise { + const response = await fetch(`${this.baseUrl}/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(config), + }); + + if (!response.ok) { + throw new Error(`Failed to create stream: ${response.statusText}`); + } + + const { sessionId } = await response.json(); + return new RenderStreamImpl(this.baseUrl, sessionId); + } + + async getStream(sessionId: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/stream/${sessionId}`); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error(`Failed to get stream: ${response.statusText}`); + } + + const session = await response.json(); + return new RenderStreamImpl(this.baseUrl, sessionId, session.status); + } catch (error) { + console.error('Failed to get stream:', error); + return null; + } + } +} + +class RenderStreamImpl implements RenderStream { + constructor( + private baseUrl: string, + public id: string, + public status: RenderStream['status'] = 'starting' + ) {} + + async connect(peerId: string): Promise { + const response = await fetch(`${this.baseUrl}/stream/${this.id}/connect`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ peerId }), + }); + + if (!response.ok) { + throw new Error(`Failed to connect to stream: ${response.statusText}`); + } + + const result = await response.json(); + this.status = result.status; + } + + async disconnect(): Promise { + const response = await fetch(`${this.baseUrl}/stream/${this.id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`Failed to disconnect from stream: ${response.statusText}`); + } + + this.status = 'stopped'; + } + + async getStatus(): Promise<{ status: string; peerId?: string }> { + const response = await fetch(`${this.baseUrl}/stream/${this.id}`); + + if (!response.ok) { + throw new Error(`Failed to get stream status: ${response.statusText}`); + } + + const session = await response.json(); + this.status = session.status; + + return { + status: session.status, + peerId: session.peerId, + }; + } +} + +export function createStreamClient(config: StreamClientConfig): StreamClient { + return new StreamClient(config); +} \ No newline at end of file diff --git a/packages/stream-kit-server/src/container-manager.ts b/packages/stream-kit-server/src/container-manager.ts new file mode 100644 index 0000000..ea8883a --- /dev/null +++ b/packages/stream-kit-server/src/container-manager.ts @@ -0,0 +1,139 @@ +import { ContainerManager, RenderStreamConfig } from './types'; +import { spawn } from 'child_process'; +import { promisify } from 'util'; + +export interface DockerContainerConfig { + image: string; + port: number; + extensionPath?: string; +} + +export class DockerContainerManager implements ContainerManager { + private containers = new Map(); + private nextPort = 8080; + + constructor(private config: DockerContainerConfig) {} + + async start(sessionId: string, renderConfig: RenderStreamConfig): Promise<{ containerId: string; port: number }> { + const port = this.nextPort++; + const containerId = `stream-kit-${sessionId}`; + + // Build docker run command + const dockerArgs = [ + 'run', + '-d', // detached + '--name', containerId, + '-p', `${port}:${this.config.port}`, + '--rm', // auto-remove when stopped + ]; + + // Mount extension if provided + if (this.config.extensionPath) { + dockerArgs.push('-v', `${this.config.extensionPath}:/app/extension:ro`); + } + + // Add environment variables + dockerArgs.push( + '-e', `RENDER_URL=${renderConfig.url}`, + '-e', `RENDER_WIDTH=${renderConfig.width || 1920}`, + '-e', `RENDER_HEIGHT=${renderConfig.height || 1080}`, + '-e', `DEVICE_SCALE_FACTOR=${renderConfig.deviceScaleFactor || 1}`, + ); + + dockerArgs.push(this.config.image); + + try { + // Execute docker run + await this.execCommand('docker', dockerArgs); + this.containers.set(containerId, { containerId, port, sessionId }); + return { containerId, port }; + } catch (error) { + throw new Error(`Failed to start container: ${error}`); + } + } + + async stop(containerId: string): Promise { + try { + await this.execCommand('docker', ['stop', containerId]); + this.containers.delete(containerId); + } catch (error) { + console.error(`Failed to stop container ${containerId}:`, error); + // Try force removal + try { + await this.execCommand('docker', ['rm', '-f', containerId]); + this.containers.delete(containerId); + } catch (forceError) { + console.error(`Failed to force remove container ${containerId}:`, forceError); + } + } + } + + async getStatus(containerId: string): Promise<'starting' | 'running' | 'stopping' | 'stopped' | 'error'> { + try { + const stdout = await this.execCommand('docker', ['inspect', '--format', '{{.State.Status}}', containerId]); + const status = stdout.trim(); + + switch (status) { + case 'created': + case 'restarting': + return 'starting'; + case 'running': + return 'running'; + case 'paused': + return 'stopping'; + case 'exited': + case 'dead': + return 'stopped'; + default: + return 'error'; + } + } catch (error) { + return 'error'; + } + } + + async cleanup(): Promise { + const containerIds = Array.from(this.containers.keys()); + + await Promise.all( + containerIds.map(async (containerId) => { + try { + await this.stop(containerId); + } catch (error) { + console.error(`Failed to cleanup container ${containerId}:`, error); + } + }) + ); + + this.containers.clear(); + } + + private async execCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(`Command failed with exit code ${code}: ${stderr}`)); + } + }); + }); + } +} \ No newline at end of file diff --git a/packages/stream-kit-server/src/index.ts b/packages/stream-kit-server/src/index.ts index 2631123..bc06ed0 100644 --- a/packages/stream-kit-server/src/index.ts +++ b/packages/stream-kit-server/src/index.ts @@ -1,3 +1,12 @@ -// Re-export the hook-based router components +// Export the new client-based API +export { StreamKitServer } from "./server"; +export { createStreamClient } from "./client"; +export type { + StreamKitServerConfig, + StreamSession, + RenderStreamConfig +} from "./types"; + +// Legacy exports (deprecated) export { createStreamKitRouter } from "./router"; export type { StreamKitHooks, StateChange } from "./types"; \ No newline at end of file diff --git a/packages/stream-kit-server/src/server.ts b/packages/stream-kit-server/src/server.ts new file mode 100644 index 0000000..e920989 --- /dev/null +++ b/packages/stream-kit-server/src/server.ts @@ -0,0 +1,123 @@ +import { StreamKitServerConfig, StreamSession, RenderStreamConfig, ContainerManager } from './types'; +import { DockerContainerManager } from './container-manager'; + +export class StreamKitServer { + private sessions = new Map(); + private containerManager: ContainerManager; + + constructor(private config: StreamKitServerConfig) { + this.containerManager = new DockerContainerManager({ + image: config.containerImage || 'stream-kit-container', + port: config.containerPort || 8080, + extensionPath: config.extensionPath, + }); + } + + async createStream(config: RenderStreamConfig): Promise<{ sessionId: string }> { + const sessionId = generateSessionId(); + + const session: StreamSession = { + id: sessionId, + url: config.url, + status: 'starting', + createdAt: new Date(), + lastActiveAt: new Date(), + config, + }; + + this.sessions.set(sessionId, session); + + // Start container in background + this.startContainer(sessionId).catch((error) => { + console.error(`Failed to start container for session ${sessionId}:`, error); + this.updateSessionStatus(sessionId, 'error'); + }); + + return { sessionId }; + } + + async connectToStream(sessionId: string, peerId: string): Promise<{ status: string; peerId?: string }> { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session ${sessionId} not found`); + } + + session.peerId = peerId; + session.lastActiveAt = new Date(); + this.sessions.set(sessionId, session); + + // TODO: Notify container about peer connection + + return { + status: session.status, + peerId: session.peerId, + }; + } + + async getStream(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (session) { + session.lastActiveAt = new Date(); + this.sessions.set(sessionId, session); + } + return session || null; + } + + async deleteStream(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + return; + } + + this.updateSessionStatus(sessionId, 'stopping'); + + if (session.containerId) { + try { + await this.containerManager.stop(session.containerId); + } catch (error) { + console.error(`Failed to stop container ${session.containerId}:`, error); + } + } + + this.sessions.delete(sessionId); + } + + async getActiveSessions(): Promise { + return Array.from(this.sessions.values()); + } + + async cleanup(): Promise { + await this.containerManager.cleanup(); + } + + private async startContainer(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session ${sessionId} not found`); + } + + try { + const { containerId } = await this.containerManager.start(sessionId, session.config); + + session.containerId = containerId; + session.status = 'running'; + this.sessions.set(sessionId, session); + } catch (error) { + this.updateSessionStatus(sessionId, 'error'); + throw error; + } + } + + private updateSessionStatus(sessionId: string, status: StreamSession['status']): void { + const session = this.sessions.get(sessionId); + if (session) { + session.status = status; + session.lastActiveAt = new Date(); + this.sessions.set(sessionId, session); + } + } +} + +function generateSessionId(): string { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); +} \ No newline at end of file diff --git a/packages/stream-kit-server/src/types.ts b/packages/stream-kit-server/src/types.ts index d03937d..ba55f6a 100644 --- a/packages/stream-kit-server/src/types.ts +++ b/packages/stream-kit-server/src/types.ts @@ -61,6 +61,43 @@ export interface StreamKitHooks { }) => AsyncIterable; } +// New client-based API types +export interface StreamKitServerConfig { + host: string; + port?: number; + containerImage?: string; + containerPort?: number; + extensionPath?: string; + maxStreams?: number; + streamTimeout?: number; +} + +export interface RenderStreamConfig { + url: string; + width?: number; + height?: number; + deviceScaleFactor?: number; + timeout?: number; +} + +export interface StreamSession { + id: string; + url: string; + status: 'starting' | 'running' | 'stopping' | 'stopped' | 'error'; + createdAt: Date; + lastActiveAt: Date; + config: RenderStreamConfig; + containerId?: string; + peerId?: string; +} + +export interface ContainerManager { + start(sessionId: string, config: RenderStreamConfig): Promise<{ containerId: string; port: number }>; + stop(containerId: string): Promise; + getStatus(containerId: string): Promise<'starting' | 'running' | 'stopping' | 'stopped' | 'error'>; + cleanup(): Promise; +} + // TODO: Import or define StreamState from @open-game-system/stream-kit-types // export type StreamState = { /* ... structure of your stream state ... */ }; // export type StreamMetadata = { /* ... structure of your stream metadata ... */ }; \ No newline at end of file diff --git a/packages/stream-kit-web/README.md b/packages/stream-kit-web/README.md index b838e58..e0ed9c5 100644 --- a/packages/stream-kit-web/README.md +++ b/packages/stream-kit-web/README.md @@ -69,6 +69,8 @@ stream.send({ stream.destroy(); ``` +> **Note:** Stream Kit uses PeerJS for WebRTC signaling, which automatically connects to the default PeerJS server. For development and testing, you don't need to configure a custom PeerJS server. For production environments, you may want to set up your own PeerJS server for better reliability and control. + ### Advanced Usage #### Custom WebRTC Configuration @@ -98,7 +100,6 @@ await stream.update({ } }); ``` - #### Error Handling ```typescript @@ -156,4 +157,4 @@ interface RenderStream { ## License -MIT License \ No newline at end of file +MIT License From a450426e2f22114ff9a72532b2e87dfa62aced8a Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Sat, 13 Sep 2025 14:11:06 -0700 Subject: [PATCH 04/13] working not in docker container --- examples/basic-react-demo/package.json | 32 +- examples/bun-stream-server/README.md | 146 +- .../bun-stream-server/container/Dockerfile | 76 +- .../bun-stream-server/container/bun.lockb | Bin 42426 -> 42098 bytes .../container/extension/background.js | 34 +- .../container/extension/content.js | 12 + .../container/extension/manifest.json | 24 +- .../container/extension/streaming.html | 22 + .../container/extension/streaming.js | 349 +- .../container/package-lock.json | 1167 ++++++ .../bun-stream-server/container/package.json | 8 +- .../bun-stream-server/container/src/server.ts | 665 +++- examples/bun-stream-server/package.json | 4 +- examples/bun-stream-server/receiver.html | 8 +- package.json | 8 +- packages/stream-kit-react/package.json | 22 +- packages/stream-kit-server/package.json | 18 +- packages/stream-kit-testing/package.json | 12 +- packages/stream-kit-types/package.json | 10 +- packages/stream-kit-web/package.json | 66 +- pnpm-lock.yaml | 3432 ++++++++++------- 21 files changed, 4325 insertions(+), 1790 deletions(-) create mode 100644 examples/bun-stream-server/container/extension/content.js create mode 100644 examples/bun-stream-server/container/package-lock.json diff --git a/examples/basic-react-demo/package.json b/examples/basic-react-demo/package.json index d3603d9..7bf4440 100644 --- a/examples/basic-react-demo/package.json +++ b/examples/basic-react-demo/package.json @@ -16,26 +16,26 @@ "@open-game-system/stream-kit-react": "workspace:*", "@open-game-system/stream-kit-types": "workspace:*", "@open-game-system/stream-kit-web": "workspace:*", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^18.3.1", + "react-dom": "^18.3.1" }, "devDependencies": { - "@testing-library/jest-dom": "^6.4.2", - "@testing-library/react": "^14.2.1", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^14.3.1", "@testing-library/user-event": "^14.6.1", - "@types/react": "^18.2.37", - "@types/react-dom": "^18.2.15", - "@types/testing-library__jest-dom": "^5.14.5", - "@typescript-eslint/eslint-plugin": "^8.30.1", - "@typescript-eslint/parser": "^8.30.1", - "@vitejs/plugin-react": "^4.2.0", + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7", + "@types/testing-library__jest-dom": "^5.14.9", + "@typescript-eslint/eslint-plugin": "^8.43.0", + "@typescript-eslint/parser": "^8.43.0", + "@vitejs/plugin-react": "^4.7.0", "eslint": "^8.57.1", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.4", - "jsdom": "^23.0.1", - "typescript": "^5.2.2", - "vite": "^5.0.0", - "vitest": "^1.6.0" + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.20", + "jsdom": "^23.2.0", + "typescript": "^5.9.2", + "vite": "^5.4.20", + "vitest": "^1.6.1" } } \ No newline at end of file diff --git a/examples/bun-stream-server/README.md b/examples/bun-stream-server/README.md index 3489805..1c37035 100644 --- a/examples/bun-stream-server/README.md +++ b/examples/bun-stream-server/README.md @@ -1,109 +1,99 @@ -# Stream Kit Server Example (Cloudflare Containers) +# Stream Kit Bun Server Example -This example demonstrates how to run a WebRTC streaming server using Cloudflare Containers, Bun, and Puppeteer. The server can capture browser content and stream it to clients using WebRTC. +This example demonstrates using Stream Kit with a Bun server that can stream web pages using Puppeteer and Chrome extensions. -## Architecture - -- **Container**: Runs a Bun server with Puppeteer for browser automation and WebRTC streaming -- **Worker**: Manages container lifecycle and routes requests -- **Durable Object**: Maintains container state and handles container-specific operations - -## Project Structure +## Quick Start +### Local Development (without Docker) +```bash +bun install +bun run src/index.ts ``` -. -├── wrangler.toml # Cloudflare Workers/Containers config -├── package.json # Worker dependencies -├── src/ # Worker & Durable Object code -│ ├── index.ts # Worker entry point -│ └── container.ts # Durable Object implementation -├── container/ # Container application code -│ ├── Dockerfile # Container configuration -│ ├── package.json # Container-specific dependencies -│ ├── src/ -│ │ └── server.ts # Bun server with Puppeteer/WebRTC -│ └── tsconfig.json # TypeScript config for container -└── README.md +### Docker Container (with tab capture & WebRTC fixes) +```bash +cd container +docker build -t stream-container . +# Map both HTTP port and UDP port range for WebRTC +docker run -p 8080:8080 -p 40000-40100:40000-40100/udp stream-container ``` -## Code Organization +## Architecture -The project is split into two main parts: +The system consists of: -1. **Worker & Durable Object** (`/src`): - - Handles routing and container lifecycle - - Implements the external API endpoints - - Manages container state via Durable Objects +1. **Container Server** (`container/src/server.ts`) - Manages Puppeteer browser instances with Chrome extension +2. **Chrome Extension** (`container/extension/`) - Handles tab capture and WebRTC streaming +3. **Receiver HTML** (`receiver.html`) - Web interface for receiving and displaying streams -2. **Container Application** (`/container`): - - Runs inside Cloudflare Container - - Implements the actual streaming functionality - - Uses Bun + Puppeteer for browser automation +## Docker Fixes -## API Routes +### Tab Capture Fix -### API Endpoints +**Problem**: Chrome headless mode in Docker containers cannot properly capture tab content, resulting in empty video streams. -- `GET /health` - Health check endpoint -- `POST /stream` - Create a new stream session -- `GET /stream/:id` - Get stream info and status -- `DELETE /stream/:id` - Stop and cleanup stream +**Solution**: +- Use virtual display (Xvfb) instead of headless mode +- Enable proper graphics acceleration flags +- Add window manager (fluxbox) for proper rendering context -## Development +### WebRTC Networking Fix -1. Install dependencies for both the Worker and Container: -```bash -# Install Worker dependencies -npm install +**Problem**: WebRTC peer-to-peer connections fail in Docker containers due to network isolation. Video streams connect but never receive data (readyState stays 0). -# Install Container dependencies -cd container && npm install -``` +**Solution**: +- Configure TURN servers in PeerJS for both sender and receiver +- Expose UDP port range (40000-40100) for WebRTC media traffic +- Use proper Chrome flags for media capture in containerized environment -2. Test the container locally: -```bash -# Build the container (from the container directory) -cd container -docker build -t stream-server-test . +### What was changed: -# Run it locally -docker run -p 8080:8080 --rm stream-server-test +1. **Dockerfile**: Added Xvfb, X11VNC, and fluxbox packages +2. **Startup script**: Initializes virtual display before starting Chrome +3. **Chrome flags**: Use `headless: false` with virtual display, enable GPU acceleration +4. **Environment**: Set `DISPLAY=:0` for virtual X11 session -# Test with curl -curl http://localhost:8080/health -``` +### Testing the fix: -3. Deploy to Cloudflare: +1. Build and run the Docker container: ```bash -# From the root directory -wrangler deploy +cd container +docker build -t stream-container . +# Include UDP port mapping for WebRTC media connections +docker run -p 8080:8080 -p 40000-40100:40000-40100/udp stream-container ``` -## Container Details +2. Open `receiver.html` in your browser + +3. Enter a URL (e.g., `https://www.nytimes.com`) and click "Start Stream & Listen" -The container runs: -- Bun for the server runtime -- Puppeteer for browser automation -- Chrome/Chromium for page rendering -- WebRTC for streaming with PeerJS +4. You should now see actual video content instead of a spinning/waiting state -The included browser extension uses the default PeerJS server for WebRTC signaling, so no additional configuration is needed to establish peer connections. This is suitable for development and testing environments. For production deployments, you may want to configure a custom PeerJS server for better reliability and control. +### Debugging: -## Environment Variables +If you still have issues, check the container logs for: +- `[TAB_CAPTURE]` messages showing stream details +- Video track settings (width, height, frameRate) +- Chrome launch success with virtual display -The container automatically receives these Cloudflare-provided variables: -- `CLOUDFLARE_COUNTRY_A2` - Two-letter country code -- `CLOUDFLARE_LOCATION` - Location name -- `CLOUDFLARE_REGION` - Region name +Expected log output: +``` +🔍 Starting basic browser launch mode: virtual display +✅ Test 1 PASSED: Basic browser launch works +[TAB_CAPTURE] Video track details: {width: 1920, height: 1080, frameRate: 30} +``` -## Notes +## Usage -- Initial container provisioning takes a few minutes -- Each container instance can handle one streaming session -- The Worker will automatically route requests to the appropriate container instance -- Container instances are recycled after periods of inactivity +1. Start the container server +2. Open `receiver.html` in a web browser +3. Enter the URL you want to stream +4. Click "Start Stream & Listen" +5. The receiver will automatically connect and display the live stream -## License +## Files -MIT License +- `src/index.ts` - Main Bun server +- `container/` - Docker container setup +- `receiver.html` - Stream receiver interface +- `test-*.js` - Test scripts for development diff --git a/examples/bun-stream-server/container/Dockerfile b/examples/bun-stream-server/container/Dockerfile index 19b99d8..7b64a6c 100644 --- a/examples/bun-stream-server/container/Dockerfile +++ b/examples/bun-stream-server/container/Dockerfile @@ -1,34 +1,62 @@ -# Use Bun as base image -FROM oven/bun:1 as base - -# We don't need the standalone Chromium -ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true - -# Install Chromium browser (arm64-compatible) and dependencies -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - chromium \ - fonts-liberation \ - libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 \ - libcups2 libdbus-1-3 libdrm2 libgbm1 libgtk-3-0 \ - libnspr4 libnss3 libxcomposite1 libxdamage1 libxfixes3 \ - libxkbcommon0 libxrandr2 xdg-utils \ +# Use Debian ARM64 which has the actual Chromium binary (not a snap wrapper) +FROM --platform=linux/arm64 debian:bullseye-slim + +# Prevent interactive prompts during package installation +ENV DEBIAN_FRONTEND=noninteractive + +# Install Node.js and necessary dependencies +RUN apt-get update && apt-get install -y \ + curl \ + gnupg \ + wget \ + unzip \ + ca-certificates \ + && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ + && apt-get install -y nodejs + +# Install Chromium and dependencies for ARM64 +RUN apt-get update && apt-get install -y \ + chromium \ + chromium-driver \ + fonts-liberation \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libatspi2.0-0 \ + libdrm2 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + xdg-utils \ && rm -rf /var/lib/apt/lists/* +# Set Chromium path for Puppeteer +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + +# Mark that we're running in Docker for Chrome configuration +ENV DOCKER_ENVIRONMENT=true + +# Install tsx for TypeScript execution +RUN npm install -g tsx + +# Set working directory WORKDIR /app # Copy package files -COPY package.json ./ +COPY package*.json ./ -# Install dependencies (puppeteer-core doesn't download Chromium) -RUN bun install +# Install dependencies +RUN npm ci -# Copy source +# Copy source and extension COPY . . -# Set environment variable to use system Chrome -ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium - -# Run the app +# Run the app using tsx to handle TypeScript EXPOSE 8080 -CMD ["bun", "run", "src/server.ts"] \ No newline at end of file +# Expose UDP port range for WebRTC media connections +EXPOSE 40000-40100/udp +CMD ["tsx", "src/server.ts"] \ No newline at end of file diff --git a/examples/bun-stream-server/container/bun.lockb b/examples/bun-stream-server/container/bun.lockb index bb73c296f32164deaea9a928ae0dc4be948c9fc8..d115353bea106b8d59ecd6d6afcb2111820f3689 100755 GIT binary patch delta 15380 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 a2U1^c1xpTH?4;uyM?=j8h_qNX|33ikr|>xd literal 42426 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 NI0WA@{LlOQzW{h9#b5vc diff --git a/examples/bun-stream-server/container/extension/background.js b/examples/bun-stream-server/container/extension/background.js index ae6709e..e3f16cf 100644 --- a/examples/bun-stream-server/container/extension/background.js +++ b/examples/bun-stream-server/container/extension/background.js @@ -13,12 +13,30 @@ * 5. Screen capture and streaming begins */ -chrome.tabs.create( - { - active: false, // Hidden tab - user doesn't need to see it - url: `chrome-extension://${chrome.runtime.id}/streaming.html`, - }, - (tab) => { - console.log('[Background] Created streaming tab:', tab.id); +chrome.runtime.onInstalled.addListener(() => { + chrome.tabs.create({ url: chrome.runtime.getURL('streaming.html'), active: false }); +}); + +chrome.runtime.onStartup.addListener(() => { + chrome.tabs.create({ url: chrome.runtime.getURL('streaming.html'), active: false }); +}); + +chrome.commands.onCommand.addListener(async (command) => { + console.log('[BACKGROUND] Command received:', command); + if (command === 'start-capture') { + // Focus the target tab (current active in current window) + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + console.log('[BACKGROUND] Active tab for capture:', tab && { id: tab.id, url: tab.url }); + if (tab && tab.id) { + // Send a message to the streaming page to start capture immediately + // Broadcast to all extension contexts; streaming.html listens on runtime.onMessage + chrome.runtime.sendMessage({ type: 'START_CAPTURE', targetTabId: tab.id }); + console.log('[BACKGROUND] START_CAPTURE runtime message sent'); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[BACKGROUND] start-capture error:', e && e.message); + } } -); +}); diff --git a/examples/bun-stream-server/container/extension/content.js b/examples/bun-stream-server/container/extension/content.js new file mode 100644 index 0000000..ce14220 --- /dev/null +++ b/examples/bun-stream-server/container/extension/content.js @@ -0,0 +1,12 @@ +(() => { + try { + // Lightweight marker to indicate the content script ran + window.__videoCaptureExtensionActive = true; + // Optionally, expose a no-op to help debugging + // eslint-disable-next-line no-console + console.log('[CONTENT] Video Capture extension content script injected'); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[CONTENT] Injection error:', e && e.message); + } +})(); \ No newline at end of file diff --git a/examples/bun-stream-server/container/extension/manifest.json b/examples/bun-stream-server/container/extension/manifest.json index 906f2c3..24bddb4 100644 --- a/examples/bun-stream-server/container/extension/manifest.json +++ b/examples/bun-stream-server/container/extension/manifest.json @@ -14,11 +14,31 @@ "permissions": [ "tabs", "tabCapture", - "storage", "activeTab", + "storage", "scripting", "sockets.udp", "sockets.tcp" ], - "host_permissions": ["*://*/*", "", "https://*/*", "http://*/*"] + "host_permissions": [ + "*://*/*", + "", + "https://*/*", + "http://*/*" + ], + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_start" + } + ], + "commands": { + "start-capture": { + "suggested_key": { + "default": "Alt+Shift+S" + }, + "description": "Start tab capture" + } + } } diff --git a/examples/bun-stream-server/container/extension/streaming.html b/examples/bun-stream-server/container/extension/streaming.html index c732c25..e4f4724 100644 --- a/examples/bun-stream-server/container/extension/streaming.html +++ b/examples/bun-stream-server/container/extension/streaming.html @@ -5,6 +5,28 @@ onerror="console.error('[EXTENSION] Failed to load peer.js')" > + + + - \ No newline at end of file + diff --git a/examples/bun-stream-server/src/index.ts b/examples/bun-stream-server/src/index.ts index 4b69ede..a0bf5e2 100644 --- a/examples/bun-stream-server/src/index.ts +++ b/examples/bun-stream-server/src/index.ts @@ -1,10 +1,91 @@ +import crypto from "crypto"; + const OPEN_CONTAINER_PORT = 8080; +const STREAM_INSTANCE_NAME = "default-singleton-debug-v3"; +const TURN_TTL_SECONDS = 3600; + +type IceServer = { + urls: string[] | string; + username?: string; + credential?: string; +}; + +function logTrace(traceId: string, event: string, details?: Record) { + if (details) { + console.log(`[trace:${traceId}] ${event}`, details); + return; + } + console.log(`[trace:${traceId}] ${event}`); +} + +function withTraceHeader(response: Response, traceId: string): Response { + const cloned = new Response(response.body, response); + cloned.headers.set("x-stream-trace-id", traceId); + return cloned; +} + +function normalizeIceServers(iceServers: IceServer[]): IceServer[] { + return iceServers.map((server) => { + const urls = Array.isArray(server.urls) ? server.urls : [server.urls]; + const filteredUrls = urls.filter((url) => !url.includes(":53")); + return { + ...server, + urls: filteredUrls, + }; + }).filter((server) => { + const urls = Array.isArray(server.urls) ? server.urls : [server.urls]; + return urls.length > 0; + }); +} + +async function generateTurnIceServers(env: Env, traceId: string): Promise { + const apiToken = env.CLOUDFLARE_TURN_API_TOKEN; + const turnKeyId = env.CLOUDFLARE_TURN_KEY_ID; + + if (!apiToken || !turnKeyId) { + throw new Error("TURN credentials are not configured in Worker secrets"); + } + + logTrace(traceId, "turn_credentials_request_start", { ttlSeconds: TURN_TTL_SECONDS }); + const response = await fetch( + `https://rtc.live.cloudflare.com/v1/turn/keys/${turnKeyId}/credentials/generate-ice-servers`, + { + method: "POST", + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ttl: TURN_TTL_SECONDS }), + } + ); + + const bodyText = await response.text(); + if (!response.ok) { + logTrace(traceId, "turn_credentials_request_failed", { + status: response.status, + body: bodyText, + }); + throw new Error(`TURN credentials request failed: ${response.status}`); + } + + const parsed = JSON.parse(bodyText) as { iceServers?: IceServer[] }; + if (!parsed.iceServers || !Array.isArray(parsed.iceServers)) { + throw new Error("TURN credentials response did not include iceServers"); + } + + const iceServers = normalizeIceServers(parsed.iceServers); + logTrace(traceId, "turn_credentials_request_complete", { + serverCount: iceServers.length, + }); + return iceServers; +} // Helper functions async function startAndWaitForPort( container: Container, portToAwait: number, - maxTries = 10 + traceId: string, + maxTries = 120 ) { const port = container.getTcpPort(portToAwait); let monitor; @@ -12,20 +93,40 @@ async function startAndWaitForPort( for (let i = 0; i < maxTries; i++) { try { if (!container.running) { - container.start(); + // @ts-ignore - enableInternet is required for Puppeteer to reach external websites + container.start({ enableInternet: true }); monitor = container.monitor(); + logTrace(traceId, "container_start_requested", { try: i + 1, port: portToAwait }); } - await (await port.fetch("http://ping")).text(); + + const res = await port.fetch("http://localhost:8080/ping", { + // @ts-ignore + signal: AbortSignal.timeout(1500), + headers: { + "x-stream-trace-id": traceId, + }, + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`fetch failed: ${res.status} ${text}`); + } + logTrace(traceId, "container_ready", { try: i + 1, port: portToAwait }); return; } catch (err: any) { - console.error("Error connecting to the container on", i, "try", err); + logTrace(traceId, "container_wait_retry", { + try: i + 1, + message: err.message, + name: err.name, + }); + const errMsg = (err.message || "").toLowerCase(); if ( - err.message.includes("listening") || - err.message.includes( - "there is no container instance that can be provided" - ) + errMsg.includes("listening") || + errMsg.includes("there is no container instance that can be provided") || + errMsg.includes("timeout") || + err.name === "TimeoutError" || + errMsg.includes("fetch failed") ) { - await new Promise((res) => setTimeout(res, 300)); + await new Promise((res) => setTimeout(res, 500)); continue; } throw err; @@ -54,13 +155,16 @@ async function waitForLocalhost(port: number, maxTries = 10) { async function proxyFetch( container: Container, request: Request, - portNumber: number + portNumber: number, + traceId: string ): Promise { + const headers = new Headers(request.headers); + headers.set("x-stream-trace-id", traceId); const response = await container .getTcpPort(portNumber) .fetch(request.url.replace("https://", "http://"), { method: request.method, - headers: request.headers, + headers, body: request.body, }); return response; @@ -78,47 +182,116 @@ function createJsonResponse(data: unknown, init?: ResponseInit): Response { } // Durable Object implementation -export class MyContainer implements DurableObject { +export class StreamContainer implements DurableObject { + private waitPromise: Promise | null = null; + constructor( private readonly ctx: DurableObjectState, private readonly env: Env - ) { - ctx.blockConcurrencyWhile(async () => { - if (!ctx.container) { - // No container available, wait for localhost to be available - console.log("No container binding found, using localhost:8080"); + ) {} + + private async ensureRunning(traceId: string) { + if (this.ctx.container && !this.ctx.container.running && this.waitPromise) { + logTrace(traceId, "do_container_stopped_resetting_wait_promise"); + this.waitPromise = null; + } + + if (this.waitPromise) return this.waitPromise; + + this.waitPromise = (async () => { + if (!this.ctx.container) { + logTrace(traceId, "do_localhost_mode"); await waitForLocalhost(OPEN_CONTAINER_PORT); } else { - // Container available, start and wait for it - console.log("Container binding found, starting container"); - await startAndWaitForPort(ctx.container, OPEN_CONTAINER_PORT); + logTrace(traceId, "do_container_mode"); + await startAndWaitForPort(this.ctx.container, OPEN_CONTAINER_PORT, traceId); } - }); + })(); + + return this.waitPromise; } async fetch(request: Request): Promise { + const url = new URL(request.url); + const traceId = request.headers.get("x-stream-trace-id") || crypto.randomUUID(); + const startedAt = Date.now(); + logTrace(traceId, "do_request_received", { + method: request.method, + path: url.pathname, + }); + try { + await this.ensureRunning(traceId); + if (!this.ctx.container) { // No container, proxy to localhost:8080 + logTrace(traceId, "do_proxy_local", { method: request.method, path: url.pathname }); const localUrl = request.url.replace(new URL(request.url).origin, 'http://localhost:8080'); const response = await fetch(localUrl, { method: request.method, - headers: request.headers, + headers: new Headers(request.headers), body: request.body, }); - return response; + const tracedResponse = withTraceHeader(response, traceId); + logTrace(traceId, "do_response_sent", { + status: tracedResponse.status, + durationMs: Date.now() - startedAt, + }); + return tracedResponse; } else { // Use the container - return await proxyFetch( + logTrace(traceId, "do_proxy_container", { method: request.method, path: url.pathname }); + const res = await proxyFetch( this.ctx.container, request, - OPEN_CONTAINER_PORT + OPEN_CONTAINER_PORT, + traceId ); + const tracedResponse = withTraceHeader(res, traceId); + logTrace(traceId, "do_response_sent", { + status: tracedResponse.status, + durationMs: Date.now() - startedAt, + }); + return tracedResponse; } } catch (error) { + if ( + this.ctx.container && + (error as Error).message.includes("container is not running") + ) { + logTrace(traceId, "do_retry_after_container_stopped"); + this.waitPromise = null; + await this.ensureRunning(traceId); + + const retriedResponse = await proxyFetch( + this.ctx.container, + request, + OPEN_CONTAINER_PORT, + traceId + ); + const tracedResponse = withTraceHeader(retriedResponse, traceId); + logTrace(traceId, "do_response_sent_after_retry", { + status: tracedResponse.status, + durationMs: Date.now() - startedAt, + }); + return tracedResponse; + } + + logTrace(traceId, "do_error", { + message: (error as Error).message, + durationMs: Date.now() - startedAt, + }); return createJsonResponse( - { error: (!this.ctx.container ? "Local server" : "Container") + " error: " + (error as Error).message }, - { status: 500 } + { + error: (!this.ctx.container ? "Local server" : "Container") + " error: " + (error as Error).message, + traceId, + }, + { + status: 500, + headers: { + "x-stream-trace-id": traceId, + }, + } ); } } @@ -127,12 +300,79 @@ export class MyContainer implements DurableObject { // Worker entry point export default { async fetch(request: Request, env: Env): Promise { + const traceId = request.headers.get("x-stream-trace-id") || crypto.randomUUID(); const url = new URL(request.url); - const pathname = url.pathname; + logTrace(traceId, "worker_request_received", { + method: request.method, + path: url.pathname, + }); + + if (url.pathname === "/ice-servers" && request.method === "GET") { + try { + const response = withTraceHeader( + createJsonResponse( + { + iceServers: await generateTurnIceServers(env, traceId), + traceId, + }, + { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, x-stream-trace-id", + }, + } + ), + traceId + ); + logTrace(traceId, "worker_response_sent", { status: response.status, path: url.pathname }); + return response; + } catch (error) { + const response = withTraceHeader( + createJsonResponse( + { error: (error as Error).message, traceId }, + { + status: 500, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, x-stream-trace-id", + }, + } + ), + traceId + ); + logTrace(traceId, "worker_response_sent", { status: response.status, path: url.pathname }); + return response; + } + } + + if (request.method === "OPTIONS") { + const response = withTraceHeader( + new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, x-stream-trace-id", + }, + }), + traceId + ); + logTrace(traceId, "worker_response_sent", { status: response.status, path: url.pathname }); + return response; + } + + // Always route through the same Durable Object to share the container instance + const id = env.STREAM_CONTAINER.idFromName(STREAM_INSTANCE_NAME); + const stub = env.STREAM_CONTAINER.get(id); + const forwardedRequest = new Request(request, { + headers: new Headers(request.headers), + }); + forwardedRequest.headers.set("x-stream-trace-id", traceId); - // Always route through Durable Objects - const id = env.MY_CONTAINER.idFromName(pathname); - const stub = env.MY_CONTAINER.get(id); - return await stub.fetch(request); + const response = withTraceHeader(await stub.fetch(forwardedRequest), traceId); + logTrace(traceId, "worker_response_sent", { status: response.status, path: url.pathname }); + return response; }, }; diff --git a/examples/bun-stream-server/worker-configuration.d.ts b/examples/bun-stream-server/worker-configuration.d.ts index 59b9119..6d2e5da 100644 --- a/examples/bun-stream-server/worker-configuration.d.ts +++ b/examples/bun-stream-server/worker-configuration.d.ts @@ -1,8 +1,10 @@ -// Generated by Wrangler by running `wrangler types` (hash: 95c09f256642a28e3040440af4a953e6) +// Generated by Wrangler by running `wrangler types` (hash: 4fcc58a36afdf2d2cf2f7a4e94908aa1) // Runtime types generated with workerd@1.20250319.0 2025-04-10 nodejs_compat,nodejs_compat_populate_process_env declare namespace Cloudflare { interface Env { - MY_CONTAINER: DurableObjectNamespace; + CLOUDFLARE_TURN_API_TOKEN: string; + CLOUDFLARE_TURN_KEY_ID: string; + STREAM_CONTAINER: DurableObjectNamespace; } } interface Env extends Cloudflare.Env {} diff --git a/examples/bun-stream-server/wrangler.jsonc b/examples/bun-stream-server/wrangler.jsonc index 413407e..809a3fc 100644 --- a/examples/bun-stream-server/wrangler.jsonc +++ b/examples/bun-stream-server/wrangler.jsonc @@ -14,14 +14,15 @@ "containers": [{ "name": "codeflare-containers", "image": "./container/Dockerfile", - "class_name": "MyContainer", - "instances": 2 + "class_name": "StreamContainer", + "instances": 2, + "enable_internet": true }], "durable_objects": { "bindings": [ { - "name": "MY_CONTAINER", - "class_name": "MyContainer" + "name": "STREAM_CONTAINER", + "class_name": "StreamContainer" } ] }, @@ -31,6 +32,15 @@ "new_sqlite_classes": [ "MyContainer" ] + }, + { + "tag": "v2", + "new_sqlite_classes": [ + "StreamContainer" + ], + "deleted_classes": [ + "MyContainer" + ] } ], "observability": { diff --git a/packages/stream-kit-web/package.json b/packages/stream-kit-web/package.json index c51e6e3..1132ec3 100644 --- a/packages/stream-kit-web/package.json +++ b/packages/stream-kit-web/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "test": "vitest", + "test": "vitest run", "test:watch": "vitest --watch", "test:coverage": "vitest --coverage", "typecheck": "tsc --noEmit", From 756091f8704ae2f75be4ae562d2644168fea6f5b Mon Sep 17 00:00:00 2001 From: AI Feature Agent Date: Fri, 13 Mar 2026 00:50:14 -0700 Subject: [PATCH 06/13] Harden Worker boundaries and add seam tests --- .../bun-stream-server/container/src/server.ts | 22 ++- examples/bun-stream-server/package.json | 3 +- examples/bun-stream-server/src/index.ts | 102 +++++++++--- examples/bun-stream-server/src/protocol.ts | 84 ++++++++++ examples/bun-stream-server/test/index.test.ts | 152 ++++++++++++++++++ examples/bun-stream-server/tsconfig.json | 2 +- .../worker-configuration.d.ts | 1 + 7 files changed, 335 insertions(+), 31 deletions(-) create mode 100644 examples/bun-stream-server/src/protocol.ts create mode 100644 examples/bun-stream-server/test/index.test.ts diff --git a/examples/bun-stream-server/container/src/server.ts b/examples/bun-stream-server/container/src/server.ts index d1eeaf0..fee05db 100644 --- a/examples/bun-stream-server/container/src/server.ts +++ b/examples/bun-stream-server/container/src/server.ts @@ -23,6 +23,10 @@ import type { Browser, Page } from 'puppeteer'; import crypto from 'crypto'; import http from 'http'; import url from 'url'; +import { + parseStartStreamRequest, + type IceServerConfig, +} from '../../src/protocol'; // TypeScript declaration for browser window extensions declare global { @@ -35,7 +39,7 @@ declare global { lastError: string | null; callCount: number; }; - INITIALIZE?: (params: { srcPeerId: string; destPeerId: string; iceServers?: Array<{ urls: string[] | string; username?: string; credential?: string }> }) => Promise; + INITIALIZE?: (params: { srcPeerId: string; destPeerId: string; iceServers?: IceServerConfig[] }) => Promise; Peer?: any; // PeerJS constructor } } @@ -830,7 +834,7 @@ async function handleDebugState(traceId: string): Promise { } async function handleStartStream( - data: { url: string; peerId: string; iceServers?: Array<{ urls: string[] | string; username?: string; credential?: string }> }, + data: { url: string; peerId: string; iceServers?: IceServerConfig[] }, traceId: string ): Promise { try { @@ -1075,7 +1079,7 @@ const server = http.createServer(async (req: any, res: any) => { req.on('end', resolve); }); - const data = JSON.parse(body); + const data = parseStartStreamRequest(JSON.parse(body)); response = await handleStartStream(data, traceId); } else { response = new Response(`Not Found: ${req.method} ${req.url} (parsed path: ${pathname})`, { status: 404 }); @@ -1091,6 +1095,18 @@ const server = http.createServer(async (req: any, res: any) => { res.end(responseText); } catch (err: any) { + if (err instanceof SyntaxError || err?.message?.includes('must be')) { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, x-stream-trace-id', + 'x-stream-trace-id': req.headers['x-stream-trace-id'] || '', + }); + res.end(JSON.stringify({ status: 'error', message: err.message })); + return; + } + console.error('Unhandled error:', err); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: err.message })); diff --git a/examples/bun-stream-server/package.json b/examples/bun-stream-server/package.json index c75076c..a7a4f8f 100644 --- a/examples/bun-stream-server/package.json +++ b/examples/bun-stream-server/package.json @@ -5,7 +5,8 @@ "scripts": { "deploy": "wrangler deploy", "dev": "wrangler dev", - "start": "wrangler dev" + "start": "wrangler dev", + "test": "npx --yes vitest@2.1.9 run test/index.test.ts" }, "devDependencies": { "@types/bun": "^1.2.21", diff --git a/examples/bun-stream-server/src/index.ts b/examples/bun-stream-server/src/index.ts index a0bf5e2..085db19 100644 --- a/examples/bun-stream-server/src/index.ts +++ b/examples/bun-stream-server/src/index.ts @@ -1,14 +1,14 @@ import crypto from "crypto"; +import { Buffer } from "buffer"; +import { + parseTurnCredentialsResponse, + type IceServerConfig, +} from "./protocol"; const OPEN_CONTAINER_PORT = 8080; const STREAM_INSTANCE_NAME = "default-singleton-debug-v3"; -const TURN_TTL_SECONDS = 3600; - -type IceServer = { - urls: string[] | string; - username?: string; - credential?: string; -}; +const TURN_TTL_SECONDS = 300; +const DEBUG_TOKEN_HEADER = "x-debug-token"; function logTrace(traceId: string, event: string, details?: Record) { if (details) { @@ -24,10 +24,18 @@ function withTraceHeader(response: Response, traceId: string): Response { return cloned; } -function normalizeIceServers(iceServers: IceServer[]): IceServer[] { +export function normalizeIceServers(iceServers: IceServerConfig[]): IceServerConfig[] { return iceServers.map((server) => { const urls = Array.isArray(server.urls) ? server.urls : [server.urls]; - const filteredUrls = urls.filter((url) => !url.includes(":53")); + const filteredUrls = urls.filter((url) => { + const normalizedUrl = url.toLowerCase(); + return !( + normalizedUrl.includes(":53?") || + normalizedUrl.endsWith(":53") || + normalizedUrl.includes(":53#") || + normalizedUrl.includes(":53/") + ); + }); return { ...server, urls: filteredUrls, @@ -38,7 +46,42 @@ function normalizeIceServers(iceServers: IceServer[]): IceServer[] { }); } -async function generateTurnIceServers(env: Env, traceId: string): Promise { +function timingSafeMatches(actual: string, expected: string): boolean { + const actualBytes = Buffer.from(actual); + const expectedBytes = Buffer.from(expected); + + if (actualBytes.length !== expectedBytes.length) { + return false; + } + + return crypto.timingSafeEqual(actualBytes, expectedBytes); +} + +export function isDebugRequestAuthorized(request: Request, env: Env): boolean { + if (!env.DEBUG_STATE_TOKEN) { + return true; + } + + const providedToken = request.headers.get(DEBUG_TOKEN_HEADER); + if (!providedToken) { + return false; + } + + return timingSafeMatches(providedToken, env.DEBUG_STATE_TOKEN); +} + +function buildCorsHeaders(methods: string): HeadersInit { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": methods, + "Access-Control-Allow-Headers": `Content-Type, x-stream-trace-id, ${DEBUG_TOKEN_HEADER}`, + }; +} + +export async function generateTurnIceServers( + env: Env, + traceId: string +): Promise { const apiToken = env.CLOUDFLARE_TURN_API_TOKEN; const turnKeyId = env.CLOUDFLARE_TURN_KEY_ID; @@ -68,11 +111,7 @@ async function generateTurnIceServers(env: Env, traceId: string): Promise { + return typeof value === "object" && value !== null; +} + +function parseNonEmptyString(value: unknown, fieldName: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${fieldName} must be a non-empty string`); + } + + return value.trim(); +} + +export function parseIceServerConfig(value: unknown): IceServerConfig { + if (!isRecord(value)) { + throw new Error("ice server must be an object"); + } + + const rawUrls = value.urls; + const urls = Array.isArray(rawUrls) + ? rawUrls.map((url, index) => parseNonEmptyString(url, `iceServers[].urls[${index}]`)) + : parseNonEmptyString(rawUrls, "iceServers[].urls"); + + if (typeof value.username !== "undefined" && typeof value.username !== "string") { + throw new Error("iceServers[].username must be a string when provided"); + } + + if (typeof value.credential !== "undefined" && typeof value.credential !== "string") { + throw new Error("iceServers[].credential must be a string when provided"); + } + + return { + urls, + username: value.username, + credential: value.credential, + }; +} + +export function parseIceServers(value: unknown): IceServerConfig[] { + if (typeof value === "undefined") { + return []; + } + + if (!Array.isArray(value)) { + throw new Error("iceServers must be an array when provided"); + } + + return value.map(parseIceServerConfig); +} + +export function parseTurnCredentialsResponse(value: unknown): { + iceServers: IceServerConfig[]; +} { + if (!isRecord(value) || !("iceServers" in value)) { + throw new Error("TURN credentials response did not include iceServers"); + } + + return { + iceServers: parseIceServers(value.iceServers), + }; +} + +export function parseStartStreamRequest(value: unknown): StartStreamRequest { + if (!isRecord(value)) { + throw new Error("request body must be an object"); + } + + return { + url: parseNonEmptyString(value.url, "url"), + peerId: parseNonEmptyString(value.peerId, "peerId"), + iceServers: parseIceServers(value.iceServers), + }; +} diff --git a/examples/bun-stream-server/test/index.test.ts b/examples/bun-stream-server/test/index.test.ts new file mode 100644 index 0000000..81364ed --- /dev/null +++ b/examples/bun-stream-server/test/index.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import worker from "../src/index"; + +type TestEnv = Parameters[1]; + +function createContainerNamespace(fetchImpl?: (request: Request) => Promise) { + const calls: Request[] = []; + const stubFetch = vi.fn(async (request: Request) => { + calls.push(request); + if (fetchImpl) { + return fetchImpl(request); + } + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + return { + idFromName: vi.fn(() => "stream-id"), + get: vi.fn(() => ({ fetch: stubFetch })), + stubFetch, + calls, + }; +} + +function createEnv(overrides: Partial = {}) { + const container = createContainerNamespace(); + const env: TestEnv = { + CLOUDFLARE_TURN_API_TOKEN: "turn-token", + CLOUDFLARE_TURN_KEY_ID: "turn-key-id", + STREAM_CONTAINER: container as unknown as TestEnv["STREAM_CONTAINER"], + ...overrides, + }; + + return { env, container }; +} + +describe("bun-stream-server worker", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("returns normalized TURN ice servers", async () => { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + iceServers: [ + { + urls: [ + "stun:stun.cloudflare.com:3478", + "turn:turn.cloudflare.com:53?transport=udp", + ], + }, + { + urls: "turn:turn.cloudflare.com:5349?transport=tcp", + username: "user", + credential: "pass", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + vi.stubGlobal("fetch", fetchMock); + + const { env } = createEnv(); + const response = await worker.fetch( + new Request("https://example.com/ice-servers", { + headers: { + "x-stream-trace-id": "trace-ice-1", + }, + }), + env + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + iceServers: [ + { + urls: ["stun:stun.cloudflare.com:3478"], + }, + { + urls: ["turn:turn.cloudflare.com:5349?transport=tcp"], + username: "user", + credential: "pass", + }, + ], + traceId: "trace-ice-1", + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://rtc.live.cloudflare.com/v1/turn/keys/turn-key-id/credentials/generate-ice-servers", + { + method: "POST", + headers: { + Authorization: "Bearer turn-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ ttl: 300 }), + } + ); + }); + + it("blocks /debug-state when the debug token is missing", async () => { + const { env, container } = createEnv({ DEBUG_STATE_TOKEN: "top-secret" }); + + const response = await worker.fetch( + new Request("https://example.com/debug-state", { + headers: { + "x-stream-trace-id": "trace-debug-blocked", + }, + }), + env + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "Forbidden", + traceId: "trace-debug-blocked", + }); + expect(container.get).not.toHaveBeenCalled(); + expect(container.stubFetch).not.toHaveBeenCalled(); + }); + + it("forwards /debug-state when the debug token matches", async () => { + const { env, container } = createEnv({ DEBUG_STATE_TOKEN: "top-secret" }); + + const response = await worker.fetch( + new Request("https://example.com/debug-state", { + headers: { + "x-stream-trace-id": "trace-debug-allowed", + "x-debug-token": "top-secret", + }, + }), + env + ); + + expect(response.status).toBe(200); + expect(container.get).toHaveBeenCalledOnce(); + expect(container.stubFetch).toHaveBeenCalledOnce(); + + const forwardedRequest = container.calls[0]; + expect(forwardedRequest.headers.get("x-stream-trace-id")).toBe( + "trace-debug-allowed" + ); + }); +}); diff --git a/examples/bun-stream-server/tsconfig.json b/examples/bun-stream-server/tsconfig.json index 6a06624..bc183bd 100644 --- a/examples/bun-stream-server/tsconfig.json +++ b/examples/bun-stream-server/tsconfig.json @@ -3,7 +3,7 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "node", - "types": ["./worker-configuration.d.ts"], + "types": ["node", "./worker-configuration.d.ts"], "esModuleInterop": true, "strict": true, "skipLibCheck": true, diff --git a/examples/bun-stream-server/worker-configuration.d.ts b/examples/bun-stream-server/worker-configuration.d.ts index 6d2e5da..529dbac 100644 --- a/examples/bun-stream-server/worker-configuration.d.ts +++ b/examples/bun-stream-server/worker-configuration.d.ts @@ -4,6 +4,7 @@ declare namespace Cloudflare { interface Env { CLOUDFLARE_TURN_API_TOKEN: string; CLOUDFLARE_TURN_KEY_ID: string; + DEBUG_STATE_TOKEN?: string; STREAM_CONTAINER: DurableObjectNamespace; } } From 2edf3f4c544a7befc04cfe210248edaafcfd1575 Mon Sep 17 00:00:00 2001 From: AI Feature Agent Date: Fri, 13 Mar 2026 00:53:07 -0700 Subject: [PATCH 07/13] Isolate streamed sessions per receiver --- examples/bun-stream-server/receiver.html | 8 ++- examples/bun-stream-server/src/index.ts | 68 +++++++++++++++---- examples/bun-stream-server/test/index.test.ts | 30 ++++++++ 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/examples/bun-stream-server/receiver.html b/examples/bun-stream-server/receiver.html index a35f839..77c091e 100644 --- a/examples/bun-stream-server/receiver.html +++ b/examples/bun-stream-server/receiver.html @@ -216,6 +216,7 @@

How to use:

let isListening = false; let callHandlersBound = false; let currentTraceId = null; + const sessionId = 'session-' + Math.random().toString(36).substring(2, 15); const urlInput = document.getElementById('urlInput'); const receiverIdDisplay = document.getElementById('receiverIdDisplay'); @@ -329,7 +330,8 @@

How to use:

const iceResponse = await fetch(iceServersEndpoint, { method: 'GET', headers: { - 'x-stream-trace-id': currentTraceId + 'x-stream-trace-id': currentTraceId, + 'x-stream-session-id': sessionId } }); const icePayload = await iceResponse.json(); @@ -370,7 +372,8 @@

How to use:

method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-stream-trace-id': currentTraceId + 'x-stream-trace-id': currentTraceId, + 'x-stream-session-id': sessionId }, body: JSON.stringify({ url: url, @@ -755,6 +758,7 @@

How to use:

console.log('🎥 Stream Kit Receiver ready'); console.log('📱 Receiver ID:', receiverId); + console.log('🪪 Session ID:', sessionId); console.log('💡 Enter a URL and click "Start Stream & Listen" to begin'); diff --git a/examples/bun-stream-server/src/index.ts b/examples/bun-stream-server/src/index.ts index 085db19..81563b3 100644 --- a/examples/bun-stream-server/src/index.ts +++ b/examples/bun-stream-server/src/index.ts @@ -9,6 +9,7 @@ const OPEN_CONTAINER_PORT = 8080; const STREAM_INSTANCE_NAME = "default-singleton-debug-v3"; const TURN_TTL_SECONDS = 300; const DEBUG_TOKEN_HEADER = "x-debug-token"; +const SESSION_ID_HEADER = "x-stream-session-id"; function logTrace(traceId: string, event: string, details?: Record) { if (details) { @@ -24,6 +25,16 @@ function withTraceHeader(response: Response, traceId: string): Response { return cloned; } +function withSessionHeader(response: Response, sessionId: string | null): Response { + if (!sessionId) { + return response; + } + + const cloned = new Response(response.body, response); + cloned.headers.set(SESSION_ID_HEADER, sessionId); + return cloned; +} + export function normalizeIceServers(iceServers: IceServerConfig[]): IceServerConfig[] { return iceServers.map((server) => { const urls = Array.isArray(server.urls) ? server.urls : [server.urls]; @@ -74,10 +85,25 @@ function buildCorsHeaders(methods: string): HeadersInit { return { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": methods, - "Access-Control-Allow-Headers": `Content-Type, x-stream-trace-id, ${DEBUG_TOKEN_HEADER}`, + "Access-Control-Allow-Headers": `Content-Type, x-stream-trace-id, ${DEBUG_TOKEN_HEADER}, ${SESSION_ID_HEADER}`, + "Access-Control-Expose-Headers": `x-stream-trace-id, ${SESSION_ID_HEADER}`, }; } +export function resolveSessionId(request: Request): string | null { + const providedSessionId = request.headers.get(SESSION_ID_HEADER); + if (!providedSessionId) { + return null; + } + + const normalizedSessionId = providedSessionId.trim(); + if (!/^[a-zA-Z0-9_-]{1,100}$/.test(normalizedSessionId)) { + return null; + } + + return normalizedSessionId; +} + export async function generateTurnIceServers( env: Env, traceId: string @@ -341,9 +367,12 @@ export default { async fetch(request: Request, env: Env): Promise { const traceId = request.headers.get("x-stream-trace-id") || crypto.randomUUID(); const url = new URL(request.url); + const sessionId = resolveSessionId(request); + const streamInstanceName = sessionId ? `session-${sessionId}` : STREAM_INSTANCE_NAME; logTrace(traceId, "worker_request_received", { method: request.method, path: url.pathname, + sessionId, }); if (url.pathname === "/ice-servers" && request.method === "GET") { @@ -353,6 +382,7 @@ export default { { iceServers: await generateTurnIceServers(env, traceId), traceId, + sessionId, }, { headers: { @@ -362,12 +392,13 @@ export default { ), traceId ); - logTrace(traceId, "worker_response_sent", { status: response.status, path: url.pathname }); - return response; + const tracedResponse = withSessionHeader(response, sessionId); + logTrace(traceId, "worker_response_sent", { status: tracedResponse.status, path: url.pathname, sessionId }); + return tracedResponse; } catch (error) { const response = withTraceHeader( createJsonResponse( - { error: (error as Error).message, traceId }, + { error: (error as Error).message, traceId, sessionId }, { status: 500, headers: { @@ -377,8 +408,9 @@ export default { ), traceId ); - logTrace(traceId, "worker_response_sent", { status: response.status, path: url.pathname }); - return response; + const tracedResponse = withSessionHeader(response, sessionId); + logTrace(traceId, "worker_response_sent", { status: tracedResponse.status, path: url.pathname, sessionId }); + return tracedResponse; } } @@ -393,12 +425,14 @@ export default { ), traceId ); + const tracedResponse = withSessionHeader(response, sessionId); logTrace(traceId, "worker_response_sent", { - status: response.status, + status: tracedResponse.status, path: url.pathname, authorized: false, + sessionId, }); - return response; + return tracedResponse; } if (request.method === "OPTIONS") { @@ -409,20 +443,26 @@ export default { }), traceId ); - logTrace(traceId, "worker_response_sent", { status: response.status, path: url.pathname }); - return response; + const tracedResponse = withSessionHeader(response, sessionId); + logTrace(traceId, "worker_response_sent", { status: tracedResponse.status, path: url.pathname, sessionId }); + return tracedResponse; } - // Always route through the same Durable Object to share the container instance - const id = env.STREAM_CONTAINER.idFromName(STREAM_INSTANCE_NAME); + const id = env.STREAM_CONTAINER.idFromName(streamInstanceName); const stub = env.STREAM_CONTAINER.get(id); const forwardedRequest = new Request(request, { headers: new Headers(request.headers), }); forwardedRequest.headers.set("x-stream-trace-id", traceId); + if (sessionId) { + forwardedRequest.headers.set(SESSION_ID_HEADER, sessionId); + } - const response = withTraceHeader(await stub.fetch(forwardedRequest), traceId); - logTrace(traceId, "worker_response_sent", { status: response.status, path: url.pathname }); + const response = withSessionHeader( + withTraceHeader(await stub.fetch(forwardedRequest), traceId), + sessionId + ); + logTrace(traceId, "worker_response_sent", { status: response.status, path: url.pathname, sessionId }); return response; }, }; diff --git a/examples/bun-stream-server/test/index.test.ts b/examples/bun-stream-server/test/index.test.ts index 81364ed..8ca1b65 100644 --- a/examples/bun-stream-server/test/index.test.ts +++ b/examples/bun-stream-server/test/index.test.ts @@ -91,6 +91,7 @@ describe("bun-stream-server worker", () => { credential: "pass", }, ], + sessionId: null, traceId: "trace-ice-1", }); expect(fetchMock).toHaveBeenCalledWith( @@ -149,4 +150,33 @@ describe("bun-stream-server worker", () => { "trace-debug-allowed" ); }); + + it("routes requests to a session-scoped durable object when a session header is present", async () => { + const { env, container } = createEnv(); + + const response = await worker.fetch( + new Request("https://example.com/start-stream", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-stream-trace-id": "trace-session-1", + "x-stream-session-id": "receiver-session-1", + }, + body: JSON.stringify({ + url: "https://example.com", + peerId: "receiver-123", + iceServers: [], + }), + }), + env + ); + + expect(response.status).toBe(200); + expect(container.idFromName).toHaveBeenCalledWith("session-receiver-session-1"); + + const forwardedRequest = container.calls[0]; + expect(forwardedRequest.headers.get("x-stream-session-id")).toBe( + "receiver-session-1" + ); + }); }); From d45756c777dc5aa972089ae64d2c57ab18764f29 Mon Sep 17 00:00:00 2001 From: AI Feature Agent Date: Fri, 13 Mar 2026 08:42:56 -0700 Subject: [PATCH 08/13] Refresh stream-kit docs and agent context --- .gitignore | 6 +- AGENTS.md | 1 + ARCHITECTURE.md | 169 +-------------------------- CLAUDE.md | 61 ++++++++++ README.md | 76 +++++++----- docs/architecture.md | 92 +++++++++++++++ docs/index.md | 16 +++ docs/integration.md | 107 +++++++++++++++++ docs/status.md | 59 ++++++++++ examples/bun-stream-server/README.md | 116 ++++++------------ 10 files changed, 432 insertions(+), 271 deletions(-) create mode 120000 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 docs/architecture.md create mode 100644 docs/index.md create mode 100644 docs/integration.md create mode 100644 docs/status.md diff --git a/.gitignore b/.gitignore index 2a14f5f..05d3e51 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,8 @@ junit.xml .wrangler/ # LLM context docs -.llms \ No newline at end of file +.llms + +# Agent working files +.swarm/ +.claude/ diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d7fe40a..0d9658f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,167 +1,10 @@ # Stream Kit Architecture -This document is the central map for the repo: what is product code, what is example code, how the Cloudflare deployment works, and where OpenGame System fits in. +The canonical architecture document now lives at [`docs/architecture.md`](./docs/architecture.md). -## What This Repo Actually Contains +For the latest repo map and current status, start with: -There are two separate layers in this repository: - -1. The reusable packages in `packages/` -2. The end-to-end Cloudflare example in `examples/bun-stream-server/` - -They are related, but today they are not fully wired together. - -## The Reusable Packages - -These packages are the SDK layer: - -- `packages/stream-kit-types` - - Shared TypeScript types for sessions, stream state, render options, and events. -- `packages/stream-kit-web` - - Browser-side client abstractions for requesting a stream session and managing a `RenderStream`. - - This is the beginnings of a generic browser client SDK. -- `packages/stream-kit-react` - - React bindings around an existing `RenderStream`. - - This does not start a Cloudflare container by itself. It renders and subscribes to a stream object. -- `packages/stream-kit-server` - - Server-side abstractions and a hook-based router for stream state persistence. - - It also contains a `StreamKitServer` class, but that API is still incomplete and is not what the Cloudflare example uses. -- `packages/stream-kit-testing` - - Mock clients and test helpers for the package layer. - -These packages are the foundation for a future productized SDK, but they are not the deployed runtime that powers the current Cloudflare demo. - -## The Cloudflare Example - -The real working deployment path today lives in `examples/bun-stream-server/`. - -That example is self-contained and includes: - -- `examples/bun-stream-server/src/index.ts` - - The public Cloudflare Worker entrypoint - - Routes all incoming requests through a single Durable Object instance - - Starts and proxies a Cloudflare Container -- `examples/bun-stream-server/wrangler.jsonc` - - Declares the Worker, Durable Object binding, and container image - - Enables outbound internet for the container deployment path -- `examples/bun-stream-server/container/` - - The containerized Node/Puppeteer server that runs Chromium -- `examples/bun-stream-server/container/extension/` - - A Chrome extension that captures the rendered tab and sends it over PeerJS/WebRTC -- `examples/bun-stream-server/receiver.html` - - A standalone receiver page that opens a PeerJS peer, asks the Worker to start a stream, and displays the remote media - -## Current End-to-End Flow - -```mermaid -flowchart LR - User["Receiver Browser"] -->|"POST /start-stream"| Worker["Cloudflare Worker"] - Worker --> DO["Durable Object"] - DO --> Container["Cloudflare Container"] - Container --> Chromium["Chromium + Extension"] - Chromium -->|"PeerJS/WebRTC call"| User -``` - -The flow is: - -1. The receiver page opens a PeerJS receiver peer in the browser. -2. The receiver page sends `POST /start-stream` to the deployed Worker with: - - `url`: the website to render - - `peerId`: the receiver's PeerJS ID -3. The Worker forwards the request into a single Durable Object. -4. The Durable Object ensures a container instance is running. -5. The container launches Chromium and opens the target URL. -6. The Chrome extension captures the target tab. -7. The extension creates a PeerJS sender peer and calls the receiver peer. -8. The receiver answers the call and attaches the returned `MediaStream` to the page. - -## Does This Require OpenGame API? - -No. - -The current example does not call OpenGame API at all. It can be deployed and exercised as its own standalone system. - -## How OpenGame Would Integrate - -OpenGame is optional orchestration around Stream Kit, not a hard runtime dependency. - -The likely integration model is: - -1. OpenGame decides when a stream should exist. -2. OpenGame calls your deployed Stream Kit Worker with the page or game URL to render. -3. Stream Kit produces the remote browser stream. -4. OpenGame embeds the receiver UI or uses the package layer to display the stream inside its own app. - -In other words: - -- OpenGame can own auth, tenancy, sessions, game state, and entitlement. -- Stream Kit can own remote rendering, browser automation, and video delivery. - -## Do You Need Your Own Worker? - -Yes, for the Cloudflare example path. - -The example under `examples/bun-stream-server/` is designed to be deployed as your own Worker + Durable Object + Container stack. It is not a client-only artifact. - -You can run it: - -- Locally, by running the container server yourself and opening `receiver.html` -- In Cloudflare, by deploying the Worker with Wrangler - -## What Works Right Now - -We have verified these pieces: - -- The Worker deploys successfully. -- The Durable Object comes up. -- The container boots. -- `/health` responds from the deployed environment. -- `/test-puppeteer` successfully launches Chromium with the extension. -- `/start-stream` now returns success from the deployed environment. -- The receiver now receives an incoming PeerJS call from the container. -- The receiver now receives a `MediaStream`. - -## What Is Still Broken - -The final media leg is still not correct in the deployed Cloudflare environment. - -Current observed behavior: - -- The browser receives a `MediaStream` -- The call connects -- The `