@volter/world-runtime 3.0.157 → 3.0.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ // THE BOARD'S PICTURES (served-world.ts, BOARD_PICTURE_SCRIPT): a page a World's board shows draws its own picture when
2
+ // the board asks, since a page cannot photograph another origin's. The World adds this script to the pages a console
3
+ // frames, from the page's own origin, naming that console; it answers that console alone, with what the page shows at
4
+ // the top of its window, as a JPEG. Pictures are taken in the viewer's browser while the page runs, one page at a time,
5
+ // and kept by the World against the frame's change (the console's useBoardPictures).
6
+ import { domToBlob } from 'modern-screenshot';
7
+
8
+ /** How long a picture waits for one of the page's resources (an image, a font) before drawing without it: a page whose
9
+ * image never answers is drawn as it stands, not after the library's own half minute. */
10
+ const RESOURCE_WAIT_MS = 3000;
11
+
12
+ const script = document.currentScript as HTMLScriptElement | null;
13
+ const board = script?.dataset.board ?? '';
14
+
15
+ type Ask = { type?: unknown; id?: unknown };
16
+
17
+ if (board && window.parent !== window) {
18
+ addEventListener('message', (event: MessageEvent) => {
19
+ if (event.origin !== board || event.source !== window.parent) return;
20
+ const ask = event.data as Ask;
21
+ if (ask?.type !== 'volter-board-picture' || typeof ask.id !== 'string') return;
22
+ const id = ask.id;
23
+ void picture().then(
24
+ (image) => window.parent.postMessage({ type: 'volter-board-picture', id, image }, board),
25
+ (error: unknown) => window.parent.postMessage({ type: 'volter-board-picture', id, error: error instanceof Error ? error.message : String(error) }, board),
26
+ );
27
+ });
28
+ // the board asks once the page has loaded
29
+ const ready = (): void => window.parent.postMessage({ type: 'volter-board-picture-ready' }, board);
30
+ if (document.readyState === 'complete') ready();
31
+ else addEventListener('load', ready, { once: true });
32
+ }
33
+
34
+ /** The colour behind the page, as the browser paints it: the root's or the body's background, else the canvas's white. A
35
+ * JPEG has no transparency, so a page that leaves its background unset would otherwise be drawn on black. */
36
+ function backdrop(): string {
37
+ const unset = (c: string): boolean => c === '' || c === 'transparent' || /^rgba\(.*,\s*0\)$/.test(c);
38
+ for (const element of [document.documentElement, document.body]) {
39
+ const colour = element ? getComputedStyle(element).backgroundColor : '';
40
+ if (!unset(colour)) return colour;
41
+ }
42
+ return '#ffffff';
43
+ }
44
+
45
+ async function picture(): Promise<Blob> {
46
+ await document.fonts?.ready;
47
+ const image = await domToBlob(document.documentElement, { width: innerWidth, height: innerHeight, scale: 1, type: 'image/jpeg', quality: 0.8, timeout: RESOURCE_WAIT_MS, backgroundColor: backdrop() });
48
+ if (!image) throw new Error('the page drew no picture');
49
+ return image;
50
+ }
@@ -0,0 +1,14 @@
1
+ function Pz(z,Q){return z[13]=1,z[14]=Q>>8,z[15]=Q&255,z[16]=Q>>8,z[17]=Q&255,z}var o=112,t=72,e=89,zz=115,v;function Oz(){let z=new Int32Array(256);for(let Q=0;Q<256;Q++){let Z=Q;for(let $=0;$<8;$++)Z=Z&1?3988292384^Z>>>1:Z>>>1;z[Q]=Z}return z}function _z(z){let Q=-1;if(!v)v=Oz();for(let Z=0;Z<z.length;Z++)Q=v[(Q^z[Z])&255]^Q>>>8;return Q^-1}function Az(z){let Q=z.length-1;for(let Z=Q;Z>=4;Z--)if(z[Z-4]===9&&z[Z-3]===o&&z[Z-2]===t&&z[Z-1]===e&&z[Z]===zz)return Z-3;return 0}function kz(z,Q,Z=!1){let $=new Uint8Array(13);Q*=39.3701,$[0]=o,$[1]=t,$[2]=e,$[3]=zz,$[4]=Q>>>24,$[5]=Q>>>16,$[6]=Q>>>8,$[7]=Q&255,$[8]=$[4],$[9]=$[5],$[10]=$[6],$[11]=$[7],$[12]=1;let Y=_z($),J=new Uint8Array(4);if(J[0]=Y>>>24,J[1]=Y>>>16,J[2]=Y>>>8,J[3]=Y&255,Z){let X=Az(z);return z.set($,X),z.set(J,X+13),z}else{let X=new Uint8Array(4);X[0]=0,X[1]=0,X[2]=0,X[3]=9;let K=new Uint8Array(54);return K.set(z,0),K.set(X,33),K.set($,37),K.set(J,50),K}}var Qz="[modern-screenshot]",I=typeof window<"u",Nz=I&&"Worker"in window,Dz=I&&"atob"in window,vQ=I&&"btoa"in window,m=I?window.navigator?.userAgent:"",Zz=m.includes("Chrome"),S=m.includes("AppleWebKit")&&!Zz,x=m.includes("Firefox"),Iz=(z)=>z&&("__CONTEXT__"in z),Uz=(z)=>z.constructor.name==="CSSFontFaceRule",Fz=(z)=>z.constructor.name==="CSSImportRule",Rz=(z)=>z.constructor.name==="CSSLayerBlockRule",k=(z)=>z.nodeType===1,C=(z)=>typeof z.className==="object",$z=(z)=>z.tagName==="image",wz=(z)=>z.tagName==="use",R=(z)=>k(z)&&typeof z.style<"u"&&!C(z),Ez=(z)=>z.nodeType===8,Cz=(z)=>z.nodeType===3,F=(z)=>z.tagName==="IMG",b=(z)=>z.tagName==="VIDEO",fz=(z)=>z.tagName==="CANVAS",Tz=(z)=>z.tagName==="TEXTAREA",Sz=(z)=>z.tagName==="INPUT",bz=(z)=>z.tagName==="STYLE",hz=(z)=>z.tagName==="SCRIPT",gz=(z)=>z.tagName==="SELECT",vz=(z)=>z.tagName==="SLOT",pz=(z)=>z.tagName==="IFRAME",yz=(...z)=>console.warn(Qz,...z);function mz(z){let Q=z?.createElement?.("canvas");if(Q)Q.height=Q.width=1;return Boolean(Q)&&"toDataURL"in Q&&Boolean(Q.toDataURL("image/webp").includes("image/webp"))}var p=(z)=>z.startsWith("data:");function Kz(z,Q){if(z.match(/^[a-z]+:\/\//i))return z;if(I&&z.match(/^\/\//))return window.location.protocol+z;if(z.match(/^[a-z]+:/i))return z;if(!I)return z;let Z=h().implementation.createHTMLDocument(),$=Z.createElement("base"),Y=Z.createElement("a");if(Z.head.appendChild($),Z.body.appendChild(Y),Q)$.href=Q;return Y.href=z,Y.href}function h(z){return(z&&k(z)?z?.ownerDocument:z)??window.document}var g="http://www.w3.org/2000/svg";function xz(z,Q,Z){let $=h(Z).createElementNS(g,"svg");return $.setAttributeNS(null,"width",z.toString()),$.setAttributeNS(null,"height",Q.toString()),$.setAttributeNS(null,"viewBox",`0 0 ${z} ${Q}`),$}function uz(z,Q){let Z=new XMLSerializer().serializeToString(z);if(Q)Z=Z.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"");return`data:image/svg+xml;charset=utf-8,${encodeURIComponent(Z)}`}async function iz(z,Q="image/png",Z=1){try{return await new Promise(($,Y)=>{z.toBlob((J)=>{if(J)$(J);else Y(Error("Blob is null"))},Q,Z)})}catch($){if(Dz)return cz(z.toDataURL(Q,Z));throw $}}function cz(z){let[Q,Z]=z.split(","),$=Q.match(/data:(.+);/)?.[1]??void 0,Y=window.atob(Z),J=Y.length,X=new Uint8Array(J);for(let K=0;K<J;K+=1)X[K]=Y.charCodeAt(K);return new Blob([X],{type:$})}function Jz(z,Q){return new Promise((Z,$)=>{let Y=new FileReader;if(Y.onload=()=>Z(Y.result),Y.onerror=()=>$(Y.error),Y.onabort=()=>$(Error(`Failed read blob to ${Q}`)),Q==="dataUrl")Y.readAsDataURL(z);else if(Q==="arrayBuffer")Y.readAsArrayBuffer(z)})}var dz=(z)=>Jz(z,"dataUrl"),lz=(z)=>Jz(z,"arrayBuffer");function U(z,Q){let Z=h(Q).createElement("img");return Z.decoding="sync",Z.loading="eager",Z.src=z,Z}function w(z,Q){return new Promise((Z)=>{let{timeout:$,ownerDocument:Y,onError:J,onWarn:X}=Q??{},K=typeof z==="string"?U(z,h(Y)):z,H=null,j=null;function G(){Z(K),H&&clearTimeout(H),j?.()}if($)H=setTimeout(G,$);if(b(K)){let q=K.currentSrc||K.src;if(!q){if(K.poster)return w(K.poster,Q).then(Z);return G()}if(K.readyState>=2)return G();let B=G,M=(W)=>{X?.("Failed video load",q,W),J?.(W),G()};j=()=>{K.removeEventListener("loadeddata",B),K.removeEventListener("error",M)},K.addEventListener("loadeddata",B,{once:!0}),K.addEventListener("error",M,{once:!0})}else{let q=$z(K)?K.href.baseVal:K.currentSrc||K.src;if(!q)return G();let B=async()=>{if(F(K)&&"decode"in K)try{await K.decode()}catch(W){X?.("Failed to decode image, trying to render anyway",K.dataset.originalSrc||q,W)}G()},M=(W)=>{X?.("Failed image load",K.dataset.originalSrc||q,W),G()};if(F(K)&&K.complete)return B();j=()=>{K.removeEventListener("load",B),K.removeEventListener("error",M)},K.addEventListener("load",B,{once:!0}),K.addEventListener("error",M,{once:!0})}})}async function sz(z,Q){if(R(z))if(F(z)||b(z))await w(z,Q);else await Promise.all(["img","video"].flatMap((Z)=>{return Array.from(z.querySelectorAll(Z)).map(($)=>w($,Q))}))}var Yz=function(){let Q=0,Z=()=>`0000${(Math.random()*1679616<<0).toString(36)}`.slice(-4);return()=>{return Q+=1,`u${Z()}${Q}`}}();function Xz(z){return z?.split(",").map((Q)=>Q.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}var d=0;function az(z){let Q=`${Qz}[#${d}]`;return d++,{time:(Z)=>z&&console.time(`${Q} ${Z}`),timeEnd:(Z)=>z&&console.timeEnd(`${Q} ${Z}`),warn:(...Z)=>z&&yz(...Z)}}function rz(z){return{cache:z?"no-cache":"force-cache"}}async function u(z,Q){return Iz(z)?z:nz(z,{...Q,autoDestruct:!0})}async function nz(z,Q){let{scale:Z=1,workerUrl:$,workerNumber:Y=1}=Q||{},J=Boolean(Q?.debug),X=Q?.features??!0,K=z.ownerDocument??(I?window.document:void 0),H=z.ownerDocument?.defaultView??(I?window:void 0),j=new Map,G={width:0,height:0,quality:1,type:"image/png",scale:Z,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:30000,progress:null,debug:J,fetch:{requestInit:rz(Q?.fetch?.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1,...Q?.fetch},fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:Y,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1,...Q,__CONTEXT__:!0,log:az(J),node:z,ownerDocument:K,ownerWindow:H,dpi:Z===1?null:96*Z,svgStyleElement:Gz(K),svgDefsElement:K?.createElementNS(g,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:Nz&&$&&Y?Y:0})].map(()=>{try{let M=new Worker($);return M.onmessage=async(W)=>{let{url:V,result:L}=W.data;if(L)j.get(V)?.resolve?.(L);else j.get(V)?.reject?.(Error(`Error receiving message from worker: ${V}`))},M.onmessageerror=(W)=>{let{url:V}=W.data;j.get(V)?.reject?.(Error(`Error receiving message from worker: ${V}`))},M}catch(M){return G.log.warn("Failed to new Worker",M),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[mz(K)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:j,drawImageCount:0,tasks:[],features:X,isEnable:(M)=>{if(M==="restoreScrollPosition")return typeof X==="boolean"?!1:X[M]??!1;if(typeof X==="boolean")return X;return X[M]??!0},shadowRoots:[]};G.log.time("wait until load"),await sz(z,{timeout:G.timeout,onWarn:G.log.warn}),G.log.timeEnd("wait until load");let{width:q,height:B}=oz(z,G);return G.width=q,G.height=B,G}function Gz(z){if(!z)return;let Q=z.createElement("style"),Z=Q.ownerDocument.createTextNode(`
2
+ .______background-clip--text {
3
+ background-clip: text;
4
+ -webkit-background-clip: text;
5
+ }
6
+ `);return Q.appendChild(Z),Q}function oz(z,Q){let{width:Z,height:$}=Q;if(k(z)&&(!Z||!$)){let Y=z.getBoundingClientRect();Z=Z||Y.width||Number(z.getAttribute("width"))||0,$=$||Y.height||Number(z.getAttribute("height"))||0}return{width:Z,height:$}}async function tz(z,Q){let{log:Z,timeout:$,drawImageCount:Y,drawImageInterval:J}=Q;Z.time("image to canvas");let X=await w(z,{timeout:$,onWarn:Q.log.warn}),{canvas:K,context2d:H}=ez(z.ownerDocument,Q),j=()=>{try{H?.drawImage(X,0,0,K.width,K.height)}catch(G){Q.log.warn("Failed to drawImage",G)}};if(j(),Q.isEnable("fixSvgXmlDecode"))for(let G=0;G<Y;G++)await new Promise((q)=>{setTimeout(()=>{H?.clearRect(0,0,K.width,K.height),j(),q()},G+J)});return Q.drawImageCount=0,Z.timeEnd("image to canvas"),K}function ez(z,Q){let{width:Z,height:$,scale:Y,backgroundColor:J,maximumCanvasSize:X}=Q,K=z.createElement("canvas");if(K.width=Math.floor(Z*Y),K.height=Math.floor($*Y),K.style.width=`${Z}px`,K.style.height=`${$}px`,X){if(K.width>X||K.height>X)if(K.width>X&&K.height>X)if(K.width>K.height)K.height*=X/K.width,K.width=X;else K.width*=X/K.height,K.height=X;else if(K.width>X)K.height*=X/K.width,K.width=X;else K.width*=X/K.height,K.height=X}let H=K.getContext("2d");if(H&&J)H.fillStyle=J,H.fillRect(0,0,K.width,K.height);return{canvas:K,context2d:H}}function jz(z,Q){if(z.ownerDocument)try{let J=z.toDataURL();if(J!=="data:,")return U(J,z.ownerDocument)}catch(J){Q.log.warn("Failed to clone canvas",J)}let Z=z.cloneNode(!1),$=z.getContext("2d"),Y=Z.getContext("2d");try{if($&&Y)Y.putImageData($.getImageData(0,0,z.width,z.height),0,0);return Z}catch(J){Q.log.warn("Failed to clone canvas",J)}return Z}function zQ(z,Q){try{if(z?.contentDocument?.documentElement)return i(z.contentDocument.documentElement,Q)}catch(Z){Q.log.warn("Failed to clone iframe",Z)}return z.cloneNode(!1)}function QQ(z){let Q=z.cloneNode(!1);if(z.currentSrc&&z.currentSrc!==z.src)Q.src=z.currentSrc,Q.srcset="";if(Q.loading==="lazy")Q.loading="eager";return Q}async function ZQ(z,Q){if(z.ownerDocument&&!z.currentSrc&&z.poster)return U(z.poster,z.ownerDocument);let Z=z.cloneNode(!1);if(Z.crossOrigin="anonymous",z.currentSrc&&z.currentSrc!==z.src)Z.src=z.currentSrc;let $=Z.ownerDocument;if($){let Y=!0;if(await w(Z,{onError:()=>Y=!1,onWarn:Q.log.warn}),!Y){if(z.poster)return U(z.poster,z.ownerDocument);return Z}Z.currentTime=z.currentTime,await new Promise((X)=>{Z.addEventListener("seeked",X,{once:!0})});let J=$.createElement("canvas");J.width=z.offsetWidth,J.height=z.offsetHeight;try{let X=J.getContext("2d");if(X)X.drawImage(Z,0,0,J.width,J.height)}catch(X){if(Q.log.warn("Failed to clone video",X),z.poster)return U(z.poster,z.ownerDocument);return Z}return jz(J,Q)}return Z}function $Q(z,Q){if(fz(z))return jz(z,Q);if(pz(z))return zQ(z,Q);if(F(z))return QQ(z);if(b(z))return ZQ(z,Q);return z.cloneNode(!1)}function KQ(z){let Q=z.sandbox;if(!Q){let{ownerDocument:Z}=z;try{if(Z)Q=Z.createElement("iframe"),Q.id=`__SANDBOX__${Yz()}`,Q.width="0",Q.height="0",Q.style.visibility="hidden",Q.style.position="fixed",Z.body.appendChild(Q),Q.srcdoc='<!DOCTYPE html><meta charset="UTF-8"><title></title><body>',z.sandbox=Q}catch($){z.log.warn("Failed to getSandBox",$)}}return Q}var JQ=["width","height","-webkit-text-fill-color"],YQ=["stroke","fill"];function Vz(z,Q,Z){let{defaultComputedStyles:$}=Z,Y=z.nodeName.toLowerCase(),J=C(z)&&Y!=="svg",X=J?YQ.map((V)=>[V,z.getAttribute(V)]).filter(([,V])=>V!==null):[],K=[J&&"svg",Y,X.map((V,L)=>`${V}=${L}`).join(","),Q].filter(Boolean).join(":");if($.has(K))return $.get(K);let j=KQ(Z)?.contentWindow;if(!j)return new Map;let G=j?.document,q,B;if(J)q=G.createElementNS(g,"svg"),B=q.ownerDocument.createElementNS(q.namespaceURI,Y),X.forEach(([V,L])=>{B.setAttributeNS(null,V,L)}),q.appendChild(B);else q=B=G.createElement(Y);B.textContent=" ",G.body.appendChild(q);let M=j.getComputedStyle(B,Q),W=new Map;for(let V=M.length,L=0;L<V;L++){let P=M.item(L);if(JQ.includes(P))continue;W.set(P,M.getPropertyValue(P))}return G.body.removeChild(q),$.set(K,W),W}function Hz(z,Q,Z){let $=new Map,Y=[],J=new Map;if(Z)for(let K of Z)X(K);else for(let K=z.length,H=0;H<K;H++){let j=z.item(H);X(j)}for(let K=Y.length,H=0;H<K;H++)J.get(Y[H])?.forEach((j,G)=>$.set(G,j));function X(K){let H=z.getPropertyValue(K),j=z.getPropertyPriority(K),G=K.lastIndexOf("-"),q=G>-1?K.substring(0,G):void 0;if(q){let B=J.get(q);if(!B)B=new Map,J.set(q,B);B.set(K,[H,j])}if(Q.get(K)===H&&!j)return;if(q)Y.push(q);else $.set(K,[H,j])}return $}function XQ(z,Q,Z,$){let{ownerWindow:Y,includeStyleProperties:J,currentParentNodeStyle:X}=$,K=Q.style,H=Y.getComputedStyle(z),j=Vz(z,null,$);X?.forEach((q,B)=>{j.delete(B)});let G=Hz(H,j,J);if(G.delete("transition-property"),G.delete("all"),G.delete("d"),G.delete("content"),Z)G.delete("position"),G.delete("margin-top"),G.delete("margin-right"),G.delete("margin-bottom"),G.delete("margin-left"),G.delete("margin-block-start"),G.delete("margin-block-end"),G.delete("margin-inline-start"),G.delete("margin-inline-end"),G.set("box-sizing",["border-box",""]);if(G.get("background-clip")?.[0]==="text")Q.classList.add("______background-clip--text");if(Zz){if(!G.has("font-kerning"))G.set("font-kerning",["normal",""]);if((G.get("overflow-x")?.[0]==="hidden"||G.get("overflow-y")?.[0]==="hidden")&&G.get("text-overflow")?.[0]==="ellipsis"&&z.scrollWidth===z.clientWidth)G.set("text-overflow",["clip",""])}for(let q=K.length,B=0;B<q;B++)K.removeProperty(K.item(B));return G.forEach(([q,B],M)=>{K.setProperty(M,q,B)}),G}function GQ(z,Q){if(Tz(z)||Sz(z)||gz(z))Q.setAttribute("value",z.value)}var jQ=["::before","::after"],VQ=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function HQ(z,Q,Z,$,Y){let{ownerWindow:J,svgStyleElement:X,svgStyles:K,currentNodeStyle:H}=$;if(!X||!J)return;function j(G){let q=J.getComputedStyle(z,G),B=q.getPropertyValue("content");if(!B||B==="none")return;Y?.(B),B=B.replace(/(')|(")|(counter\(.+\))/g,"");let M=[Yz()],W=Vz(z,G,$);H?.forEach((_,A)=>{W.delete(A)});let V=Hz(q,W,$.includeStyleProperties);if(V.delete("content"),V.delete("-webkit-locale"),V.get("background-clip")?.[0]==="text")Q.classList.add("______background-clip--text");let L=[`content: '${B}';`];if(V.forEach(([_,A],D)=>{L.push(`${D}: ${_}${A?" !important":""};`)}),L.length===1)return;try{Q.className=[Q.className,...M].join(" ")}catch(_){$.log.warn("Failed to copyPseudoClass",_);return}let P=L.join(`
7
+ `),O=K.get(P);if(!O)O=[],K.set(P,O);O.push(`.${M[0]}${G}`)}if(jQ.forEach(j),Z)VQ.forEach(j)}var l=new Set(["symbol"]);async function s(z,Q,Z,$,Y){if(k(Z)&&(bz(Z)||hz(Z)))return;if($.filter&&!$.filter(Z))return;if(l.has(Q.nodeName)||l.has(Z.nodeName))$.currentParentNodeStyle=void 0;else $.currentParentNodeStyle=$.currentNodeStyle;let J=await i(Z,$,!1,Y);if($.isEnable("restoreScrollPosition"))BQ(z,J);Q.appendChild(J)}async function a(z,Q,Z,$){let Y=z.firstChild;if(k(z)){if(z.shadowRoot)Y=z.shadowRoot?.firstChild,Z.shadowRoots.push(z.shadowRoot)}for(let J=Y;J;J=J.nextSibling){if(Ez(J))continue;if(k(J)&&vz(J)&&typeof J.assignedNodes==="function"){let X=J.assignedNodes();for(let K=0;K<X.length;K++)await s(z,Q,X[K],Z,$)}else await s(z,Q,J,Z,$)}}function BQ(z,Q){if(!R(z)||!R(Q))return;let{scrollTop:Z,scrollLeft:$}=z;if(!Z&&!$)return;let{transform:Y}=Q.style,J=new DOMMatrix(Y),{a:X,b:K,c:H,d:j}=J;J.a=1,J.b=0,J.c=0,J.d=1,J.translateSelf(-$,-Z),J.a=X,J.b=K,J.c=H,J.d=j,Q.style.transform=J.toString()}function MQ(z,Q){let{backgroundColor:Z,width:$,height:Y,style:J}=Q,X=z.style;if(Z)X.setProperty("background-color",Z,"important");if($)X.setProperty("width",`${$}px`,"important");if(Y)X.setProperty("height",`${Y}px`,"important");if(J)for(let K in J)X[K]=J[K]}var qQ=/^[\w-:]+$/;async function i(z,Q,Z=!1,$){let{ownerDocument:Y,ownerWindow:J,fontFamilies:X,onCloneEachNode:K}=Q;if(Y&&Cz(z)){if($&&/\S/.test(z.data))$(z.data);return Y.createTextNode(z.data)}if(Y&&J&&k(z)&&(R(z)||C(z))){let j=await $Q(z,Q);if(Q.isEnable("removeAbnormalAttributes")){let V=j.getAttributeNames();for(let L=V.length,P=0;P<L;P++){let O=V[P];if(!qQ.test(O))j.removeAttribute(O)}}let G=Q.currentNodeStyle=XQ(z,j,Z,Q);if(Z)MQ(j,Q);let q=!1;if(Q.isEnable("copyScrollbar")){let V=[G.get("overflow-x")?.[0],G.get("overflow-y")?.[0]];q=V.includes("scroll")||(V.includes("auto")||V.includes("overlay"))&&(z.scrollHeight>z.clientHeight||z.scrollWidth>z.clientWidth)}let B=G.get("text-transform")?.[0],M=Xz(G.get("font-family")?.[0]),W=M?(V)=>{if(B==="uppercase")V=V.toUpperCase();else if(B==="lowercase")V=V.toLowerCase();else if(B==="capitalize")V=V[0].toUpperCase()+V.substring(1);M.forEach((L)=>{let P=X.get(L);if(!P)X.set(L,P=new Set);V.split("").forEach((O)=>P.add(O))})}:void 0;if(HQ(z,j,q,Q,W),GQ(z,j),!b(z))await a(z,j,Q,W);return await K?.(j),j}let H=z.cloneNode(!1);return await a(z,H,Q),await K?.(H),H}function WQ(z){if(z.ownerDocument=void 0,z.ownerWindow=void 0,z.svgStyleElement=void 0,z.svgDefsElement=void 0,z.svgStyles.clear(),z.defaultComputedStyles.clear(),z.sandbox){try{z.sandbox.remove()}catch(Q){z.log.warn("Failed to destroyContext",Q)}z.sandbox=void 0}z.workers=[],z.fontFamilies.clear(),z.fontCssTexts.clear(),z.requests.clear(),z.tasks=[],z.shadowRoots=[]}function LQ(z){let{url:Q,timeout:Z,responseType:$,...Y}=z,J=new AbortController,X=Z?setTimeout(()=>J.abort(),Z):void 0;return fetch(Q,{signal:J.signal,...Y}).then((K)=>{if(!K.ok)throw Error("Failed fetch, not 2xx response",{cause:K});switch($){case"arrayBuffer":return K.arrayBuffer();case"dataUrl":return K.blob().then(dz);case"text":default:return K.text()}}).finally(()=>clearTimeout(X))}function E(z,Q){let{url:Z,requestType:$="text",responseType:Y="text",imageDom:J}=Q,X=Z,{timeout:K,acceptOfImage:H,requests:j,fetchFn:G,fetch:{requestInit:q,bypassingCache:B,placeholderImage:M},font:W,workers:V,fontFamilies:L}=z;if($==="image"&&(S||x))z.drawImageCount++;let P=j.get(Z);if(!P){if(B){if(B instanceof RegExp&&B.test(X))X+=(/\?/.test(X)?"&":"?")+new Date().getTime()}let O=$.startsWith("font")&&W&&W.minify,_=new Set;if(O)$.split(";")[1].split(",").forEach((T)=>{if(!L.has(T))return;L.get(T).forEach((c)=>_.add(c))});let A=O&&_.size,D={url:X,timeout:K,responseType:A?"arrayBuffer":Y,headers:$==="image"?{accept:H}:void 0,...q};P={type:$,resolve:void 0,reject:void 0,response:null},P.response=(async()=>{if(G&&$==="image"){let N=await G(Z);if(N)return N}if(!S&&Z.startsWith("http")&&V.length)return new Promise((N,T)=>{V[j.size&V.length-1].postMessage({rawUrl:Z,...D}),P.resolve=N,P.reject=T});return LQ(D)})().catch((N)=>{if(j.delete(Z),$==="image"&&M)return z.log.warn("Failed to fetch image base64, trying to use placeholder image",X),typeof M==="string"?M:M(J);throw N}),j.set(Z,P)}return P.response}async function Bz(z,Q,Z,$){if(!Mz(z))return z;for(let[Y,J]of PQ(z,Q))try{let X=await E(Z,{url:J,requestType:$?"image":"text",responseType:"dataUrl"});z=z.replace(OQ(Y),`$1${X}$3`)}catch(X){Z.log.warn("Failed to fetch css data url",Y,X)}return z}function Mz(z){return/url\((['"]?)([^'"]+?)\1\)/.test(z)}var qz=/url\((['"]?)([^'"]+?)\1\)/g;function PQ(z,Q){let Z=[];return z.replace(qz,($,Y,J)=>{return Z.push([J,Kz(J,Q)]),$}),Z.filter(([$])=>!p($))}function OQ(z){let Q=z.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${Q})(['"]?\\))`,"g")}var _Q=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function AQ(z,Q){return _Q.map((Z)=>{let $=z.getPropertyValue(Z);if(!$||$==="none")return null;if(S||x)Q.drawImageCount++;return Bz($,null,Q,!0).then((Y)=>{if(!Y||$===Y)return;z.setProperty(Z,Y,z.getPropertyPriority(Z))})}).filter(Boolean)}function kQ(z,Q){if(F(z)){let Z=z.currentSrc||z.src;if(!p(Z))return[E(Q,{url:Z,imageDom:z,requestType:"image",responseType:"dataUrl"}).then(($)=>{if(!$)return;z.srcset="",z.dataset.originalSrc=Z,z.src=$||""})];if(S||x)Q.drawImageCount++}else if(C(z)&&!p(z.href.baseVal)){let Z=z.href.baseVal;return[E(Q,{url:Z,imageDom:z,requestType:"image",responseType:"dataUrl"}).then(($)=>{if(!$)return;z.dataset.originalSrc=Z,z.href.baseVal=$||""})]}return[]}function NQ(z,Q){let{ownerDocument:Z,svgDefsElement:$}=Q,Y=z.getAttribute("href")??z.getAttribute("xlink:href");if(!Y)return[];let[J,X]=Y.split("#");if(X){let K=`#${X}`,H=Q.shadowRoots.reduce((j,G)=>{return j??G.querySelector(`svg ${K}`)},Z?.querySelector(`svg ${K}`));if(J)z.setAttribute("href",K);if($?.querySelector(K))return[];if(H)return $?.appendChild(H.cloneNode(!0)),[];else if(J)return[E(Q,{url:J,responseType:"text"}).then((j)=>{$?.insertAdjacentHTML("beforeend",j)})]}return[]}function Wz(z,Q){let{tasks:Z}=Q;if(k(z)){if(F(z)||$z(z))Z.push(...kQ(z,Q));if(wz(z))Z.push(...NQ(z,Q))}if(R(z))Z.push(...AQ(z.style,Q));z.childNodes.forEach(($)=>{Wz($,Q)})}async function DQ(z,Q){let{ownerDocument:Z,svgStyleElement:$,fontFamilies:Y,fontCssTexts:J,tasks:X,font:K}=Q;if(!Z||!$||!Y.size)return;if(K&&K.cssText){let H=n(K.cssText,Q);$.appendChild(Z.createTextNode(`${H}
8
+ `))}else{let H=Array.from(Z.styleSheets).filter((M)=>{try{return"cssRules"in M&&Boolean(M.cssRules.length)}catch(W){return Q.log.warn(`Error while reading CSS rules from ${M.href}`,W),!1}}),j=Z.implementation.createHTMLDocument(""),G=j.createElement("style");j.head.appendChild(G);let q=G.sheet;if(await Promise.all(H.flatMap((M)=>{return Array.from(M.cssRules).map(async(W)=>{if(Fz(W)){let V=W.href,L="";try{L=await E(Q,{url:V,requestType:"text",responseType:"text"})}catch(O){Q.log.warn(`Error fetch remote css import from ${V}`,O)}let P=L.replace(qz,(O,_,A)=>O.replace(A,Kz(A,V)));for(let O of UQ(P))try{q.insertRule(O,q.cssRules.length)}catch(_){Q.log.warn("Error inserting rule from remote css import",{rule:O,error:_})}}})})),q.cssRules.length)H.push(q);let B=[];H.forEach((M)=>{y(M.cssRules,B)}),B.filter((M)=>Uz(M)&&Mz(M.style.getPropertyValue("src"))&&Xz(M.style.getPropertyValue("font-family"))?.some((W)=>Y.has(W))).forEach((M)=>{let W=M,V=J.get(W.cssText);if(V)$.appendChild(Z.createTextNode(`${V}
9
+ `));else X.push(Bz(W.cssText,W.parentStyleSheet?W.parentStyleSheet.href:null,Q).then((L)=>{L=n(L,Q),J.set(W.cssText,L),$.appendChild(Z.createTextNode(`${L}
10
+ `))}))})}}var IQ=/(\/\*[\s\S]*?\*\/)/g,r=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function UQ(z){if(z==null)return[];let Q=[],Z=z.replace(IQ,"");while(!0){let J=r.exec(Z);if(!J)break;Q.push(J[0])}Z=Z.replace(r,"");let $=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,Y=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");while(!0){let J=$.exec(Z);if(!J)if(J=Y.exec(Z),!J)break;else $.lastIndex=Y.lastIndex;else Y.lastIndex=$.lastIndex;Q.push(J[0])}return Q}var FQ=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,RQ=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function n(z,Q){let{font:Z}=Q,$=Z?Z?.preferredFormat:void 0;return $?z.replace(RQ,(Y)=>{while(!0){let[J,,X]=FQ.exec(Y)||[];if(!X)return"";if(X===$)return`src: ${J};`}}):z}function y(z,Q=[]){for(let Z of Array.from(z))if(Rz(Z))Q.push(...y(Z.cssRules));else if("cssRules"in Z)y(Z.cssRules,Q);else Q.push(Z);return Q}var wQ=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function EQ(z){return wQ.test(z.innerHTML)}async function CQ(z,Q){let Z=await u(z,Q);if(k(Z.node)&&C(Z.node)&&!EQ(Z.node))return Z.node;let{ownerDocument:$,log:Y,tasks:J,svgStyleElement:X,svgDefsElement:K,svgStyles:H,font:j,progress:G,autoDestruct:q,onCloneNode:B,onEmbedNode:M,onCreateForeignObjectSvg:W}=Z;Y.time("clone node");let V=await i(Z.node,Z,!0);if(X&&$){let A="";H.forEach((D,N)=>{A+=`${D.join(`,
11
+ `)} {
12
+ ${N}
13
+ }
14
+ `}),X.appendChild($.createTextNode(A))}if(Y.timeEnd("clone node"),await B?.(V),j!==!1&&k(V))Y.time("embed web font"),await DQ(V,Z),Y.timeEnd("embed web font");Y.time("embed node"),Wz(V,Z);let L=J.length,P=0,O=async()=>{while(!0){let A=J.pop();if(!A)break;try{await A}catch(D){Z.log.warn("Failed to run task",D)}G?.(++P,L)}};G?.(P,L),await Promise.all([...Array.from({length:4})].map(O)),Y.timeEnd("embed node"),await M?.(V);let _=fQ(V,Z);return K&&_.insertBefore(K,_.children[0]),X&&_.insertBefore(X,_.children[0]),q&&WQ(Z),await W?.(_),_}function fQ(z,Q){let{width:Z,height:$}=Q,Y=xz(Z,$,z.ownerDocument),J=Y.ownerDocument.createElementNS(Y.namespaceURI,"foreignObject");return J.setAttributeNS(null,"x","0%"),J.setAttributeNS(null,"y","0%"),J.setAttributeNS(null,"width","100%"),J.setAttributeNS(null,"height","100%"),J.append(z),Y.appendChild(J),Y}async function TQ(z,Q){let Z=await u(z,Q),$=await CQ(Z),Y=uz($,Z.isEnable("removeControlCharacter"));if(!Z.autoDestruct)Z.svgStyleElement=Gz(Z.ownerDocument),Z.svgDefsElement=Z.ownerDocument?.createElementNS(g,"defs"),Z.svgStyles.clear();let J=U(Y,$.ownerDocument);return await tz(J,Z)}async function Lz(z,Q){let Z=await u(z,Q),{log:$,type:Y,quality:J,dpi:X}=Z,K=await TQ(Z);$.time("canvas to blob");let H=await iz(K,Y,J);if(["image/png","image/jpeg"].includes(Y)&&X){let j=await lz(H.slice(0,33)),G=new Uint8Array(j);if(Y==="image/png")G=kz(G,X);else if(Y==="image/jpeg")G=Pz(G,X);return $.timeEnd("canvas to blob"),new Blob([G,H.slice(33)],{type:Y})}return $.timeEnd("canvas to blob"),H}var SQ=3000,bQ=document.currentScript,f=bQ?.dataset.board??"";if(f&&window.parent!==window){addEventListener("message",(Q)=>{if(Q.origin!==f||Q.source!==window.parent)return;let Z=Q.data;if(Z?.type!=="volter-board-picture"||typeof Z.id!=="string")return;let $=Z.id;gQ().then((Y)=>window.parent.postMessage({type:"volter-board-picture",id:$,image:Y},f),(Y)=>window.parent.postMessage({type:"volter-board-picture",id:$,error:Y instanceof Error?Y.message:String(Y)},f))});let z=()=>window.parent.postMessage({type:"volter-board-picture-ready"},f);if(document.readyState==="complete")z();else addEventListener("load",z,{once:!0})}function hQ(){let z=(Q)=>Q===""||Q==="transparent"||/^rgba\(.*,\s*0\)$/.test(Q);for(let Q of[document.documentElement,document.body]){let Z=Q?getComputedStyle(Q).backgroundColor:"";if(!z(Z))return Z}return"#ffffff"}async function gQ(){await document.fonts?.ready;let z=await Lz(document.documentElement,{width:innerWidth,height:innerHeight,scale:1,type:"image/jpeg",quality:0.8,timeout:SQ,backgroundColor:hQ()});if(!z)throw Error("the page drew no picture");return z}
@@ -0,0 +1,50 @@
1
+ // THE BOARD'S PICTURES (served-world.ts, BOARD_PICTURE_SCRIPT): a page a World's board shows draws its own picture when
2
+ // the board asks, since a page cannot photograph another origin's. The World adds this script to the pages a console
3
+ // frames, from the page's own origin, naming that console; it answers that console alone, with what the page shows at
4
+ // the top of its window, as a JPEG. Pictures are taken in the viewer's browser while the page runs, one page at a time,
5
+ // and kept by the World against the frame's change (the console's useBoardPictures).
6
+ import { domToBlob } from 'modern-screenshot';
7
+
8
+ /** How long a picture waits for one of the page's resources (an image, a font) before drawing without it: a page whose
9
+ * image never answers is drawn as it stands, not after the library's own half minute. */
10
+ const RESOURCE_WAIT_MS = 3000;
11
+
12
+ const script = document.currentScript as HTMLScriptElement | null;
13
+ const board = script?.dataset.board ?? '';
14
+
15
+ type Ask = { type?: unknown; id?: unknown };
16
+
17
+ if (board && window.parent !== window) {
18
+ addEventListener('message', (event: MessageEvent) => {
19
+ if (event.origin !== board || event.source !== window.parent) return;
20
+ const ask = event.data as Ask;
21
+ if (ask?.type !== 'volter-board-picture' || typeof ask.id !== 'string') return;
22
+ const id = ask.id;
23
+ void picture().then(
24
+ (image) => window.parent.postMessage({ type: 'volter-board-picture', id, image }, board),
25
+ (error: unknown) => window.parent.postMessage({ type: 'volter-board-picture', id, error: error instanceof Error ? error.message : String(error) }, board),
26
+ );
27
+ });
28
+ // the board asks once the page has loaded
29
+ const ready = (): void => window.parent.postMessage({ type: 'volter-board-picture-ready' }, board);
30
+ if (document.readyState === 'complete') ready();
31
+ else addEventListener('load', ready, { once: true });
32
+ }
33
+
34
+ /** The colour behind the page, as the browser paints it: the root's or the body's background, else the canvas's white. A
35
+ * JPEG has no transparency, so a page that leaves its background unset would otherwise be drawn on black. */
36
+ function backdrop(): string {
37
+ const unset = (c: string): boolean => c === '' || c === 'transparent' || /^rgba\(.*,\s*0\)$/.test(c);
38
+ for (const element of [document.documentElement, document.body]) {
39
+ const colour = element ? getComputedStyle(element).backgroundColor : '';
40
+ if (!unset(colour)) return colour;
41
+ }
42
+ return '#ffffff';
43
+ }
44
+
45
+ async function picture(): Promise<Blob> {
46
+ await document.fonts?.ready;
47
+ const image = await domToBlob(document.documentElement, { width: innerWidth, height: innerHeight, scale: 1, type: 'image/jpeg', quality: 0.8, timeout: RESOURCE_WAIT_MS, backgroundColor: backdrop() });
48
+ if (!image) throw new Error('the page drew no picture');
49
+ return image;
50
+ }
@@ -58,7 +58,8 @@ export type MountedWorld = {
58
58
  readToken: string;
59
59
  };
60
60
  handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */
61
- site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */
61
+ site: (request: Request, host: string) => Promise<Response>; /** the same site at its local address, its framed pages carrying the board's picture script (localSite) */
62
+ localSite: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */
62
63
  pagesPass: (scope: 'read' | 'write', land: string, grant?: {
63
64
  who?: string;
64
65
  framer?: string;
@@ -277,6 +278,16 @@ export declare class WorldDoors {
277
278
  private keepCookiesHere;
278
279
  /** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
279
280
  private pagesPlane;
281
+ /** THE BOARD'S PICTURES' SCRIPT (BOARD_PICTURE_SCRIPT), built from ../client/board-picture.tsx. */
282
+ private boardPictureScript;
283
+ /** A page a console frames, with the board's picture script (BOARD_PICTURE_SCRIPT) naming that console: it draws the
284
+ * page's picture when that console asks, and answers no one else. Only an HTML document loaded in a frame; anything
285
+ * else is answered as it came. */
286
+ private withBoardPicture;
287
+ /** BOARD PICTURES (board/pictures?frame=<vendor>:<frame id>&revision=<its change>): a frame's picture as a viewer's
288
+ * browser took it of the frame's page, kept against the frame's change. GET answers the picture of that change, or
289
+ * 404 while there is none; PUT keeps one (a JPEG, at most BOARD_PICTURE_BYTES) in place of the frame's earlier ones. */
290
+ private boardPicture;
280
291
  /** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
281
292
  * not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
282
293
  * keeps them; each lasts 30 days from its opening. */
@@ -407,6 +418,10 @@ export declare class WorldDoors {
407
418
  /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
408
419
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
409
420
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
421
+ /** A site at its local address (`<hostname>.<world>--<org>.localhost`, the World view's and the twins host's): read as
422
+ * site() reads it, and a page of it this World's console frames carries the board's picture script, served from the
423
+ * site's own origin and naming that console (THE BOARD'S PICTURES). */
424
+ localSite(request: Request, host: string): Promise<Response>;
410
425
  site(request: Request, host: string, browser?: string, grant?: PagesGrant): Promise<Response>;
411
426
  handle(request: Request): Promise<Response>;
412
427
  /** A twin's workspaces as its discovery door lists them (`GET /twin`'s `screens`: each built workspace screen's id and
@@ -31,7 +31,7 @@ import { boardFrameKey, getActiveWorldStore, withHistoryLocks, captureHistory, h
31
31
  // to `.volter/token` and `.volter/token.read` (0600).
32
32
  // One writer per world by construction: the doors run in this process, serialized per twin.
33
33
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
34
- import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, TWIN_SITE_HEADER, siteUrlOf, ORIGINAL_PATH_HEADER, validateRemoteOrigin } from '@volter/world-core/runtime';
34
+ import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, TWIN_SITE_HEADER, siteUrlOf, ORIGINAL_PATH_HEADER, validateRemoteOrigin, bundleClient, filePathOf } from '@volter/world-core/runtime';
35
35
  import { resolveTwinHost } from '@volter/world-core/host-routing';
36
36
  import { assertBrowserName, validateBrowserState, browserAcceptLanguage, cookieMatchesHost } from '@volter/world-core';
37
37
  import { worldBrowser, worldBrowsers, putWorldBrowser, deleteWorldBrowser, materializeBrowserFiles, browserLoad, putBrowserLoad, browserLoadId } from "./browsers.js";
@@ -113,6 +113,22 @@ export function boardFile(worldRoot) { return join(worldRoot, stateDirName(), 'b
113
113
  const BOARD_KEY = /^(?:[a-z0-9-]+|~browser:[a-z0-9-]+:[a-z0-9-]+):[^\s]{1,300}$/;
114
114
  /** The largest board document a World keeps. */
115
115
  const BOARD_BYTES = 256 * 1024;
116
+ /** THE BOARD'S PICTURES: the script a page the board shows draws its picture with (../client/board-picture.tsx), served
117
+ * at this path on the page's own origin, where a site's script-src 'self' takes it; and the most a kept picture is. */
118
+ const BOARD_PICTURE_SCRIPT = '/.well-known/volter/board-picture.js';
119
+ const BOARD_PICTURE_ENTRY = () => filePathOf(new URL('../client/board-picture.tsx', import.meta.url));
120
+ const BOARD_PICTURE_BYTES = 2 * 1024 * 1024;
121
+ /** A frame's request for a page, without the conditions a browser revalidates its copy with: a page a board shows is
122
+ * answered whole, with the board's picture script, never as "not modified" against a copy kept without it (a site's
123
+ * HTML names its asset's etag, and a browser asks again with it). */
124
+ const wholeForFrame = (request) => {
125
+ if (request.method !== 'GET' || request.headers.get('sec-fetch-dest') !== 'iframe' || !(request.headers.has('if-none-match') || request.headers.has('if-modified-since')))
126
+ return request;
127
+ const headers = new Headers(request.headers);
128
+ headers.delete('if-none-match');
129
+ headers.delete('if-modified-since');
130
+ return new Request(request, { headers });
131
+ };
116
132
  /** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
117
133
  /** A kept board document's version: its bytes' digest, or `none` before one is kept. */
118
134
  const boardVersion = (text) => (text === null ? 'none' : createHash('sha256').update(text).digest('hex').slice(0, 16));
@@ -236,6 +252,7 @@ export async function mountWorld(name, opts = {}) {
236
252
  rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
237
253
  handle: (request) => world.handle(request),
238
254
  site: (request, host) => world.site(request, host),
255
+ localSite: (request, host) => world.localSite(request, host),
239
256
  pagesPass: (scope, land, grant) => world.pagesPass(scope, land, grant),
240
257
  branchBoard: (frame) => world.branchBoard(frame),
241
258
  async boot(url) {
@@ -599,6 +616,9 @@ export class WorldDoors {
599
616
  const door = url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
600
617
  const opening = doors;
601
618
  const own = this.host.browserOrigin?.() ?? null;
619
+ // the script a page the board shows draws its picture with, from the page's own origin (THE BOARD'S PICTURES)
620
+ if (url.pathname === BOARD_PICTURE_SCRIPT && (request.method === 'GET' || request.method === 'HEAD'))
621
+ return await this.boardPictureScript(request);
602
622
  if (door === 'open' && request.method === 'GET') {
603
623
  // framed by this World's console, or its parent's (a comparison frames its branch's pages); the pages it opens are
604
624
  // framed only by the console that asked
@@ -674,7 +694,7 @@ export class WorldDoors {
674
694
  if (loading)
675
695
  return loading;
676
696
  }
677
- const answer = await this.site(request, site, browser, held);
697
+ const answer = await this.withBoardPicture(request, await this.site(wholeForFrame(request), site, browser, held), held.framer);
678
698
  const out = new Headers(answer.headers);
679
699
  // A document belongs to this live page session and browser state, not an earlier opening of its URL.
680
700
  if (out.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() === 'text/html')
@@ -684,12 +704,14 @@ export class WorldDoors {
684
704
  return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
685
705
  }
686
706
  if (url.pathname.startsWith(`/${this.served}/`) && !url.pathname.startsWith(`/${this.served}/.well-known/`)) {
687
- const answer = await this.wire(request, url, url.pathname.slice(`/${this.served}/`.length));
688
- if (answer.status === 101)
689
- return answer;
707
+ const wired = await this.wire(wholeForFrame(request), url, url.pathname.slice(`/${this.served}/`.length));
708
+ if (wired.status === 101)
709
+ return wired;
710
+ const session = this.pageSession(request, url);
711
+ const answer = await this.withBoardPicture(request, wired, session?.framer);
690
712
  const out = new Headers(answer.headers);
691
713
  // framed by the console whose session asked, and by the page itself
692
- const framer = this.pageSession(request, url)?.framer ?? own;
714
+ const framer = session?.framer ?? own;
693
715
  out.append('content-security-policy', `frame-ancestors 'self'${framer ? ` ${framer}` : ''}`);
694
716
  // a twin's own sign-in kept in the frame a console shows it in, which may be another site (each *.localhost is):
695
717
  // its cookies are partitioned by that console, as this plane's own session is
@@ -702,6 +724,87 @@ export class WorldDoors {
702
724
  }
703
725
  return Response.json({ error: `this origin shows ${this.served}'s pages only` }, { status: 404 });
704
726
  }
727
+ /** THE BOARD'S PICTURES' SCRIPT (BOARD_PICTURE_SCRIPT), built from ../client/board-picture.tsx. */
728
+ async boardPictureScript(request) {
729
+ try {
730
+ const body = await bundleClient(BOARD_PICTURE_ENTRY());
731
+ return new Response(request.method === 'HEAD' ? null : body, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache', 'x-content-type-options': 'nosniff' } });
732
+ }
733
+ catch (error) {
734
+ return Response.json({ error: `the board's picture script is not built: ${error instanceof Error ? error.message : String(error)}` }, { status: 500 });
735
+ }
736
+ }
737
+ /** A page a console frames, with the board's picture script (BOARD_PICTURE_SCRIPT) naming that console: it draws the
738
+ * page's picture when that console asks, and answers no one else. Only an HTML document loaded in a frame; anything
739
+ * else is answered as it came. */
740
+ async withBoardPicture(request, answer, framer) {
741
+ if (!framer || request.method !== 'GET' || request.headers.get('sec-fetch-dest') !== 'iframe' || answer.status !== 200 || !answer.body)
742
+ return answer;
743
+ const headers = new Headers(answer.headers);
744
+ if (!/^text\/html\b/i.test(headers.get('content-type') ?? '') || headers.has('content-encoding'))
745
+ return answer;
746
+ let boardOrigin;
747
+ try {
748
+ boardOrigin = new URL(framer).origin;
749
+ }
750
+ catch {
751
+ return answer;
752
+ }
753
+ const html = await answer.text();
754
+ const tag = `<script src="${BOARD_PICTURE_SCRIPT}" data-board="${boardOrigin.replace(/[&"<>]/g, '')}" async></script>`;
755
+ // in the head, where a parser reads it first; a page without one has it before its body, or after its doctype
756
+ const at = [/<\/head\s*>/i, /<body[\s>]/i].map((pattern) => html.search(pattern)).find((i) => i >= 0);
757
+ const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
758
+ const out = at !== undefined ? `${html.slice(0, at)}${tag}${html.slice(at)}` : doctype ? `${doctype[0]}${tag}${html.slice(doctype[0].length)}` : `${tag}${html}`;
759
+ // this answer is the page with the script: no validator of the page without it, and no copy kept
760
+ headers.delete('content-length');
761
+ headers.delete('etag');
762
+ headers.delete('last-modified');
763
+ headers.set('cache-control', 'private, no-store');
764
+ return new Response(out, { status: answer.status, statusText: answer.statusText, headers });
765
+ }
766
+ /** BOARD PICTURES (board/pictures?frame=<vendor>:<frame id>&revision=<its change>): a frame's picture as a viewer's
767
+ * browser took it of the frame's page, kept against the frame's change. GET answers the picture of that change, or
768
+ * 404 while there is none; PUT keeps one (a JPEG, at most BOARD_PICTURE_BYTES) in place of the frame's earlier ones. */
769
+ async boardPicture(request, url) {
770
+ const frame = url.searchParams.get('frame') ?? '';
771
+ const revision = url.searchParams.get('revision') ?? '';
772
+ if (!frame || !revision)
773
+ return Response.json({ error: 'board/pictures?frame=<vendor>:<frame id>&revision=<its change>' }, { status: 400 });
774
+ const hash = (text) => createHash('sha256').update(text).digest('hex');
775
+ const dir = join(this.worldRoot, stateDirName(), 'board-pictures', hash(frame));
776
+ const key = join(dir, `${hash(revision)}.jpg`);
777
+ const blobs = getActiveBlobStore();
778
+ if (request.method === 'GET') {
779
+ const bytes = await blobs.get(key);
780
+ return bytes ? new Response(new Uint8Array(bytes), { headers: { 'content-type': 'image/jpeg', 'cache-control': 'private, no-cache' } }) : Response.json({ error: 'no picture of this change yet' }, { status: 404 });
781
+ }
782
+ if (!/^image\/jpeg\b/i.test(request.headers.get('content-type') ?? ''))
783
+ return Response.json({ error: 'a board picture is sent as image/jpeg' }, { status: 415 });
784
+ // read as it arrives, never past the cap (a chunked body names no length)
785
+ const chunks = [];
786
+ let size = 0;
787
+ const reader = request.body?.getReader();
788
+ for (let part = await reader?.read(); part && !part.done; part = await reader.read()) {
789
+ size += part.value.byteLength;
790
+ if (size > BOARD_PICTURE_BYTES) {
791
+ await reader.cancel();
792
+ return Response.json({ error: `a board picture is at most ${BOARD_PICTURE_BYTES} bytes` }, { status: 413 });
793
+ }
794
+ chunks.push(part.value);
795
+ }
796
+ if (size === 0)
797
+ return Response.json({ error: 'a board picture has bytes' }, { status: 400 });
798
+ const bytes = new Uint8Array(size);
799
+ let at = 0;
800
+ for (const chunk of chunks) {
801
+ bytes.set(chunk, at);
802
+ at += chunk.byteLength;
803
+ }
804
+ await blobs.remove(`${dir}/`);
805
+ await blobs.put(key, bytes);
806
+ return new Response(null, { status: 204 });
807
+ }
705
808
  /** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
706
809
  * not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
707
810
  * keeps them; each lasts 30 days from its opening. */
@@ -1209,6 +1312,14 @@ export class WorldDoors {
1209
1312
  /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
1210
1313
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
1211
1314
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
1315
+ /** A site at its local address (`<hostname>.<world>--<org>.localhost`, the World view's and the twins host's): read as
1316
+ * site() reads it, and a page of it this World's console frames carries the board's picture script, served from the
1317
+ * site's own origin and naming that console (THE BOARD'S PICTURES). */
1318
+ async localSite(request, host) {
1319
+ if (new URL(request.url).pathname === BOARD_PICTURE_SCRIPT && (request.method === 'GET' || request.method === 'HEAD'))
1320
+ return await this.boardPictureScript(request);
1321
+ return await this.withBoardPicture(request, await this.site(wholeForFrame(request), host), this.host.browserOrigin?.() ?? undefined);
1322
+ }
1212
1323
  async site(request, host, browser, grant) {
1213
1324
  request = withoutBrowserHeader(request);
1214
1325
  if (!browser && request.method !== 'GET' && request.method !== 'HEAD')
@@ -1992,6 +2103,8 @@ export class WorldDoors {
1992
2103
  return Response.json(await this.board(url));
1993
2104
  if (kind === 'board' && name === 'changes' && !verb && request.method === 'GET')
1994
2105
  return await this.boardChanges(url);
2106
+ if (kind === 'board' && name === 'pictures' && !verb && (request.method === 'GET' || request.method === 'PUT'))
2107
+ return await this.boardPicture(request, url);
1995
2108
  if (kind === 'board' && !name && request.method === 'PUT') {
1996
2109
  if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? ''))
1997
2110
  return Response.json({ error: 'the board is sent as application/json' }, { status: 415 });
@@ -2266,7 +2379,7 @@ export class WorldDoors {
2266
2379
  }
2267
2380
  if (kind === 'changesets' && !name && request.method === 'POST') {
2268
2381
  const body = (await request.json().catch(() => ({})));
2269
- const slug = body.message ? body.message.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) : '';
2382
+ const slug = body.message ? body.message.split('\n')[0].toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) : '';
2270
2383
  // with no summary to name it, the next free `changeset-<n>`, as the command names one
2271
2384
  const taken = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset.name));
2272
2385
  let n = taken.size + 1;
@@ -122,7 +122,7 @@ export async function serveWorldView(name, opts = {}) {
122
122
  // a site the World's twins answer, under the World's local origin (`<hostname>.<world>--<org>.localhost`)
123
123
  const site = origins && !proxied ? siteOfHost(at.hostname, worlds.keys()) : undefined;
124
124
  if (site)
125
- return !site.site.includes('.') && site.site.includes('--') ? worlds.get(site.world).handle(request) : worlds.get(site.world).site(request, site.site);
125
+ return !site.site.includes('.') && site.site.includes('--') ? worlds.get(site.world).handle(request) : worlds.get(site.world).localSite(request, site.site);
126
126
  // a World's content plane serves that World's twins' pages and nothing else of this view's (no console)
127
127
  const pagesFor = origins && !proxied ? pagesOfHost(at.hostname, worlds.keys()) : undefined;
128
128
  if (pagesFor)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.157",
3
+ "version": "3.0.158",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -70,21 +70,22 @@
70
70
  "node": ">=22.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@volter/world-core": "3.0.149",
74
- "@volter/world-console": "3.0.149"
73
+ "@volter/world-core": "3.0.150",
74
+ "@volter/world-console": "3.0.150"
75
75
  },
76
76
  "dependencies": {
77
77
  "@electric-sql/pglite": "0.5.8",
78
78
  "@electric-sql/pglite-pgvector": "0.0.9",
79
+ "@volter/browser-host-node": "0.1.84",
80
+ "@volter/browser-runtime": "0.1.104",
81
+ "@volter/browser-sandboxes": "0.1.84",
79
82
  "@volter/world-access": "3.0.1",
80
- "@volter/world-core": "3.0.149",
83
+ "@volter/world-core": "3.0.150",
84
+ "modern-screenshot": "4.7.0",
81
85
  "mongodb": "~6.20.0",
82
86
  "pg-gateway": "0.3.0-beta.4",
83
87
  "smol-toml": "^1.8.0",
84
- "yaml": "2.9.0",
85
- "@volter/browser-host-node": "0.1.84",
86
- "@volter/browser-sandboxes": "0.1.84",
87
- "@volter/browser-runtime": "0.1.104"
88
+ "yaml": "2.9.0"
88
89
  },
89
90
  "peerDependenciesMeta": {
90
91
  "@volter/world-console": {
@@ -31,7 +31,7 @@ import { boardFrameKey, getActiveWorldStore, withHistoryLocks, captureHistory, h
31
31
  // to `.volter/token` and `.volter/token.read` (0600).
32
32
  // One writer per world by construction: the doors run in this process, serialized per twin.
33
33
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
34
- import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, TWIN_SITE_HEADER, siteLabel, siteUrlOf, ORIGINAL_PATH_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink, type BoardFrame } from '@volter/world-core/runtime';
34
+ import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, TWIN_SITE_HEADER, siteLabel, siteUrlOf, ORIGINAL_PATH_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink, type BoardFrame, bundleClient, filePathOf } from '@volter/world-core/runtime';
35
35
  import { resolveTwinHost, type HostPredicate } from '@volter/world-core/host-routing';
36
36
  import { assertBrowserName, validateBrowserState, browserAcceptLanguage, cookieMatchesHost } from '@volter/world-core';
37
37
  import { worldBrowser, worldBrowsers, putWorldBrowser, deleteWorldBrowser, materializeBrowserFiles, browserLoad, putBrowserLoad, browserLoadId } from './browsers.ts';
@@ -108,6 +108,21 @@ type SavedBoard = { id: string; name: string; include?: string[]; hidden?: strin
108
108
  const BOARD_KEY = /^(?:[a-z0-9-]+|~browser:[a-z0-9-]+:[a-z0-9-]+):[^\s]{1,300}$/;
109
109
  /** The largest board document a World keeps. */
110
110
  const BOARD_BYTES = 256 * 1024;
111
+ /** THE BOARD'S PICTURES: the script a page the board shows draws its picture with (../client/board-picture.tsx), served
112
+ * at this path on the page's own origin, where a site's script-src 'self' takes it; and the most a kept picture is. */
113
+ const BOARD_PICTURE_SCRIPT = '/.well-known/volter/board-picture.js';
114
+ const BOARD_PICTURE_ENTRY = (): string => filePathOf(new URL('../client/board-picture.tsx', import.meta.url));
115
+ const BOARD_PICTURE_BYTES = 2 * 1024 * 1024;
116
+ /** A frame's request for a page, without the conditions a browser revalidates its copy with: a page a board shows is
117
+ * answered whole, with the board's picture script, never as "not modified" against a copy kept without it (a site's
118
+ * HTML names its asset's etag, and a browser asks again with it). */
119
+ const wholeForFrame = (request: Request): Request => {
120
+ if (request.method !== 'GET' || request.headers.get('sec-fetch-dest') !== 'iframe' || !(request.headers.has('if-none-match') || request.headers.has('if-modified-since'))) return request;
121
+ const headers = new Headers(request.headers);
122
+ headers.delete('if-none-match');
123
+ headers.delete('if-modified-since');
124
+ return new Request(request, { headers });
125
+ };
111
126
  /** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
112
127
  /** A kept board document's version: its bytes' digest, or `none` before one is kept. */
113
128
  const boardVersion = (text: string | null): string => (text === null ? 'none' : createHash('sha256').update(text).digest('hex').slice(0, 16));
@@ -179,7 +194,7 @@ function configRefFor(worldRoot: string, name: string): string {
179
194
  /** A world mounted by a host. `token`/`readToken` read the CURRENT tokens (rotate changes them);
180
195
  * `twins()` names the world's twins once booted; `rotate()` mints both tokens anew — the old die
181
196
  * with the call — and keeps them beside the world like `boot` does. */
182
- export type MountedWorld = { maxRequestBodySize: number; name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */ pagesPass: (scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => string | null; branchBoard: (frame?: string) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
197
+ export type MountedWorld = { maxRequestBodySize: number; name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; /** the same site at its local address, its framed pages carrying the board's picture script (localSite) */ localSite: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */ pagesPass: (scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => string | null; branchBoard: (frame?: string) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
183
198
  /** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
184
199
  /** What a content-plane pass, and the session it opens, carry: its scope; the control session that asked (the content
185
200
  * session ends with it; none when the World's token asked); the person; the console that frames it; and whether it
@@ -244,6 +259,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
244
259
  rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
245
260
  handle: (request) => world.handle(request),
246
261
  site: (request, host) => world.site(request, host),
262
+ localSite: (request, host) => world.localSite(request, host),
247
263
  pagesPass: (scope, land, grant) => world.pagesPass(scope, land, grant),
248
264
  branchBoard: (frame) => world.branchBoard(frame),
249
265
  async boot(url) {
@@ -582,6 +598,8 @@ export class WorldDoors {
582
598
  const door = url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
583
599
  const opening = doors;
584
600
  const own = this.host.browserOrigin?.() ?? null;
601
+ // the script a page the board shows draws its picture with, from the page's own origin (THE BOARD'S PICTURES)
602
+ if (url.pathname === BOARD_PICTURE_SCRIPT && (request.method === 'GET' || request.method === 'HEAD')) return await this.boardPictureScript(request);
585
603
  if (door === 'open' && request.method === 'GET') {
586
604
  // framed by this World's console, or its parent's (a comparison frames its branch's pages); the pages it opens are
587
605
  // framed only by the console that asked
@@ -654,7 +672,7 @@ export class WorldDoors {
654
672
  const loading = this.loadBrowser(request, url, site, browser);
655
673
  if (loading) return loading;
656
674
  }
657
- const answer = await this.site(request, site, browser, held);
675
+ const answer = await this.withBoardPicture(request, await this.site(wholeForFrame(request), site, browser, held), held.framer);
658
676
  const out = new Headers(answer.headers);
659
677
  // A document belongs to this live page session and browser state, not an earlier opening of its URL.
660
678
  if (out.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() === 'text/html') out.set('cache-control', 'private, no-store');
@@ -663,11 +681,13 @@ export class WorldDoors {
663
681
  return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
664
682
  }
665
683
  if (url.pathname.startsWith(`/${this.served}/`) && !url.pathname.startsWith(`/${this.served}/.well-known/`)) {
666
- const answer = await this.wire(request, url, url.pathname.slice(`/${this.served}/`.length));
667
- if (answer.status === 101) return answer;
684
+ const wired = await this.wire(wholeForFrame(request), url, url.pathname.slice(`/${this.served}/`.length));
685
+ if (wired.status === 101) return wired;
686
+ const session = this.pageSession(request, url);
687
+ const answer = await this.withBoardPicture(request, wired, session?.framer);
668
688
  const out = new Headers(answer.headers);
669
689
  // framed by the console whose session asked, and by the page itself
670
- const framer = this.pageSession(request, url)?.framer ?? own;
690
+ const framer = session?.framer ?? own;
671
691
  out.append('content-security-policy', `frame-ancestors 'self'${framer ? ` ${framer}` : ''}`);
672
692
  // a twin's own sign-in kept in the frame a console shows it in, which may be another site (each *.localhost is):
673
693
  // its cookies are partitioned by that console, as this plane's own session is
@@ -681,6 +701,69 @@ export class WorldDoors {
681
701
  return Response.json({ error: `this origin shows ${this.served}'s pages only` }, { status: 404 });
682
702
  }
683
703
 
704
+ /** THE BOARD'S PICTURES' SCRIPT (BOARD_PICTURE_SCRIPT), built from ../client/board-picture.tsx. */
705
+ private async boardPictureScript(request: Request): Promise<Response> {
706
+ try {
707
+ const body = await bundleClient(BOARD_PICTURE_ENTRY());
708
+ return new Response(request.method === 'HEAD' ? null : body, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache', 'x-content-type-options': 'nosniff' } });
709
+ } catch (error) {
710
+ return Response.json({ error: `the board's picture script is not built: ${error instanceof Error ? error.message : String(error)}` }, { status: 500 });
711
+ }
712
+ }
713
+
714
+ /** A page a console frames, with the board's picture script (BOARD_PICTURE_SCRIPT) naming that console: it draws the
715
+ * page's picture when that console asks, and answers no one else. Only an HTML document loaded in a frame; anything
716
+ * else is answered as it came. */
717
+ private async withBoardPicture(request: Request, answer: Response, framer: string | undefined): Promise<Response> {
718
+ if (!framer || request.method !== 'GET' || request.headers.get('sec-fetch-dest') !== 'iframe' || answer.status !== 200 || !answer.body) return answer;
719
+ const headers = new Headers(answer.headers);
720
+ if (!/^text\/html\b/i.test(headers.get('content-type') ?? '') || headers.has('content-encoding')) return answer;
721
+ let boardOrigin: string;
722
+ try { boardOrigin = new URL(framer).origin; } catch { return answer; }
723
+ const html = await answer.text();
724
+ const tag = `<script src="${BOARD_PICTURE_SCRIPT}" data-board="${boardOrigin.replace(/[&"<>]/g, '')}" async></script>`;
725
+ // in the head, where a parser reads it first; a page without one has it before its body, or after its doctype
726
+ const at = [/<\/head\s*>/i, /<body[\s>]/i].map((pattern) => html.search(pattern)).find((i) => i >= 0);
727
+ const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
728
+ const out = at !== undefined ? `${html.slice(0, at)}${tag}${html.slice(at)}` : doctype ? `${doctype[0]}${tag}${html.slice(doctype[0].length)}` : `${tag}${html}`;
729
+ // this answer is the page with the script: no validator of the page without it, and no copy kept
730
+ headers.delete('content-length'); headers.delete('etag'); headers.delete('last-modified');
731
+ headers.set('cache-control', 'private, no-store');
732
+ return new Response(out, { status: answer.status, statusText: answer.statusText, headers });
733
+ }
734
+
735
+ /** BOARD PICTURES (board/pictures?frame=<vendor>:<frame id>&revision=<its change>): a frame's picture as a viewer's
736
+ * browser took it of the frame's page, kept against the frame's change. GET answers the picture of that change, or
737
+ * 404 while there is none; PUT keeps one (a JPEG, at most BOARD_PICTURE_BYTES) in place of the frame's earlier ones. */
738
+ private async boardPicture(request: Request, url: URL): Promise<Response> {
739
+ const frame = url.searchParams.get('frame') ?? '';
740
+ const revision = url.searchParams.get('revision') ?? '';
741
+ if (!frame || !revision) return Response.json({ error: 'board/pictures?frame=<vendor>:<frame id>&revision=<its change>' }, { status: 400 });
742
+ const hash = (text: string): string => createHash('sha256').update(text).digest('hex');
743
+ const dir = join(this.worldRoot, stateDirName(), 'board-pictures', hash(frame));
744
+ const key = join(dir, `${hash(revision)}.jpg`);
745
+ const blobs = getActiveBlobStore();
746
+ if (request.method === 'GET') {
747
+ const bytes = await blobs.get(key);
748
+ return bytes ? new Response(new Uint8Array(bytes), { headers: { 'content-type': 'image/jpeg', 'cache-control': 'private, no-cache' } }) : Response.json({ error: 'no picture of this change yet' }, { status: 404 });
749
+ }
750
+ if (!/^image\/jpeg\b/i.test(request.headers.get('content-type') ?? '')) return Response.json({ error: 'a board picture is sent as image/jpeg' }, { status: 415 });
751
+ // read as it arrives, never past the cap (a chunked body names no length)
752
+ const chunks: Uint8Array[] = []; let size = 0;
753
+ const reader = request.body?.getReader();
754
+ for (let part = await reader?.read(); part && !part.done; part = await reader!.read()) {
755
+ size += part.value.byteLength;
756
+ if (size > BOARD_PICTURE_BYTES) { await reader!.cancel(); return Response.json({ error: `a board picture is at most ${BOARD_PICTURE_BYTES} bytes` }, { status: 413 }); }
757
+ chunks.push(part.value);
758
+ }
759
+ if (size === 0) return Response.json({ error: 'a board picture has bytes' }, { status: 400 });
760
+ const bytes = new Uint8Array(size); let at = 0;
761
+ for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.byteLength; }
762
+ await blobs.remove(`${dir}/`);
763
+ await blobs.put(key, bytes);
764
+ return new Response(null, { status: 204 });
765
+ }
766
+
684
767
  /** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
685
768
  * not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
686
769
  * keeps them; each lasts 30 days from its opening. */
@@ -1086,6 +1169,14 @@ export class WorldDoors {
1086
1169
  /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
1087
1170
  * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
1088
1171
  * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
1172
+ /** A site at its local address (`<hostname>.<world>--<org>.localhost`, the World view's and the twins host's): read as
1173
+ * site() reads it, and a page of it this World's console frames carries the board's picture script, served from the
1174
+ * site's own origin and naming that console (THE BOARD'S PICTURES). */
1175
+ async localSite(request: Request, host: string): Promise<Response> {
1176
+ if (new URL(request.url).pathname === BOARD_PICTURE_SCRIPT && (request.method === 'GET' || request.method === 'HEAD')) return await this.boardPictureScript(request);
1177
+ return await this.withBoardPicture(request, await this.site(wholeForFrame(request), host), this.host.browserOrigin?.() ?? undefined);
1178
+ }
1179
+
1089
1180
  async site(request: Request, host: string, browser?: string, grant?: PagesGrant): Promise<Response> {
1090
1181
  request = withoutBrowserHeader(request);
1091
1182
  if (!browser && request.method !== 'GET' && request.method !== 'HEAD') return Response.json({ error: 'a site in a World is read: GET and HEAD' }, { status: 405 });
@@ -1698,6 +1789,7 @@ export class WorldDoors {
1698
1789
  if (kind === 'map' && !name && request.method === 'GET') return Response.json(await this.map());
1699
1790
  if (kind === 'board' && !name && request.method === 'GET') return Response.json(await this.board(url));
1700
1791
  if (kind === 'board' && name === 'changes' && !verb && request.method === 'GET') return await this.boardChanges(url);
1792
+ if (kind === 'board' && name === 'pictures' && !verb && (request.method === 'GET' || request.method === 'PUT')) return await this.boardPicture(request, url);
1701
1793
  if (kind === 'board' && !name && request.method === 'PUT') {
1702
1794
  if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? '')) return Response.json({ error: 'the board is sent as application/json' }, { status: 415 });
1703
1795
  if (Number(request.headers.get('content-length') ?? '0') > BOARD_BYTES) return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 });
@@ -1911,7 +2003,7 @@ export class WorldDoors {
1911
2003
  }
1912
2004
  if (kind === 'changesets' && !name && request.method === 'POST') {
1913
2005
  const body = (await request.json().catch(() => ({}))) as { name?: string; message?: string };
1914
- const slug = body.message ? body.message.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) : '';
2006
+ const slug = body.message ? body.message.split('\n')[0]!.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) : '';
1915
2007
  // with no summary to name it, the next free `changeset-<n>`, as the command names one
1916
2008
  const taken = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset.name));
1917
2009
  let n = taken.size + 1; while (taken.has(`changeset-${n}`)) n += 1;
package/src/world-view.ts CHANGED
@@ -110,7 +110,7 @@ export async function serveWorldView(name: string, opts: { /** the person signed
110
110
  // a World's own origin serves that World (and the ping and the console) and no other
111
111
  // a site the World's twins answer, under the World's local origin (`<hostname>.<world>--<org>.localhost`)
112
112
  const site = origins && !proxied ? siteOfHost(at.hostname, worlds.keys()) : undefined;
113
- if (site) return !site.site.includes('.') && site.site.includes('--') ? worlds.get(site.world)!.handle(request) : worlds.get(site.world)!.site(request, site.site);
113
+ if (site) return !site.site.includes('.') && site.site.includes('--') ? worlds.get(site.world)!.handle(request) : worlds.get(site.world)!.localSite(request, site.site);
114
114
  // a World's content plane serves that World's twins' pages and nothing else of this view's (no console)
115
115
  const pagesFor = origins && !proxied ? pagesOfHost(at.hostname, worlds.keys()) : undefined;
116
116
  if (pagesFor) return path === '/-/ping' ? Response.json({ ok: true, worlds: [pagesFor] }) : worlds.get(pagesFor)!.handle(request);