@replohq/sandbox-runtime 1.8.0 → 1.9.0
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/PageCaptureResponder.d.ts +1 -0
- package/PageCaptureResponder.js +12 -0
- package/_vendor/replo-utils/lib/crypto.mjs +1 -0
- package/_vendor/replo-utils/lib/math.mjs +1 -1
- package/_vendor/replo-utils/lib/pageCapture.d.ts +34 -0
- package/_vendor/replo-utils/lib/pageCapture.mjs +52 -0
- package/_vendor/schemas/iframeRpcChannel.mjs +1 -1
- package/_vendor/schemas/selectMode.d.ts +2 -2
- package/_vendor/schemas/selectModeIframeRpc.d.ts +4 -4
- package/buildMetadata.js +3 -3
- package/capturePage.d.ts +11 -0
- package/capturePage.js +261 -0
- package/chunk-LIPH4O4F.mjs +67 -0
- package/package.json +5 -4
- package/sandbox-runtime.js +2 -0
- package/startPageCaptureResponder.d.ts +32 -0
- package/startPageCaptureResponder.js +87 -0
- package/chunk-SGKQ5HGT.mjs +0 -7
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function PageCaptureResponder(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { startPageCaptureResponder } from "./startPageCaptureResponder";
|
|
4
|
+
function startResponder() {
|
|
5
|
+
return startPageCaptureResponder(window);
|
|
6
|
+
}
|
|
7
|
+
function PageCaptureResponder() {
|
|
8
|
+
return /* @__PURE__ */ jsx("template", { ref: startResponder });
|
|
9
|
+
}
|
|
10
|
+
export {
|
|
11
|
+
PageCaptureResponder
|
|
12
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { base64UrlToBytes, bytesToBase64Url, constantTimeEqual, getHmacSha256Hex, randomBase64Url, randomUUID, sha256Hex } from '../../../chunk-LIPH4O4F.mjs';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Site Builder asks the cross-origin preview iframe to draw its own first screen; the site's runtime replies with a JPEG. */
|
|
2
|
+
export declare const PAGE_CAPTURE_REQUEST = "replo:page-capture:request";
|
|
3
|
+
export declare const PAGE_CAPTURE_RESPONSE = "replo:page-capture:response";
|
|
4
|
+
export interface PageCaptureRequestMessage {
|
|
5
|
+
type: typeof PAGE_CAPTURE_REQUEST;
|
|
6
|
+
requestId: string;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
}
|
|
10
|
+
export type PageCaptureResponseMessage = {
|
|
11
|
+
type: typeof PAGE_CAPTURE_RESPONSE;
|
|
12
|
+
requestId: string;
|
|
13
|
+
jpeg: ArrayBuffer;
|
|
14
|
+
ms: number;
|
|
15
|
+
} | {
|
|
16
|
+
type: typeof PAGE_CAPTURE_RESPONSE;
|
|
17
|
+
requestId: string;
|
|
18
|
+
jpeg: null;
|
|
19
|
+
error: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function isPageCaptureRequestMessage(value: unknown): value is PageCaptureRequestMessage;
|
|
22
|
+
export declare class PageCaptureError extends Error {
|
|
23
|
+
}
|
|
24
|
+
/** Parent side: ask `frame` to capture itself and resolve with its JPEG bytes. */
|
|
25
|
+
export declare function requestPageCapture({ frame, targetOrigin, width, height, timeoutMs, }: {
|
|
26
|
+
frame: Window;
|
|
27
|
+
targetOrigin: string;
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
}): Promise<{
|
|
32
|
+
jpeg: ArrayBuffer;
|
|
33
|
+
ms: number;
|
|
34
|
+
}>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { randomUUID } from '../../../chunk-LIPH4O4F.mjs';
|
|
2
|
+
|
|
3
|
+
var PAGE_CAPTURE_REQUEST = "replo:page-capture:request";
|
|
4
|
+
var PAGE_CAPTURE_RESPONSE = "replo:page-capture:response";
|
|
5
|
+
function isPageCaptureRequestMessage(value) {
|
|
6
|
+
return typeof value === "object" && value !== null && "type" in value && value.type === PAGE_CAPTURE_REQUEST && "requestId" in value && typeof value.requestId === "string" && "width" in value && typeof value.width === "number" && "height" in value && typeof value.height === "number";
|
|
7
|
+
}
|
|
8
|
+
function isPageCaptureResponseMessage(value, requestId) {
|
|
9
|
+
return typeof value === "object" && value !== null && "type" in value && value.type === PAGE_CAPTURE_RESPONSE && "requestId" in value && value.requestId === requestId;
|
|
10
|
+
}
|
|
11
|
+
var PageCaptureError = class extends Error {
|
|
12
|
+
};
|
|
13
|
+
function requestPageCapture({
|
|
14
|
+
frame,
|
|
15
|
+
targetOrigin,
|
|
16
|
+
width,
|
|
17
|
+
height,
|
|
18
|
+
timeoutMs = 15e3
|
|
19
|
+
}) {
|
|
20
|
+
const requestId = randomUUID();
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const onMessage = (event) => {
|
|
23
|
+
if (event.source !== frame || !isPageCaptureResponseMessage(event.data, requestId)) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
cleanup();
|
|
27
|
+
if (event.data.jpeg) {
|
|
28
|
+
resolve({ jpeg: event.data.jpeg, ms: event.data.ms });
|
|
29
|
+
} else {
|
|
30
|
+
reject(new PageCaptureError(event.data.error));
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const timeout = setTimeout(() => {
|
|
34
|
+
cleanup();
|
|
35
|
+
reject(new PageCaptureError("capture timed out"));
|
|
36
|
+
}, timeoutMs);
|
|
37
|
+
const cleanup = () => {
|
|
38
|
+
clearTimeout(timeout);
|
|
39
|
+
window.removeEventListener("message", onMessage);
|
|
40
|
+
};
|
|
41
|
+
window.addEventListener("message", onMessage);
|
|
42
|
+
const request = {
|
|
43
|
+
type: PAGE_CAPTURE_REQUEST,
|
|
44
|
+
requestId,
|
|
45
|
+
width,
|
|
46
|
+
height
|
|
47
|
+
};
|
|
48
|
+
frame.postMessage(request, targetOrigin);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { PAGE_CAPTURE_REQUEST, PAGE_CAPTURE_RESPONSE, PageCaptureError, isPageCaptureRequestMessage, requestPageCapture };
|
|
@@ -389,9 +389,9 @@ export declare const selectModeFormatCommandSchema: z.ZodEnum<{
|
|
|
389
389
|
* Renaming or adding a variant here is a TS error in all three.
|
|
390
390
|
*/
|
|
391
391
|
export declare const selectModeSelectionTypeSchema: z.ZodEnum<{
|
|
392
|
-
element: "element";
|
|
393
|
-
area: "area";
|
|
394
392
|
drag: "drag";
|
|
393
|
+
area: "area";
|
|
394
|
+
element: "element";
|
|
395
395
|
none: "none";
|
|
396
396
|
}>;
|
|
397
397
|
/**
|
|
@@ -342,9 +342,9 @@ export declare const websiteBuilderToPreviewIframeMethodConfig: {
|
|
|
342
342
|
setSelectionType: {
|
|
343
343
|
inputSchema: z.ZodObject<{
|
|
344
344
|
selectionType: z.ZodEnum<{
|
|
345
|
-
element: "element";
|
|
346
|
-
area: "area";
|
|
347
345
|
drag: "drag";
|
|
346
|
+
area: "area";
|
|
347
|
+
element: "element";
|
|
348
348
|
none: "none";
|
|
349
349
|
}>;
|
|
350
350
|
selectors: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -862,9 +862,9 @@ export declare const selectModeParentMessageSchema: z.ZodDiscriminatedUnion<[z.Z
|
|
|
862
862
|
method: z.ZodLiteral<"setSelectionType">;
|
|
863
863
|
data: z.ZodObject<{
|
|
864
864
|
selectionType: z.ZodEnum<{
|
|
865
|
-
element: "element";
|
|
866
|
-
area: "area";
|
|
867
865
|
drag: "drag";
|
|
866
|
+
area: "area";
|
|
867
|
+
element: "element";
|
|
868
868
|
none: "none";
|
|
869
869
|
}>;
|
|
870
870
|
selectors: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
package/buildMetadata.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const BUILD_METADATA = {
|
|
2
2
|
"packageName": "@replohq/sandbox-runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"branch": "HEAD",
|
|
5
|
-
"commit": "
|
|
6
|
-
"builtAt": "2026-09-
|
|
5
|
+
"commit": "455eef2903f4cc6a8f457d5ec77be1e1dc95aad9",
|
|
6
|
+
"builtAt": "2026-09-25T00:24:57.880Z"
|
|
7
7
|
};
|
package/capturePage.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function parseYouTubeId(src: string): string | null;
|
|
2
|
+
export declare function parseVimeoId(src: string): string | null;
|
|
3
|
+
/**
|
|
4
|
+
* Draws the page's first screen (from the document top, regardless of scroll)
|
|
5
|
+
* into an exact width×height JPEG. Videos and YouTube/Vimeo embeds, which the
|
|
6
|
+
* page cannot read directly, are swapped for their current frame or thumbnail.
|
|
7
|
+
*/
|
|
8
|
+
export declare function capturePage({ width, height, }: {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}): Promise<Blob>;
|
package/capturePage.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { snapdom } from "@zumer/snapdom";
|
|
2
|
+
import { PageCaptureError } from "./_vendor/replo-utils/lib/pageCapture.mjs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
const JPEG_QUALITY = 0.8;
|
|
5
|
+
const VIDEO_FRAME_QUALITY = 0.85;
|
|
6
|
+
const VIDEO_LOAD_TIMEOUT_MS = 4e3;
|
|
7
|
+
const VIDEO_SEEK_TIMEOUT_MS = 1500;
|
|
8
|
+
const PLACEHOLDER_BACKGROUND = "#d9d9d6";
|
|
9
|
+
const IFRAME_PLACEHOLDER_MARKER = "repeating-linear-gradient(45deg";
|
|
10
|
+
const vimeoOembedSchema = z.object({ thumbnail_url: z.string() });
|
|
11
|
+
function parseYouTubeId(src) {
|
|
12
|
+
const match = src.match(
|
|
13
|
+
/(?:youtube(?:-nocookie)?\.com\/embed\/|youtu\.be\/)([\w-]{11})/
|
|
14
|
+
);
|
|
15
|
+
return match?.[1] ?? null;
|
|
16
|
+
}
|
|
17
|
+
function parseVimeoId(src) {
|
|
18
|
+
const match = src.match(/player\.vimeo\.com\/video\/(\d+)/);
|
|
19
|
+
return match?.[1] ?? null;
|
|
20
|
+
}
|
|
21
|
+
function canReadIframeDocument(iframe) {
|
|
22
|
+
try {
|
|
23
|
+
return Boolean(iframe.contentDocument ?? iframe.contentWindow?.document);
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function fetchAsDataUrl(url) {
|
|
29
|
+
const blob = await (await fetch(url)).blob();
|
|
30
|
+
return await new Promise((resolve, reject) => {
|
|
31
|
+
const reader = new FileReader();
|
|
32
|
+
reader.addEventListener("load", () => {
|
|
33
|
+
resolve(String(reader.result));
|
|
34
|
+
});
|
|
35
|
+
reader.addEventListener("error", () => {
|
|
36
|
+
reject(reader.error ?? new PageCaptureError("image read failed"));
|
|
37
|
+
});
|
|
38
|
+
reader.readAsDataURL(blob);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function isCanvasReadable(source) {
|
|
42
|
+
try {
|
|
43
|
+
const canvas = document.createElement("canvas");
|
|
44
|
+
canvas.width = 1;
|
|
45
|
+
canvas.height = 1;
|
|
46
|
+
canvas.getContext("2d")?.drawImage(source, 0, 0, 1, 1);
|
|
47
|
+
canvas.toDataURL();
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function videoFrameDataUrl(video) {
|
|
54
|
+
const canvas = document.createElement("canvas");
|
|
55
|
+
canvas.width = video.videoWidth;
|
|
56
|
+
canvas.height = video.videoHeight;
|
|
57
|
+
const context = canvas.getContext("2d");
|
|
58
|
+
if (!context) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
context.drawImage(video, 0, 0);
|
|
62
|
+
return canvas.toDataURL("image/jpeg", VIDEO_FRAME_QUALITY);
|
|
63
|
+
}
|
|
64
|
+
function isOnScreen({
|
|
65
|
+
element,
|
|
66
|
+
height
|
|
67
|
+
}) {
|
|
68
|
+
const rect = element.getBoundingClientRect();
|
|
69
|
+
return rect.bottom > 0 && rect.top < height && rect.width > 0;
|
|
70
|
+
}
|
|
71
|
+
async function readVideoFrame({
|
|
72
|
+
video,
|
|
73
|
+
height
|
|
74
|
+
}) {
|
|
75
|
+
if (!isOnScreen({ element: video, height })) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && isCanvasReadable(video)) {
|
|
79
|
+
return videoFrameDataUrl(video);
|
|
80
|
+
}
|
|
81
|
+
const copy = document.createElement("video");
|
|
82
|
+
copy.crossOrigin = "anonymous";
|
|
83
|
+
copy.muted = true;
|
|
84
|
+
copy.preload = "auto";
|
|
85
|
+
try {
|
|
86
|
+
copy.src = video.currentSrc || video.src;
|
|
87
|
+
await new Promise((resolve, reject) => {
|
|
88
|
+
copy.addEventListener("loadeddata", () => {
|
|
89
|
+
resolve();
|
|
90
|
+
});
|
|
91
|
+
copy.addEventListener("error", () => {
|
|
92
|
+
reject(new PageCaptureError("video load failed"));
|
|
93
|
+
});
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
reject(new PageCaptureError("video load timed out"));
|
|
96
|
+
}, VIDEO_LOAD_TIMEOUT_MS);
|
|
97
|
+
});
|
|
98
|
+
if (video.currentTime > 0.1) {
|
|
99
|
+
copy.currentTime = video.currentTime;
|
|
100
|
+
await new Promise((resolve) => {
|
|
101
|
+
copy.addEventListener("seeked", () => {
|
|
102
|
+
resolve();
|
|
103
|
+
});
|
|
104
|
+
setTimeout(resolve, VIDEO_SEEK_TIMEOUT_MS);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return isCanvasReadable(copy) ? videoFrameDataUrl(copy) : null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
} finally {
|
|
111
|
+
copy.removeAttribute("src");
|
|
112
|
+
copy.load();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function readIframeThumbnail(iframe) {
|
|
116
|
+
const src = iframe.src;
|
|
117
|
+
let url = null;
|
|
118
|
+
const youTubeId = parseYouTubeId(src);
|
|
119
|
+
if (youTubeId) {
|
|
120
|
+
url = `https://i.ytimg.com/vi/${youTubeId}/hqdefault.jpg`;
|
|
121
|
+
}
|
|
122
|
+
const vimeoId = parseVimeoId(src);
|
|
123
|
+
if (vimeoId) {
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(
|
|
126
|
+
`https://vimeo.com/api/oembed.json?url=https://vimeo.com/${vimeoId}&width=1280`
|
|
127
|
+
);
|
|
128
|
+
const body = vimeoOembedSchema.safeParse(await response.json());
|
|
129
|
+
url = body.success ? body.data.thumbnail_url : null;
|
|
130
|
+
} catch {
|
|
131
|
+
url = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!url) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return fetchAsDataUrl(url).catch(() => null);
|
|
138
|
+
}
|
|
139
|
+
function createStandInsPlugin({
|
|
140
|
+
objectFitByFrame,
|
|
141
|
+
iframeThumbnails
|
|
142
|
+
}) {
|
|
143
|
+
return {
|
|
144
|
+
name: "replo-page-capture-stand-ins",
|
|
145
|
+
afterClone(context) {
|
|
146
|
+
const clone = context.clone;
|
|
147
|
+
if (!clone) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const image of clone.querySelectorAll("img")) {
|
|
151
|
+
const objectFit = objectFitByFrame.get(image.getAttribute("src") ?? "");
|
|
152
|
+
if (objectFit) {
|
|
153
|
+
image.style.objectFit = objectFit;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const placeholders = Array.from(
|
|
157
|
+
clone.querySelectorAll("div")
|
|
158
|
+
).filter((element) => {
|
|
159
|
+
return (element.getAttribute("style") ?? "").includes(
|
|
160
|
+
IFRAME_PLACEHOLDER_MARKER
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
placeholders.forEach((placeholder, index) => {
|
|
164
|
+
const thumbnail = iframeThumbnails[index];
|
|
165
|
+
placeholder.style.backgroundImage = thumbnail ? `url("${thumbnail}")` : "none";
|
|
166
|
+
placeholder.style.backgroundColor = PLACEHOLDER_BACKGROUND;
|
|
167
|
+
placeholder.style.backgroundSize = "cover";
|
|
168
|
+
placeholder.style.backgroundPosition = "center";
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function pageBackgroundColor() {
|
|
174
|
+
for (const element of [document.body, document.documentElement]) {
|
|
175
|
+
const color = getComputedStyle(element).backgroundColor;
|
|
176
|
+
if (color && color !== "transparent" && color !== "rgba(0, 0, 0, 0)") {
|
|
177
|
+
return color;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return "#fff";
|
|
181
|
+
}
|
|
182
|
+
function canvasToJpeg(canvas) {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
canvas.toBlob(
|
|
185
|
+
(blob) => {
|
|
186
|
+
if (blob) {
|
|
187
|
+
resolve(blob);
|
|
188
|
+
} else {
|
|
189
|
+
reject(new PageCaptureError("jpeg encode failed"));
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
"image/jpeg",
|
|
193
|
+
JPEG_QUALITY
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async function capturePage({
|
|
198
|
+
width,
|
|
199
|
+
height
|
|
200
|
+
}) {
|
|
201
|
+
const videos = Array.from(document.querySelectorAll("video"));
|
|
202
|
+
const iframes = Array.from(document.querySelectorAll("iframe")).filter(
|
|
203
|
+
(iframe) => !canReadIframeDocument(iframe)
|
|
204
|
+
);
|
|
205
|
+
const [videoFrames, iframeThumbnails] = await Promise.all([
|
|
206
|
+
Promise.all(
|
|
207
|
+
videos.map((video) => {
|
|
208
|
+
return readVideoFrame({ video, height });
|
|
209
|
+
})
|
|
210
|
+
),
|
|
211
|
+
Promise.all(iframes.map(readIframeThumbnail))
|
|
212
|
+
]);
|
|
213
|
+
const originalPosters = [];
|
|
214
|
+
const objectFitByFrame = /* @__PURE__ */ new Map();
|
|
215
|
+
videos.forEach((video, index) => {
|
|
216
|
+
const frame = videoFrames[index];
|
|
217
|
+
if (!frame) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
originalPosters.push({ video, poster: video.getAttribute("poster") });
|
|
221
|
+
video.setAttribute("poster", frame);
|
|
222
|
+
objectFitByFrame.set(frame, getComputedStyle(video).objectFit);
|
|
223
|
+
});
|
|
224
|
+
try {
|
|
225
|
+
const raw = await snapdom.toRaw(document.body, {
|
|
226
|
+
scale: 1,
|
|
227
|
+
embedFonts: true,
|
|
228
|
+
plugins: [createStandInsPlugin({ objectFitByFrame, iframeThumbnails })]
|
|
229
|
+
});
|
|
230
|
+
const image = new Image();
|
|
231
|
+
image.src = raw;
|
|
232
|
+
await image.decode();
|
|
233
|
+
await new Promise((resolve) => {
|
|
234
|
+
setTimeout(resolve, 100);
|
|
235
|
+
});
|
|
236
|
+
const canvas = document.createElement("canvas");
|
|
237
|
+
canvas.width = width;
|
|
238
|
+
canvas.height = height;
|
|
239
|
+
const context = canvas.getContext("2d");
|
|
240
|
+
if (!context) {
|
|
241
|
+
throw new PageCaptureError("canvas 2d context unavailable");
|
|
242
|
+
}
|
|
243
|
+
context.fillStyle = pageBackgroundColor();
|
|
244
|
+
context.fillRect(0, 0, width, height);
|
|
245
|
+
context.drawImage(image, 0, 0);
|
|
246
|
+
return await canvasToJpeg(canvas);
|
|
247
|
+
} finally {
|
|
248
|
+
for (const { video, poster } of originalPosters) {
|
|
249
|
+
if (poster === null) {
|
|
250
|
+
video.removeAttribute("poster");
|
|
251
|
+
} else {
|
|
252
|
+
video.setAttribute("poster", poster);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
export {
|
|
258
|
+
capturePage,
|
|
259
|
+
parseVimeoId,
|
|
260
|
+
parseYouTubeId
|
|
261
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { v4 } from 'uuid';
|
|
2
|
+
|
|
3
|
+
function bytesToBase64Url(bytes) {
|
|
4
|
+
let binary = "";
|
|
5
|
+
for (const byte of bytes) {
|
|
6
|
+
binary += String.fromCharCode(byte);
|
|
7
|
+
}
|
|
8
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
9
|
+
}
|
|
10
|
+
function base64UrlToBytes(input) {
|
|
11
|
+
const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
|
|
12
|
+
const padded = normalized.padEnd(
|
|
13
|
+
normalized.length + (4 - normalized.length % 4) % 4,
|
|
14
|
+
"="
|
|
15
|
+
);
|
|
16
|
+
const binary = atob(padded);
|
|
17
|
+
const bytes = new Uint8Array(binary.length);
|
|
18
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
19
|
+
bytes[i] = binary.charCodeAt(i);
|
|
20
|
+
}
|
|
21
|
+
return bytes;
|
|
22
|
+
}
|
|
23
|
+
function randomBase64Url(byteLength) {
|
|
24
|
+
return bytesToBase64Url(crypto.getRandomValues(new Uint8Array(byteLength)));
|
|
25
|
+
}
|
|
26
|
+
function randomUUID() {
|
|
27
|
+
return v4();
|
|
28
|
+
}
|
|
29
|
+
async function sha256Hex(input) {
|
|
30
|
+
const buffer = typeof input === "string" ? new TextEncoder().encode(input) : input;
|
|
31
|
+
const hash = await crypto.subtle.digest("SHA-256", buffer);
|
|
32
|
+
return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
33
|
+
}
|
|
34
|
+
function constantTimeEqual({
|
|
35
|
+
left,
|
|
36
|
+
right
|
|
37
|
+
}) {
|
|
38
|
+
if (left.length !== right.length) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
let difference = 0;
|
|
42
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
43
|
+
difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
|
|
44
|
+
}
|
|
45
|
+
return difference === 0;
|
|
46
|
+
}
|
|
47
|
+
async function getHmacSha256Hex({
|
|
48
|
+
rawBody,
|
|
49
|
+
signingKey
|
|
50
|
+
}) {
|
|
51
|
+
const encoder = new TextEncoder();
|
|
52
|
+
const key = await crypto.subtle.importKey(
|
|
53
|
+
"raw",
|
|
54
|
+
encoder.encode(signingKey),
|
|
55
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
56
|
+
false,
|
|
57
|
+
["sign"]
|
|
58
|
+
);
|
|
59
|
+
const signature = await crypto.subtle.sign(
|
|
60
|
+
"HMAC",
|
|
61
|
+
key,
|
|
62
|
+
encoder.encode(rawBody)
|
|
63
|
+
);
|
|
64
|
+
return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { base64UrlToBytes, bytesToBase64Url, constantTimeEqual, getHmacSha256Hex, randomBase64Url, randomUUID, sha256Hex };
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@replohq/sandbox-runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"reploBuild": {
|
|
5
5
|
"packageName": "@replohq/sandbox-runtime",
|
|
6
|
-
"version": "1.
|
|
6
|
+
"version": "1.9.0",
|
|
7
7
|
"branch": "HEAD",
|
|
8
|
-
"commit": "
|
|
9
|
-
"builtAt": "2026-09-
|
|
8
|
+
"commit": "455eef2903f4cc6a8f457d5ec77be1e1dc95aad9",
|
|
9
|
+
"builtAt": "2026-09-25T00:24:57.880Z"
|
|
10
10
|
},
|
|
11
11
|
"description": "Replo sandbox runtime — editor integration for agent-built Next.js sites.",
|
|
12
12
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"@tiptap/extension-text": "^2.25.0",
|
|
41
41
|
"@tiptap/extension-underline": "^2.25.0",
|
|
42
42
|
"@tiptap/pm": "^2.25.0",
|
|
43
|
+
"@zumer/snapdom": "^3.0.0",
|
|
43
44
|
"uuid": "^11.1.0",
|
|
44
45
|
"zod": "^4.3.6",
|
|
45
46
|
"zustand": "^4.5.7"
|
package/sandbox-runtime.js
CHANGED
|
@@ -8,6 +8,7 @@ import { DocumentReadyBeacon } from "./DocumentReadyBeacon";
|
|
|
8
8
|
import LinkInterceptor from "./LinkInterceptor";
|
|
9
9
|
import { NavigationReceiver } from "./NavigationReceiver";
|
|
10
10
|
import { NextErrorReporter } from "./NextErrorReporter";
|
|
11
|
+
import { PageCaptureResponder } from "./PageCaptureResponder";
|
|
11
12
|
import PostHogRelay from "./posthog-relay";
|
|
12
13
|
import { EditorSelection } from "./select-mode/EditorSelection";
|
|
13
14
|
function ViewportSnapshotResponder() {
|
|
@@ -24,6 +25,7 @@ function SandboxRuntime({ children }) {
|
|
|
24
25
|
/* @__PURE__ */ jsx(NavigationReceiver, {}),
|
|
25
26
|
/* @__PURE__ */ jsx(PostHogRelay, {}),
|
|
26
27
|
/* @__PURE__ */ jsx(ViewportSnapshotResponder, {}),
|
|
28
|
+
/* @__PURE__ */ jsx(PageCaptureResponder, {}),
|
|
27
29
|
/* @__PURE__ */ jsx(EditorSelection, {}),
|
|
28
30
|
/* @__PURE__ */ jsx(AnnotationLayerMount, {}),
|
|
29
31
|
/* @__PURE__ */ jsx(DesignLibraryPreview, {}),
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PageCaptureResponseMessage } from "./_vendor/replo-utils/lib/pageCapture";
|
|
2
|
+
interface PageCaptureMessageListener {
|
|
3
|
+
(event: {
|
|
4
|
+
data: unknown;
|
|
5
|
+
source: unknown;
|
|
6
|
+
origin: string;
|
|
7
|
+
}): void;
|
|
8
|
+
}
|
|
9
|
+
/** The slice of `window` the responder touches, so specs can pass a fake. */
|
|
10
|
+
export interface PageCaptureWindow {
|
|
11
|
+
readonly innerWidth: number;
|
|
12
|
+
readonly parent: {
|
|
13
|
+
postMessage(message: PageCaptureResponseMessage, targetOrigin: string, transfer: Transferable[]): void;
|
|
14
|
+
};
|
|
15
|
+
readonly document: {
|
|
16
|
+
readonly fonts?: {
|
|
17
|
+
readonly ready: Promise<unknown>;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
requestIdleCallback?(callback: () => void, options: {
|
|
21
|
+
timeout: number;
|
|
22
|
+
}): number;
|
|
23
|
+
addEventListener(type: "message", listener: PageCaptureMessageListener): void;
|
|
24
|
+
removeEventListener(type: "message", listener: PageCaptureMessageListener): void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Answers Site Builder's page-capture requests with a JPEG of this page's
|
|
28
|
+
* first screen. Requests run one at a time so concurrent captures never
|
|
29
|
+
* fight over the page's temporary video posters.
|
|
30
|
+
*/
|
|
31
|
+
export declare function startPageCaptureResponder(targetWindow: PageCaptureWindow): () => void;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPageCaptureRequestMessage,
|
|
3
|
+
PAGE_CAPTURE_RESPONSE
|
|
4
|
+
} from "./_vendor/replo-utils/lib/pageCapture.mjs";
|
|
5
|
+
import { isTrustedViewportSnapshotRequester } from "./_vendor/replo-utils/lib/viewportSnapshot.mjs";
|
|
6
|
+
const IDLE_TIMEOUT_MS = 1e3;
|
|
7
|
+
function waitForIdle(targetWindow) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
if (targetWindow.requestIdleCallback) {
|
|
10
|
+
targetWindow.requestIdleCallback(
|
|
11
|
+
() => {
|
|
12
|
+
resolve();
|
|
13
|
+
},
|
|
14
|
+
{ timeout: IDLE_TIMEOUT_MS }
|
|
15
|
+
);
|
|
16
|
+
} else {
|
|
17
|
+
setTimeout(resolve, 0);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async function respondToRequest({
|
|
22
|
+
targetWindow,
|
|
23
|
+
request
|
|
24
|
+
}) {
|
|
25
|
+
const failure = (error) => {
|
|
26
|
+
return {
|
|
27
|
+
response: {
|
|
28
|
+
type: PAGE_CAPTURE_RESPONSE,
|
|
29
|
+
requestId: request.requestId,
|
|
30
|
+
jpeg: null,
|
|
31
|
+
error
|
|
32
|
+
},
|
|
33
|
+
transfer: []
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
if (targetWindow.innerWidth !== request.width) {
|
|
37
|
+
return failure("layout-width-mismatch");
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const startedAt = performance.now();
|
|
41
|
+
await waitForIdle(targetWindow);
|
|
42
|
+
await targetWindow.document.fonts?.ready;
|
|
43
|
+
const { capturePage } = await import("./capturePage");
|
|
44
|
+
const blob = await capturePage({
|
|
45
|
+
width: request.width,
|
|
46
|
+
height: request.height
|
|
47
|
+
});
|
|
48
|
+
const jpeg = await blob.arrayBuffer();
|
|
49
|
+
return {
|
|
50
|
+
response: {
|
|
51
|
+
type: PAGE_CAPTURE_RESPONSE,
|
|
52
|
+
requestId: request.requestId,
|
|
53
|
+
jpeg,
|
|
54
|
+
ms: Math.round(performance.now() - startedAt)
|
|
55
|
+
},
|
|
56
|
+
transfer: [jpeg]
|
|
57
|
+
};
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function startPageCaptureResponder(targetWindow) {
|
|
63
|
+
let queue = Promise.resolve();
|
|
64
|
+
const onMessage = (event) => {
|
|
65
|
+
if (event.source !== targetWindow.parent || !isPageCaptureRequestMessage(event.data) || !isTrustedViewportSnapshotRequester(event.origin)) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const request = event.data;
|
|
69
|
+
const requesterOrigin = event.origin;
|
|
70
|
+
queue = queue.then(async () => {
|
|
71
|
+
const { response, transfer } = await respondToRequest({
|
|
72
|
+
targetWindow,
|
|
73
|
+
request
|
|
74
|
+
});
|
|
75
|
+
targetWindow.parent.postMessage(response, requesterOrigin, transfer);
|
|
76
|
+
}).catch((error) => {
|
|
77
|
+
console.warn("[replo] page capture reply failed", error);
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
targetWindow.addEventListener("message", onMessage);
|
|
81
|
+
return () => {
|
|
82
|
+
targetWindow.removeEventListener("message", onMessage);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export {
|
|
86
|
+
startPageCaptureResponder
|
|
87
|
+
};
|