@volter/world-runtime 3.0.157 → 3.0.159
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/board-picture.tsx +50 -0
- package/dist/client/board-picture.bundle.js +14 -0
- package/dist/client/board-picture.tsx +50 -0
- package/dist/src/served-world.d.ts +16 -1
- package/dist/src/served-world.js +120 -7
- package/dist/src/world-view.js +1 -1
- package/package.json +9 -8
- package/src/served-world.ts +99 -7
- package/src/world-view.ts +1 -1
|
@@ -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>; /**
|
|
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
|
package/dist/src/served-world.js
CHANGED
|
@@ -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
|
|
688
|
-
if (
|
|
689
|
-
return
|
|
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 =
|
|
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;
|
package/dist/src/world-view.js
CHANGED
|
@@ -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).
|
|
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.
|
|
3
|
+
"version": "3.0.159",
|
|
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.
|
|
74
|
-
"@volter/world-console": "3.0.
|
|
73
|
+
"@volter/world-core": "3.0.151",
|
|
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.
|
|
83
|
+
"@volter/world-core": "3.0.151",
|
|
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": {
|
package/src/served-world.ts
CHANGED
|
@@ -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
|
|
667
|
-
if (
|
|
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 =
|
|
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)!.
|
|
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);
|