pi-studio 0.9.40 → 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.
package/index.ts CHANGED
@@ -31,6 +31,12 @@ import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js"
31
31
  import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
32
32
  import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
33
33
  import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
34
+ import {
35
+ buildStudioPendingPage,
36
+ buildStudioPendingSecurityHeaders,
37
+ isValidStudioLaunchId,
38
+ normalizeStudioPendingKind,
39
+ } from "./shared/studio-tab-launcher.js";
34
40
  import { renderStudioAnnotationInlineHtml } from "./shared/studio-annotation-render.js";
35
41
  import {
36
42
  buildStudioMermaidCliIconArgs,
@@ -90,6 +96,7 @@ interface StudioQuartoPreviewState {
90
96
  const STUDIO_CSS_URL = new URL("./client/studio.css", import.meta.url);
91
97
  const STUDIO_ANNOTATION_HELPERS_URL = new URL("./client/studio-annotation-helpers.js", import.meta.url);
92
98
  const STUDIO_MERMAID_HELPERS_URL = new URL("./client/studio-mermaid-helpers.js", import.meta.url);
99
+ const STUDIO_NAVIGATION_HELPERS_URL = new URL("./client/studio-navigation-helpers.js", import.meta.url);
93
100
  const STUDIO_CLIENT_URL = new URL("./client/studio-client.js", import.meta.url);
94
101
 
95
102
  interface StudioServerState {
@@ -7259,6 +7266,17 @@ function respondText(res: ServerResponse, status: number, text: string): void {
7259
7266
  res.end(text);
7260
7267
  }
7261
7268
 
7269
+ function respondStudioPendingError(res: ServerResponse, status: number, text: string, allow?: string): void {
7270
+ const nonce = randomUUID().replace(/-/g, "");
7271
+ const headers = {
7272
+ ...buildStudioPendingSecurityHeaders(nonce),
7273
+ "Content-Type": "text/plain; charset=utf-8",
7274
+ ...(allow ? { Allow: allow } : {}),
7275
+ };
7276
+ res.writeHead(status, headers);
7277
+ res.end(text);
7278
+ }
7279
+
7262
7280
  function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: string): void {
7263
7281
  const method = (req.method ?? "GET").toUpperCase();
7264
7282
  if (method !== "GET" && method !== "HEAD") {
@@ -7394,12 +7412,9 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7394
7412
  }
7395
7413
  const document = buildStudioLocalResourcePreviewDocument(resource);
7396
7414
  const docId = storeTransientStudioDocument(document);
7397
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
7398
- const parsedUrl = new URL(url);
7399
7415
  respondJson(res, 200, {
7400
7416
  ...basePayload,
7401
- url,
7402
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
7417
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
7403
7418
  });
7404
7419
  return;
7405
7420
  }
@@ -7459,13 +7474,10 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7459
7474
  }
7460
7475
 
7461
7476
  const docId = storeTransientStudioDocument(document);
7462
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
7463
- const parsedUrl = new URL(url);
7464
7477
  respondJson(res, 200, {
7465
7478
  ...basePayload,
7466
7479
  converted,
7467
- url,
7468
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
7480
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
7469
7481
  });
7470
7482
  }
7471
7483
 
@@ -10078,8 +10090,7 @@ function readTransientStudioDocument(id: string): InitialStudioDocument | null {
10078
10090
  return entry ? { ...entry.document } : null;
10079
10091
  }
10080
10092
 
10081
- function buildStudioUrl(
10082
- port: number,
10093
+ function buildStudioRelativeUrl(
10083
10094
  token: string,
10084
10095
  mode: StudioUiMode = "full",
10085
10096
  doc?: InitialStudioDocument | null,
@@ -10095,7 +10106,18 @@ function buildStudioUrl(
10095
10106
  if (doc?.draftId) params.set("draftId", doc.draftId);
10096
10107
  if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
10097
10108
  if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
10098
- return `http://127.0.0.1:${port}/?${params.toString()}`;
10109
+ return `/?${params.toString()}`;
10110
+ }
10111
+
10112
+ function buildStudioUrl(
10113
+ port: number,
10114
+ token: string,
10115
+ mode: StudioUiMode = "full",
10116
+ doc?: InitialStudioDocument | null,
10117
+ docId?: string,
10118
+ options?: { skipWorkspaceRestore?: boolean },
10119
+ ): string {
10120
+ return `http://127.0.0.1:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
10099
10121
  }
10100
10122
 
10101
10123
  interface StudioLaunchFlags {
@@ -10509,6 +10531,7 @@ function buildStudioHtml(
10509
10531
  const stylesheetHref = `/studio.css?token=${encodeURIComponent(studioToken ?? "")}`;
10510
10532
  const annotationHelpersScriptHref = `/studio-annotation-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10511
10533
  const mermaidHelpersScriptHref = `/studio-mermaid-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10534
+ const navigationHelpersScriptHref = `/studio-navigation-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10512
10535
  const clientScriptHref = `/studio-client.js?token=${encodeURIComponent(studioToken ?? "")}`;
10513
10536
  const faviconHref = buildStudioFaviconDataUri(style);
10514
10537
  const bootConfigJson = JSON.stringify({ mermaidConfig }).replace(/</g, "\\u003c");
@@ -10927,6 +10950,7 @@ ${cssVarsBlock}
10927
10950
  </script>
10928
10951
  <script src="${annotationHelpersScriptHref}"></script>
10929
10952
  <script src="${mermaidHelpersScriptHref}"></script>
10953
+ <script src="${navigationHelpersScriptHref}"></script>
10930
10954
  <script src="${clientScriptHref}"></script>
10931
10955
  </body>
10932
10956
  </html>`;
@@ -12852,13 +12876,10 @@ export default function (pi: ExtensionAPI) {
12852
12876
  resourceDir,
12853
12877
  };
12854
12878
  const docId = storeTransientStudioDocument(document);
12855
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
12856
- const parsedUrl = new URL(url);
12857
12879
  sendToClient(client, {
12858
12880
  type: "editor_only_ready",
12859
12881
  requestId: msg.requestId,
12860
- url,
12861
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
12882
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
12862
12883
  message: hasContent
12863
12884
  ? "Editor tab is ready with a detached copy of the current editor text."
12864
12885
  : "Blank editor tab is ready.",
@@ -14238,8 +14259,6 @@ export default function (pi: ExtensionAPI) {
14238
14259
  resourceDir: dirname(exportedPath),
14239
14260
  };
14240
14261
  const docId = storeTransientStudioDocument(document);
14241
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
14242
- const parsedUrl = new URL(url);
14243
14262
  respondJson(res, 200, {
14244
14263
  ok: true,
14245
14264
  filename,
@@ -14247,8 +14266,7 @@ export default function (pi: ExtensionAPI) {
14247
14266
  writeError: writeResult.error,
14248
14267
  warning: warning ?? null,
14249
14268
  openedStudio: true,
14250
- url,
14251
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
14269
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
14252
14270
  downloadUrl: `/export-pdf?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
14253
14271
  });
14254
14272
  return;
@@ -14385,8 +14403,6 @@ export default function (pi: ExtensionAPI) {
14385
14403
  draftId: exportedPath ? undefined : createStudioDraftId(),
14386
14404
  };
14387
14405
  const docId = storeTransientStudioDocument(document);
14388
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
14389
- const parsedUrl = new URL(url);
14390
14406
  respondJson(res, 200, {
14391
14407
  ok: true,
14392
14408
  filename,
@@ -14394,8 +14410,7 @@ export default function (pi: ExtensionAPI) {
14394
14410
  writeError: writeResult.error,
14395
14411
  warning: warning ?? null,
14396
14412
  openedStudio: true,
14397
- url,
14398
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
14413
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
14399
14414
  downloadUrl: `/export-html?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
14400
14415
  });
14401
14416
  return;
@@ -14454,6 +14469,39 @@ export default function (pi: ExtensionAPI) {
14454
14469
  return;
14455
14470
  }
14456
14471
 
14472
+ if (requestUrl.pathname === "/studio-open-pending") {
14473
+ const tokens = requestUrl.searchParams.getAll("token");
14474
+ if (tokens.length !== 1 || tokens[0] !== serverState.token) {
14475
+ respondStudioPendingError(res, 403, "Invalid or expired studio token. Re-run /studio.");
14476
+ return;
14477
+ }
14478
+
14479
+ const method = (req.method ?? "GET").toUpperCase();
14480
+ if (method !== "GET") {
14481
+ respondStudioPendingError(res, 405, "Method not allowed. Use GET.", "GET");
14482
+ return;
14483
+ }
14484
+
14485
+ const allowedKeys = new Set(["token", "launchId", "kind"]);
14486
+ if (Array.from(requestUrl.searchParams.keys()).some((key) => !allowedKeys.has(key))) {
14487
+ respondStudioPendingError(res, 400, "Unsupported Studio pending-page parameter.");
14488
+ return;
14489
+ }
14490
+ const launchIds = requestUrl.searchParams.getAll("launchId");
14491
+ const kinds = requestUrl.searchParams.getAll("kind");
14492
+ const launchId = launchIds.length === 1 ? launchIds[0] : "";
14493
+ const kind = kinds.length === 1 ? normalizeStudioPendingKind(kinds[0]) : null;
14494
+ if (!isValidStudioLaunchId(launchId) || !kind) {
14495
+ respondStudioPendingError(res, 400, "Invalid Studio pending-page request.");
14496
+ return;
14497
+ }
14498
+
14499
+ const nonce = randomUUID().replace(/-/g, "");
14500
+ res.writeHead(200, buildStudioPendingSecurityHeaders(nonce));
14501
+ res.end(buildStudioPendingPage({ token: serverState.token, launchId, kind, nonce }));
14502
+ return;
14503
+ }
14504
+
14457
14505
  if (requestUrl.pathname === "/studio.css") {
14458
14506
  const token = requestUrl.searchParams.get("token") ?? "";
14459
14507
  if (token !== serverState.token) {
@@ -14483,7 +14531,12 @@ export default function (pi: ExtensionAPI) {
14483
14531
  return;
14484
14532
  }
14485
14533
 
14486
- if (requestUrl.pathname === "/studio-annotation-helpers.js" || requestUrl.pathname === "/studio-mermaid-helpers.js" || requestUrl.pathname === "/studio-client.js") {
14534
+ if (
14535
+ requestUrl.pathname === "/studio-annotation-helpers.js"
14536
+ || requestUrl.pathname === "/studio-mermaid-helpers.js"
14537
+ || requestUrl.pathname === "/studio-navigation-helpers.js"
14538
+ || requestUrl.pathname === "/studio-client.js"
14539
+ ) {
14487
14540
  const token = requestUrl.searchParams.get("token") ?? "";
14488
14541
  if (token !== serverState.token) {
14489
14542
  respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
@@ -14501,12 +14554,16 @@ export default function (pi: ExtensionAPI) {
14501
14554
  ? STUDIO_ANNOTATION_HELPERS_URL
14502
14555
  : requestUrl.pathname === "/studio-mermaid-helpers.js"
14503
14556
  ? STUDIO_MERMAID_HELPERS_URL
14504
- : STUDIO_CLIENT_URL;
14557
+ : requestUrl.pathname === "/studio-navigation-helpers.js"
14558
+ ? STUDIO_NAVIGATION_HELPERS_URL
14559
+ : STUDIO_CLIENT_URL;
14505
14560
  const targetLabel = requestUrl.pathname === "/studio-annotation-helpers.js"
14506
14561
  ? "studio annotation helper script"
14507
14562
  : requestUrl.pathname === "/studio-mermaid-helpers.js"
14508
14563
  ? "studio Mermaid helper script"
14509
- : "studio client script";
14564
+ : requestUrl.pathname === "/studio-navigation-helpers.js"
14565
+ ? "studio navigation helper script"
14566
+ : "studio client script";
14510
14567
 
14511
14568
  try {
14512
14569
  const clientScript = readFileSync(targetUrl, "utf-8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.40",
3
+ "version": "0.9.41",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  ],
31
31
  "scripts": {
32
32
  "test": "node --test",
33
+ "test:browser": "node --test test/studio-tab-launcher-browser.test.js",
33
34
  "typecheck": "tsc --noEmit"
34
35
  },
35
36
  "pi": {
@@ -0,0 +1,86 @@
1
+ const STUDIO_PENDING_KINDS = Object.freeze(["document", "preview", "export"]);
2
+ const STUDIO_LAUNCH_ID_PATTERN = /^[a-zA-Z0-9_-]{20,128}$/;
3
+ const STUDIO_CSP_NONCE_PATTERN = /^[a-zA-Z0-9_-]{16,128}$/;
4
+
5
+ function escapeHtmlAttribute(value) {
6
+ return String(value ?? "")
7
+ .replace(/&/g, "&amp;")
8
+ .replace(/"/g, "&quot;")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;");
11
+ }
12
+
13
+ export function normalizeStudioPendingKind(value) {
14
+ return STUDIO_PENDING_KINDS.includes(value) ? value : null;
15
+ }
16
+
17
+ export function isValidStudioLaunchId(value) {
18
+ return typeof value === "string" && STUDIO_LAUNCH_ID_PATTERN.test(value);
19
+ }
20
+
21
+ export function buildStudioPendingSecurityHeaders(nonce) {
22
+ if (typeof nonce !== "string" || !STUDIO_CSP_NONCE_PATTERN.test(nonce)) {
23
+ throw new Error("Invalid Studio pending-page CSP nonce.");
24
+ }
25
+ return {
26
+ "Content-Type": "text/html; charset=utf-8",
27
+ "Cache-Control": "no-store",
28
+ "X-Content-Type-Options": "nosniff",
29
+ "Referrer-Policy": "no-referrer",
30
+ "Cross-Origin-Opener-Policy": "same-origin",
31
+ "Cross-Origin-Resource-Policy": "same-origin",
32
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
33
+ "Content-Security-Policy": [
34
+ "default-src 'none'",
35
+ `script-src 'nonce-${nonce}'`,
36
+ `style-src 'nonce-${nonce}'`,
37
+ "base-uri 'none'",
38
+ "form-action 'none'",
39
+ "object-src 'none'",
40
+ "frame-ancestors 'none'",
41
+ ].join("; "),
42
+ };
43
+ }
44
+
45
+ export function buildStudioPendingPage(options) {
46
+ const config = options && typeof options === "object" ? options : {};
47
+ const token = typeof config.token === "string" ? config.token : "";
48
+ const launchId = typeof config.launchId === "string" ? config.launchId : "";
49
+ const kind = normalizeStudioPendingKind(config.kind);
50
+ const nonce = typeof config.nonce === "string" ? config.nonce : "";
51
+ if (!token) throw new Error("Studio pending page requires a token.");
52
+ if (!isValidStudioLaunchId(launchId)) throw new Error("Invalid Studio launch ID.");
53
+ if (!kind) throw new Error("Invalid Studio pending-page kind.");
54
+ buildStudioPendingSecurityHeaders(nonce);
55
+
56
+ const kindLabel = kind === "document" ? "document" : (kind === "export" ? "export" : "preview");
57
+ const helperQuery = new URLSearchParams({ token }).toString();
58
+ return `<!doctype html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8" />
62
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
63
+ <title>Preparing Studio tab…</title>
64
+ <style nonce="${escapeHtmlAttribute(nonce)}">
65
+ :root { color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
66
+ body { min-height: 100vh; margin: 0; display: grid; place-items: center; background: Canvas; color: CanvasText; }
67
+ main { width: min(34rem, calc(100vw - 3rem)); padding: 2rem; border: 1px solid color-mix(in srgb, CanvasText 18%, transparent); border-radius: 12px; }
68
+ h1 { margin: 0 0 0.65rem; font-size: 1.15rem; }
69
+ p { margin: 0; line-height: 1.5; opacity: 0.8; overflow-wrap: anywhere; }
70
+ button { margin-top: 1.25rem; padding: 0.45rem 0.8rem; font: inherit; }
71
+ [hidden] { display: none !important; }
72
+ </style>
73
+ </head>
74
+ <body data-studio-pending-launch="1" data-launch-id="${escapeHtmlAttribute(launchId)}" data-launch-kind="${escapeHtmlAttribute(kind)}" data-studio-token="${escapeHtmlAttribute(token)}">
75
+ <main aria-live="polite" aria-atomic="true">
76
+ <h1 id="pendingTitle">Preparing Studio ${kindLabel}…</h1>
77
+ <p id="pendingDetail">Waiting for the originating Studio page.</p>
78
+ <button id="pendingCloseBtn" type="button" hidden>Close tab</button>
79
+ </main>
80
+ <noscript>This Studio tab requires JavaScript. You can close it and return to the originating Studio page.</noscript>
81
+ <script nonce="${escapeHtmlAttribute(nonce)}" src="/studio-navigation-helpers.js?${escapeHtmlAttribute(helperQuery)}"></script>
82
+ </body>
83
+ </html>`;
84
+ }
85
+
86
+ export { STUDIO_PENDING_KINDS };