pi-studio 0.9.39 → 0.9.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,530 @@
1
+ (() => {
2
+ const PANE_FOCUS_PARAM = "paneFocus";
3
+ const PANE_FOCUS_OFF = "off";
4
+ const PANE_FOCUS_TARGETS = Object.freeze(["left", "right"]);
5
+ const STUDIO_LAUNCH_PROTOCOL_VERSION = 1;
6
+ const STUDIO_LAUNCH_CHANNEL_PREFIX = "pi-studio-launch-v1:";
7
+ const STUDIO_PENDING_KINDS = Object.freeze(["document", "preview", "export"]);
8
+ const STUDIO_LAUNCH_ID_PATTERN = /^[a-zA-Z0-9_-]{20,128}$/;
9
+ const STUDIO_LAUNCH_TARGET_MAX_CHARS = 16_384;
10
+ const STUDIO_LAUNCH_MESSAGE_MAX_CHARS = 1_000;
11
+ const STUDIO_LAUNCH_READY_TIMEOUT_MS = 3_000;
12
+ const STUDIO_LAUNCH_DELIVERY_TIMEOUT_MS = 15_000;
13
+ const STUDIO_PENDING_STILL_WAITING_MS = 15_000;
14
+
15
+ function normalizePaneFocusTarget(value) {
16
+ return value === "left" || value === "right" ? value : PANE_FOCUS_OFF;
17
+ }
18
+
19
+ function readPaneFocusTarget(locationLike) {
20
+ try {
21
+ const search = locationLike && typeof locationLike.search === "string"
22
+ ? locationLike.search
23
+ : "";
24
+ return normalizePaneFocusTarget(new URLSearchParams(search).get(PANE_FOCUS_PARAM));
25
+ } catch {
26
+ return PANE_FOCUS_OFF;
27
+ }
28
+ }
29
+
30
+ function buildPaneFocusUrl(href, target) {
31
+ const url = new URL(String(href || ""));
32
+ const normalized = normalizePaneFocusTarget(target);
33
+ if (normalized === PANE_FOCUS_OFF) {
34
+ url.searchParams.delete(PANE_FOCUS_PARAM);
35
+ } else {
36
+ url.searchParams.set(PANE_FOCUS_PARAM, normalized);
37
+ }
38
+ return url.toString();
39
+ }
40
+
41
+ function replacePaneFocusUrlState(windowLike, target) {
42
+ if (!windowLike || !windowLike.location || !windowLike.history) return false;
43
+ if (typeof windowLike.history.replaceState !== "function") return false;
44
+ const currentHref = String(windowLike.location.href || "");
45
+ const nextHref = buildPaneFocusUrl(currentHref, target);
46
+ if (nextHref === currentHref) return false;
47
+ windowLike.history.replaceState(windowLike.history.state, "", nextHref);
48
+ return true;
49
+ }
50
+
51
+ function normalizeStudioPendingKind(value) {
52
+ return STUDIO_PENDING_KINDS.includes(value) ? value : null;
53
+ }
54
+
55
+ function isValidStudioLaunchId(value) {
56
+ return typeof value === "string" && STUDIO_LAUNCH_ID_PATTERN.test(value);
57
+ }
58
+
59
+ function makeStudioLaunchId(cryptoLike) {
60
+ if (!cryptoLike || typeof cryptoLike !== "object") {
61
+ throw new Error("Secure browser randomness is unavailable.");
62
+ }
63
+ if (typeof cryptoLike.randomUUID === "function") {
64
+ const candidate = String(cryptoLike.randomUUID()).replace(/[^a-zA-Z0-9_-]/g, "_");
65
+ if (isValidStudioLaunchId(candidate)) return candidate;
66
+ }
67
+ if (typeof cryptoLike.getRandomValues === "function") {
68
+ const bytes = new Uint8Array(24);
69
+ cryptoLike.getRandomValues(bytes);
70
+ const candidate = "launch_" + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
71
+ if (isValidStudioLaunchId(candidate)) return candidate;
72
+ }
73
+ throw new Error("Secure browser randomness is unavailable.");
74
+ }
75
+
76
+ function studioLaunchChannelName(launchId) {
77
+ if (!isValidStudioLaunchId(launchId)) throw new Error("Invalid Studio launch ID.");
78
+ return STUDIO_LAUNCH_CHANNEL_PREFIX + launchId;
79
+ }
80
+
81
+ function buildPendingStudioUrl(token, launchId, kind) {
82
+ const cleanToken = typeof token === "string" ? token : "";
83
+ const cleanKind = normalizeStudioPendingKind(kind);
84
+ if (!cleanToken || cleanToken.length > 256) throw new Error("Missing or invalid Studio token.");
85
+ if (!isValidStudioLaunchId(launchId)) throw new Error("Invalid Studio launch ID.");
86
+ if (!cleanKind) throw new Error("Invalid Studio pending-page kind.");
87
+ return "/studio-open-pending?" + new URLSearchParams({
88
+ token: cleanToken,
89
+ launchId,
90
+ kind: cleanKind,
91
+ }).toString();
92
+ }
93
+
94
+ function normalizeStudioLaunchMessage(value, fallback) {
95
+ const compact = String(value || fallback || "Studio could not complete this tab launch.")
96
+ .replace(/\s+/g, " ")
97
+ .trim();
98
+ if (!compact) return "Studio could not complete this tab launch.";
99
+ return compact.slice(0, STUDIO_LAUNCH_MESSAGE_MAX_CHARS);
100
+ }
101
+
102
+ function normalizeStudioRelativeTarget(target, locationLike, token) {
103
+ if (typeof target !== "string" || target.length < 1 || target.length > STUDIO_LAUNCH_TARGET_MAX_CHARS) {
104
+ throw new Error("Studio returned an invalid tab target.");
105
+ }
106
+ if (!(target === "/" || target.startsWith("/?") || target.startsWith("/#"))) {
107
+ throw new Error("Studio tab targets must be relative root URLs.");
108
+ }
109
+ if (target.includes("\\") || /[\u0000-\u001f\u007f]/.test(target)) {
110
+ throw new Error("Studio returned an invalid tab target.");
111
+ }
112
+ const baseHref = locationLike && typeof locationLike.href === "string" ? locationLike.href : "";
113
+ const baseUrl = new URL(baseHref);
114
+ if (baseUrl.protocol !== "http:" && baseUrl.protocol !== "https:") {
115
+ throw new Error("Studio tab targets require an HTTP(S) origin.");
116
+ }
117
+ const parsed = new URL(target, baseUrl);
118
+ if (parsed.origin !== baseUrl.origin || parsed.protocol !== baseUrl.protocol) {
119
+ throw new Error("Studio tab targets must stay on the current origin.");
120
+ }
121
+ if (parsed.username || parsed.password || parsed.pathname !== "/") {
122
+ throw new Error("Studio returned an invalid tab target.");
123
+ }
124
+ const targetTokens = parsed.searchParams.getAll("token");
125
+ if (targetTokens.length !== 1 || targetTokens[0] !== token) {
126
+ throw new Error("Studio tab target token did not match this session.");
127
+ }
128
+ return parsed.pathname + parsed.search + parsed.hash;
129
+ }
130
+
131
+ function openStudioTabDirect(windowLike, targetUrl) {
132
+ if (!windowLike || typeof windowLike.open !== "function") {
133
+ throw new Error("Opening browser tabs is unavailable.");
134
+ }
135
+ const target = String(targetUrl || "").trim();
136
+ if (!target) throw new Error("Missing browser tab target.");
137
+ windowLike.open(target, "_blank", "noopener");
138
+ }
139
+
140
+ function canOpenPendingStudioLaunch(windowLike) {
141
+ return Boolean(
142
+ windowLike
143
+ && typeof windowLike.open === "function"
144
+ && typeof windowLike.BroadcastChannel === "function"
145
+ && windowLike.crypto
146
+ && (
147
+ typeof windowLike.crypto.randomUUID === "function"
148
+ || typeof windowLike.crypto.getRandomValues === "function"
149
+ )
150
+ );
151
+ }
152
+
153
+ function addChannelMessageListener(channel, listener) {
154
+ if (channel && typeof channel.addEventListener === "function") {
155
+ channel.addEventListener("message", listener);
156
+ return () => {
157
+ if (typeof channel.removeEventListener === "function") channel.removeEventListener("message", listener);
158
+ };
159
+ }
160
+ if (channel) channel.onmessage = listener;
161
+ return () => {
162
+ if (channel && channel.onmessage === listener) channel.onmessage = null;
163
+ };
164
+ }
165
+
166
+ function createPendingStudioLaunch(options) {
167
+ const config = options && typeof options === "object" ? options : {};
168
+ const windowLike = config.window || (typeof window === "object" ? window : null);
169
+ const token = typeof config.token === "string" ? config.token : "";
170
+ const kind = normalizeStudioPendingKind(config.kind);
171
+ if (!kind) throw new Error("Invalid Studio pending-page kind.");
172
+ if (!token || token.length > 256) throw new Error("Missing or invalid Studio token.");
173
+ if (!canOpenPendingStudioLaunch(windowLike)) {
174
+ throw new Error("This browser does not support asynchronously prepared Studio tabs. Update the browser and try again.");
175
+ }
176
+
177
+ const launchId = makeStudioLaunchId(windowLike.crypto);
178
+ const pendingUrl = buildPendingStudioUrl(token, launchId, kind);
179
+ const channel = new windowLike.BroadcastChannel(studioLaunchChannelName(launchId));
180
+ const setTimer = typeof windowLike.setTimeout === "function" ? windowLike.setTimeout.bind(windowLike) : setTimeout;
181
+ const clearTimer = typeof windowLike.clearTimeout === "function" ? windowLike.clearTimeout.bind(windowLike) : clearTimeout;
182
+ const readyTimeoutMs = Math.max(100, Number(config.readyTimeoutMs) || STUDIO_LAUNCH_READY_TIMEOUT_MS);
183
+ const deliveryTimeoutMs = Math.max(100, Number(config.deliveryTimeoutMs) || STUDIO_LAUNCH_DELIVERY_TIMEOUT_MS);
184
+ let state = "waiting";
185
+ let ready = false;
186
+ let terminal = null;
187
+ let readyTimer = null;
188
+ let deliveryTimer = null;
189
+ let removeMessageListener = null;
190
+ let cleaned = false;
191
+ let controller = null;
192
+
193
+ function emit(event, detail) {
194
+ if (typeof config.onEvent !== "function") return;
195
+ try {
196
+ config.onEvent(event, {
197
+ launchId,
198
+ kind,
199
+ state,
200
+ terminalType: terminal ? terminal.type : null,
201
+ ...(detail && typeof detail === "object" ? detail : {}),
202
+ });
203
+ } catch {
204
+ // Ignore observer errors so they cannot break launch delivery.
205
+ }
206
+ }
207
+
208
+ function cleanup() {
209
+ if (cleaned) return;
210
+ cleaned = true;
211
+ if (readyTimer !== null) clearTimer(readyTimer);
212
+ if (deliveryTimer !== null) clearTimer(deliveryTimer);
213
+ readyTimer = null;
214
+ deliveryTimer = null;
215
+ if (removeMessageListener) removeMessageListener();
216
+ removeMessageListener = null;
217
+ try { channel.close(); } catch {}
218
+ }
219
+
220
+ function protocolMessage(type, extra) {
221
+ return {
222
+ protocol: STUDIO_LAUNCH_PROTOCOL_VERSION,
223
+ type,
224
+ launchId,
225
+ ...(extra && typeof extra === "object" ? extra : {}),
226
+ };
227
+ }
228
+
229
+ function postTerminal() {
230
+ if (!ready || !terminal || state === "accepted" || state === "abandoned") return false;
231
+ state = "terminal-sent";
232
+ try {
233
+ channel.postMessage(protocolMessage(terminal.type, terminal.payload));
234
+ emit("terminal-sent");
235
+ return true;
236
+ } catch (error) {
237
+ emit("terminal-send-error", { message: normalizeStudioLaunchMessage(error && error.message, "Could not deliver Studio tab result.") });
238
+ return false;
239
+ }
240
+ }
241
+
242
+ function beginDeliveryTimeout() {
243
+ if (deliveryTimer !== null) clearTimer(deliveryTimer);
244
+ deliveryTimer = setTimer(() => {
245
+ deliveryTimer = null;
246
+ if (state === "accepted" || state === "abandoned") return;
247
+ state = "abandoned";
248
+ emit("delivery-timeout");
249
+ if (typeof config.onDeliveryTimeout === "function") {
250
+ try { config.onDeliveryTimeout(controller); } catch {}
251
+ }
252
+ cleanup();
253
+ }, deliveryTimeoutMs);
254
+ }
255
+
256
+ function setTerminal(type, payload) {
257
+ if (terminal || state === "accepted" || state === "abandoned") return false;
258
+ terminal = { type, payload: payload || {} };
259
+ emit("terminal-queued");
260
+ beginDeliveryTimeout();
261
+ if (ready) postTerminal();
262
+ return true;
263
+ }
264
+
265
+ function handleMessage(event) {
266
+ const message = event && event.data;
267
+ if (!message || typeof message !== "object") return;
268
+ if (message.protocol !== STUDIO_LAUNCH_PROTOCOL_VERSION || message.launchId !== launchId) return;
269
+ if (message.type === "ready") {
270
+ if (state === "accepted" || state === "abandoned") return;
271
+ const firstReady = !ready;
272
+ ready = true;
273
+ if (readyTimer !== null) clearTimer(readyTimer);
274
+ readyTimer = null;
275
+ if (!terminal) state = "ready";
276
+ if (firstReady) {
277
+ emit("ready");
278
+ if (typeof config.onReady === "function") {
279
+ try { config.onReady(controller); } catch {}
280
+ }
281
+ }
282
+ if (terminal) postTerminal();
283
+ return;
284
+ }
285
+ if (message.type !== "accepted" || !terminal || state !== "terminal-sent") return;
286
+ if (message.terminalType !== terminal.type) return;
287
+ state = "accepted";
288
+ const acceptedOk = message.ok !== false;
289
+ emit("accepted", { ok: acceptedOk });
290
+ if (typeof config.onAccepted === "function") {
291
+ try { config.onAccepted({ controller, ok: acceptedOk, terminalType: terminal.type }); } catch {}
292
+ }
293
+ cleanup();
294
+ }
295
+
296
+ removeMessageListener = addChannelMessageListener(channel, handleMessage);
297
+ readyTimer = setTimer(() => {
298
+ readyTimer = null;
299
+ if (ready || state === "accepted" || state === "abandoned") return;
300
+ emit("ready-timeout");
301
+ if (typeof config.onReadyTimeout === "function") {
302
+ try { config.onReadyTimeout(controller); } catch {}
303
+ }
304
+ }, readyTimeoutMs);
305
+
306
+ controller = Object.freeze({
307
+ launchId,
308
+ kind,
309
+ navigate(target) {
310
+ const normalizedTarget = normalizeStudioRelativeTarget(target, windowLike.location, token);
311
+ return setTerminal("navigate", { target: normalizedTarget });
312
+ },
313
+ fail(message) {
314
+ return setTerminal("error", { message: normalizeStudioLaunchMessage(message) });
315
+ },
316
+ cancel(message) {
317
+ return setTerminal("cancel", { message: normalizeStudioLaunchMessage(message, "Studio tab launch was cancelled.") });
318
+ },
319
+ abandon(message) {
320
+ if (state === "accepted" || state === "abandoned") return false;
321
+ if (ready && !terminal) {
322
+ try {
323
+ channel.postMessage(protocolMessage("cancel", {
324
+ message: normalizeStudioLaunchMessage(message, "The originating Studio page was closed."),
325
+ }));
326
+ } catch {}
327
+ }
328
+ state = "abandoned";
329
+ emit("abandoned");
330
+ cleanup();
331
+ return true;
332
+ },
333
+ getSnapshot() {
334
+ return Object.freeze({
335
+ launchId,
336
+ kind,
337
+ state,
338
+ ready,
339
+ terminalType: terminal ? terminal.type : null,
340
+ });
341
+ },
342
+ });
343
+
344
+ emit("created");
345
+ try {
346
+ windowLike.open(pendingUrl, "_blank", "noopener");
347
+ emit("open-requested");
348
+ } catch (error) {
349
+ state = "abandoned";
350
+ emit("open-error", { message: normalizeStudioLaunchMessage(error && error.message, "Browser tab open failed.") });
351
+ if (typeof config.onOpenError === "function") {
352
+ try { config.onOpenError(error, controller); } catch {}
353
+ }
354
+ cleanup();
355
+ throw error;
356
+ }
357
+ return controller;
358
+ }
359
+
360
+ function startStudioPendingPage(windowLike, documentLike, options) {
361
+ const config = options && typeof options === "object" ? options : {};
362
+ const launchId = typeof config.launchId === "string" ? config.launchId : "";
363
+ const kind = normalizeStudioPendingKind(config.kind);
364
+ const token = typeof config.token === "string" ? config.token : "";
365
+ const titleEl = documentLike && typeof documentLike.getElementById === "function"
366
+ ? documentLike.getElementById("pendingTitle")
367
+ : null;
368
+ const detailEl = documentLike && typeof documentLike.getElementById === "function"
369
+ ? documentLike.getElementById("pendingDetail")
370
+ : null;
371
+ const closeBtn = documentLike && typeof documentLike.getElementById === "function"
372
+ ? documentLike.getElementById("pendingCloseBtn")
373
+ : null;
374
+
375
+ function render(title, detail, showClose) {
376
+ if (titleEl) titleEl.textContent = title;
377
+ if (detailEl) detailEl.textContent = detail;
378
+ if (closeBtn) closeBtn.hidden = !showClose;
379
+ }
380
+
381
+ if (!windowLike || typeof windowLike.BroadcastChannel !== "function" || !isValidStudioLaunchId(launchId) || !kind || !token) {
382
+ render("Could not prepare Studio tab", "This pending-tab request was invalid. Return to Studio and try again.", true);
383
+ return null;
384
+ }
385
+
386
+ const channel = new windowLike.BroadcastChannel(studioLaunchChannelName(launchId));
387
+ const setTimer = typeof windowLike.setTimeout === "function" ? windowLike.setTimeout.bind(windowLike) : setTimeout;
388
+ const clearTimer = typeof windowLike.clearTimeout === "function" ? windowLike.clearTimeout.bind(windowLike) : clearTimeout;
389
+ let terminalHandled = false;
390
+ let closed = false;
391
+ let waitingTimer = null;
392
+ let removeMessageListener = null;
393
+
394
+ function post(type, extra) {
395
+ channel.postMessage({
396
+ protocol: STUDIO_LAUNCH_PROTOCOL_VERSION,
397
+ type,
398
+ launchId,
399
+ ...(extra && typeof extra === "object" ? extra : {}),
400
+ });
401
+ }
402
+
403
+ function cleanup() {
404
+ if (closed) return;
405
+ closed = true;
406
+ if (waitingTimer !== null) clearTimer(waitingTimer);
407
+ waitingTimer = null;
408
+ if (removeMessageListener) removeMessageListener();
409
+ removeMessageListener = null;
410
+ try { channel.close(); } catch {}
411
+ }
412
+
413
+ function acknowledge(terminalType, ok) {
414
+ try { post("accepted", { terminalType, ok }); } catch {}
415
+ }
416
+
417
+ function handleMessage(event) {
418
+ const message = event && event.data;
419
+ if (!message || typeof message !== "object" || terminalHandled) return;
420
+ if (message.protocol !== STUDIO_LAUNCH_PROTOCOL_VERSION || message.launchId !== launchId) return;
421
+ if (message.type === "navigate") {
422
+ terminalHandled = true;
423
+ let relativeTarget = "";
424
+ try {
425
+ relativeTarget = normalizeStudioRelativeTarget(message.target, windowLike.location, token);
426
+ } catch {
427
+ acknowledge("navigate", false);
428
+ render("Could not open Studio tab", "Studio rejected an invalid or unsafe navigation target.", true);
429
+ setTimer(cleanup, 50);
430
+ return;
431
+ }
432
+ acknowledge("navigate", true);
433
+ render("Opening Studio tab…", "The requested Studio view is ready.", false);
434
+ setTimer(() => {
435
+ cleanup();
436
+ try {
437
+ windowLike.location.replace(relativeTarget);
438
+ } catch {
439
+ render("Could not open Studio tab", "The browser could not navigate this pending tab.", true);
440
+ }
441
+ }, 20);
442
+ return;
443
+ }
444
+ if (message.type === "error") {
445
+ terminalHandled = true;
446
+ acknowledge("error", true);
447
+ render("Studio could not open this tab", normalizeStudioLaunchMessage(message.message), true);
448
+ setTimer(cleanup, 50);
449
+ return;
450
+ }
451
+ if (message.type === "cancel") {
452
+ terminalHandled = true;
453
+ acknowledge("cancel", true);
454
+ render("Studio tab launch cancelled", normalizeStudioLaunchMessage(message.message, "The originating Studio page cancelled this launch."), true);
455
+ setTimer(cleanup, 50);
456
+ }
457
+ }
458
+
459
+ removeMessageListener = addChannelMessageListener(channel, handleMessage);
460
+ if (closeBtn && typeof closeBtn.addEventListener === "function") {
461
+ closeBtn.addEventListener("click", () => {
462
+ try { windowLike.close(); } catch {}
463
+ });
464
+ }
465
+ if (typeof windowLike.addEventListener === "function") {
466
+ windowLike.addEventListener("pagehide", cleanup, { once: true });
467
+ }
468
+ waitingTimer = setTimer(() => {
469
+ waitingTimer = null;
470
+ if (terminalHandled) return;
471
+ render("Still waiting for Studio…", "The originating Studio page has not finished. Keep this tab open, or close it and retry from Studio.", true);
472
+ }, Math.max(100, Number(config.stillWaitingMs) || STUDIO_PENDING_STILL_WAITING_MS));
473
+ try {
474
+ post("ready");
475
+ } catch {
476
+ cleanup();
477
+ render("Could not contact Studio", "Return to the originating Studio page and try again.", true);
478
+ return null;
479
+ }
480
+
481
+ return Object.freeze({
482
+ close: cleanup,
483
+ getSnapshot() {
484
+ return Object.freeze({ launchId, kind, terminalHandled, closed });
485
+ },
486
+ });
487
+ }
488
+
489
+ globalThis.PiStudioNavigationHelpers = Object.freeze({
490
+ PANE_FOCUS_OFF,
491
+ PANE_FOCUS_PARAM,
492
+ PANE_FOCUS_TARGETS,
493
+ STUDIO_LAUNCH_CHANNEL_PREFIX,
494
+ STUDIO_LAUNCH_DELIVERY_TIMEOUT_MS,
495
+ STUDIO_LAUNCH_MESSAGE_MAX_CHARS,
496
+ STUDIO_LAUNCH_PROTOCOL_VERSION,
497
+ STUDIO_LAUNCH_READY_TIMEOUT_MS,
498
+ STUDIO_LAUNCH_TARGET_MAX_CHARS,
499
+ STUDIO_PENDING_KINDS,
500
+ buildPaneFocusUrl,
501
+ buildPendingStudioUrl,
502
+ canOpenPendingStudioLaunch,
503
+ createPendingStudioLaunch,
504
+ isValidStudioLaunchId,
505
+ makeStudioLaunchId,
506
+ normalizePaneFocusTarget,
507
+ normalizeStudioLaunchMessage,
508
+ normalizeStudioPendingKind,
509
+ normalizeStudioRelativeTarget,
510
+ openStudioTabDirect,
511
+ readPaneFocusTarget,
512
+ replacePaneFocusUrlState,
513
+ startStudioPendingPage,
514
+ studioLaunchChannelName,
515
+ });
516
+
517
+ if (
518
+ typeof window === "object"
519
+ && typeof document === "object"
520
+ && document.body
521
+ && document.body.dataset
522
+ && document.body.dataset.studioPendingLaunch === "1"
523
+ ) {
524
+ startStudioPendingPage(window, document, {
525
+ launchId: document.body.dataset.launchId || "",
526
+ kind: document.body.dataset.launchKind || "",
527
+ token: document.body.dataset.studioToken || "",
528
+ });
529
+ }
530
+ })();