Skip to content

Commit c9eb230

Browse files
authored
fix(viewer): preserve live HTML previews (#7156)
* fix(viewer): preserve live HTML previews * fix(viewer): sync saved text to retained preview * fix(viewer): prewarm retained file revisions * fix(viewer): expire saved preview adoption * fix(viewer): repaint retained previews on activation * fix(viewer): reconcile retained style history * fix(viewer): renew preview asset scopes without reloads * test(viewer): cover scope renewal in team previews * test(exports): expect managed preview bases
1 parent 393af2f commit c9eb230

22 files changed

Lines changed: 2088 additions & 283 deletions

apps/daemon/src/routes/project/index.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Express, Request, Response } from 'express';
66
import type { LintArtifactRequest, LintArtifactResponse } from '@open-design/contracts';
77
import {
88
PREVIEW_OBSERVABILITY_BRIDGE_MARKER,
9+
buildPreviewBaseHrefBridge,
910
buildPreviewObservabilityBridge,
1011
} from '@open-design/contracts/runtime/preview-observability';
1112
import {
@@ -5446,6 +5447,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
54465447
projectId: string,
54475448
ownerFilePath: string,
54485449
scope: string,
5450+
expiresAt: number,
54495451
): string {
54505452
// Respect an artifact-authored base URL. Only generated documents without
54515453
// one need the containment base that keeps runtime-created relative URLs
@@ -5456,10 +5458,13 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
54565458
? ''
54575459
: `${encodeProjectPathForUrl(ownerDir)}/`;
54585460
const baseTag = `<base href="/api/projects/${encodeURIComponent(projectId)}`
5459-
+ `/preview/${encodeURIComponent(scope)}/${dirSuffix}">`;
5461+
+ `/preview/${encodeURIComponent(scope)}/${dirSuffix}" data-od-project-preview-base>`;
5462+
const baseHref = `/api/projects/${encodeURIComponent(projectId)}`
5463+
+ `/preview/${encodeURIComponent(scope)}/${dirSuffix}`;
5464+
const bridge = buildPreviewBaseHrefBridge({ href: baseHref, expiresAt });
54605465
const head = /<head\b[^>]*>/i;
5461-
if (head.test(html)) return html.replace(head, (tag) => `${tag}${baseTag}`);
5462-
return `${baseTag}${html}`;
5466+
if (head.test(html)) return html.replace(head, (tag) => `${tag}${baseTag}${bridge}`);
5467+
return `${baseTag}${bridge}${html}`;
54635468
}
54645469

54655470
function rewriteWorkspaceScopedHtmlAssetUrls(
@@ -5832,13 +5837,19 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
58325837
workspaceMemberId: requestContext.workspaceMemberId,
58335838
},
58345839
);
5840+
const expiresAt = projectPreviewScopes.expiresAt(project.id, scope);
5841+
if (expiresAt === undefined) {
5842+
sendApiError(res, 503, 'PREVIEW_SCOPE_NOT_FOUND', 'preview scope not found');
5843+
return;
5844+
}
58355845
/** @type {import('@open-design/contracts').ProjectPreviewUrlResponse} */
58365846
const body = {
58375847
url: `/api/projects/${encodeURIComponent(project.id)}/preview/${scope}/${encodeProjectPathForUrl(meta.name)}`,
58385848
file: meta.name,
58395849
csp: projectPreviewCsp,
58405850
iframeSandbox: projectPreviewIframeSandbox,
58415851
opaqueOrigin: true,
5852+
expiresAt,
58425853
};
58435854
res.setHeader('Cache-Control', 'no-store');
58445855
res.json(body);
@@ -5853,6 +5864,61 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
58535864
}
58545865
});
58555866

5867+
app.post('/api/projects/:id/preview/:scope/renew', async (req, res) => {
5868+
try {
5869+
const projectId = String(req.params.id ?? '');
5870+
const scope = String(req.params.scope ?? '');
5871+
// The scope is embedded in untrusted preview HTML. Requiring a custom
5872+
// header makes renewal a host-only operation: an opaque-origin iframe
5873+
// cannot set it without a CORS preflight, and this route grants no CORS.
5874+
if (req.get('x-od-preview-scope-renewal') !== '1') {
5875+
sendApiError(res, 403, 'FORBIDDEN', 'preview scope renewal requires host authorization');
5876+
return;
5877+
}
5878+
if (!previewScopeRe.test(scope)) {
5879+
sendApiError(res, 400, 'BAD_REQUEST', 'invalid preview scope');
5880+
return;
5881+
}
5882+
const project = getProject(db, projectId);
5883+
if (!project) {
5884+
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
5885+
return;
5886+
}
5887+
const previewWorkspace = projectPreviewScopes.resolve(project.id, scope);
5888+
if (previewWorkspace === undefined) {
5889+
sendApiError(res, 404, 'PREVIEW_SCOPE_NOT_FOUND', 'preview scope not found');
5890+
return;
5891+
}
5892+
const authorityRequest = previewWorkspace
5893+
? {
5894+
query: {
5895+
...req.query,
5896+
workspaceId: previewWorkspace.workspaceId,
5897+
workspaceMemberId: previewWorkspace.workspaceMemberId,
5898+
},
5899+
get: req.get.bind(req),
5900+
}
5901+
: req;
5902+
if (!await authorizeProjectRequest(
5903+
authorityRequest,
5904+
res,
5905+
project.id,
5906+
{ mode: 'read', allowNavigationQuery: true },
5907+
)) return;
5908+
const expiresAt = projectPreviewScopes.renew(project.id, scope);
5909+
if (expiresAt === undefined) {
5910+
sendApiError(res, 404, 'PREVIEW_SCOPE_NOT_FOUND', 'preview scope not found');
5911+
return;
5912+
}
5913+
/** @type {import('@open-design/contracts').ProjectPreviewScopeRenewResponse} */
5914+
const body = { expiresAt };
5915+
res.setHeader('Cache-Control', 'no-store');
5916+
res.json(body);
5917+
} catch (err: any) {
5918+
sendApiError(res, 400, 'BAD_REQUEST', String(err));
5919+
}
5920+
});
5921+
58565922
app.get(/^\/api\/projects\/([^/]+)\/text-preview\/(.+)$/u, async (req, res) => {
58575923
let handle: import('fs/promises').FileHandle | null = null;
58585924
try {
@@ -6082,11 +6148,14 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
60826148
}
60836149
: null;
60846150
const scope = projectPreviewScopes.mint(projectId, previewWorkspace);
6151+
const expiresAt = projectPreviewScopes.expiresAt(projectId, scope);
6152+
if (expiresAt === undefined) return html;
60856153
return injectProjectPreviewBase(
60866154
html,
60876155
projectId,
60886156
relPath,
60896157
scope,
6158+
expiresAt,
60906159
);
60916160
},
60926161
true, // revalidate: emit ETag/Last-Modified so covers/preview/export reuse cached assets

apps/daemon/src/server-context.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ export interface ProjectPreviewScopeDeps {
100100
options?: { readonly ttlMs?: number },
101101
) => string;
102102
revoke: (scope: string) => void;
103+
expiresAt: (projectId: string, scope: string) => number | undefined;
104+
renew: (
105+
projectId: string,
106+
scope: string,
107+
options?: { readonly ttlMs?: number },
108+
) => number | undefined;
103109
validate: (projectId: string, scope: string) => boolean;
104110
resolve: (
105111
projectId: string,

apps/daemon/src/server.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2157,6 +2157,29 @@ function createProjectPreviewScopeRegistry() {
21572157
revoke(scope) {
21582158
scopes.delete(String(scope || ''));
21592159
},
2160+
expiresAt(projectId, scope) {
2161+
const key = String(scope || '');
2162+
const entry = scopes.get(key);
2163+
if (!entry) return undefined;
2164+
if (entry.expiresAt <= Date.now()) {
2165+
scopes.delete(key);
2166+
return undefined;
2167+
}
2168+
if (entry.projectId !== String(projectId)) return undefined;
2169+
return entry.expiresAt;
2170+
},
2171+
renew(projectId, scope, options = {}) {
2172+
const key = String(scope || '');
2173+
const entry = scopes.get(key);
2174+
if (!entry) return undefined;
2175+
if (entry.expiresAt <= Date.now()) {
2176+
scopes.delete(key);
2177+
return undefined;
2178+
}
2179+
if (entry.projectId !== String(projectId)) return undefined;
2180+
entry.expiresAt = Date.now() + (options.ttlMs ?? PROJECT_PREVIEW_SCOPE_TTL_MS);
2181+
return entry.expiresAt;
2182+
},
21602183
validate(projectId, scope) {
21612184
const key = String(scope || '');
21622185
const entry = scopes.get(key);

apps/daemon/tests/project-preview-containment.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ describe('project preview containment routes', () => {
112112
csp: string;
113113
iframeSandbox: string;
114114
opaqueOrigin: true;
115+
expiresAt: number;
115116
};
116117

117118
expect(body.file).toBe('pages/index.html');
@@ -123,6 +124,25 @@ describe('project preview containment routes', () => {
123124
expect(body.csp).toContain("connect-src 'none'");
124125
expect(body.csp).not.toContain('allow-same-origin');
125126
expect(body.opaqueOrigin).toBe(true);
127+
expect(body.expiresAt).toBeGreaterThan(Date.now());
128+
129+
const renewalScope = /\/preview\/([^/]+)\//u.exec(body.url)?.[1];
130+
expect(renewalScope).toBeTruthy();
131+
const rejectedRenewal = await fetch(
132+
`${baseUrl}/api/projects/${projectId}/preview/${renewalScope}/renew`,
133+
{ method: 'POST' },
134+
);
135+
expect(rejectedRenewal.status).toBe(403);
136+
const renewal = await fetch(
137+
`${baseUrl}/api/projects/${projectId}/preview/${renewalScope}/renew`,
138+
{
139+
method: 'POST',
140+
headers: { 'x-od-preview-scope-renewal': '1' },
141+
},
142+
);
143+
expect(renewal.status).toBe(200);
144+
const renewed = await renewal.json() as { expiresAt: number };
145+
expect(renewed.expiresAt).toBeGreaterThanOrEqual(body.expiresAt);
126146

127147
const previewResponse = await fetch(`${baseUrl}${body.url}`, {
128148
headers: { Origin: 'null' },
@@ -327,6 +347,19 @@ describe('project preview containment routes', () => {
327347
expect(baseHref).toMatch(
328348
new RegExp(`^/api/projects/${projectId}/preview/[A-Za-z0-9_-]{8,128}/$`, 'u'),
329349
);
350+
expect(html).toContain('data-od-project-preview-base');
351+
expect(html).toContain('data-od-preview-base-bridge');
352+
expect(html).toContain("type: 'od:preview-base-scope'");
353+
354+
const workspaceScope = /\/preview\/([^/]+)\//u.exec(baseHref!)?.[1];
355+
const workspaceRenewal = await fetch(
356+
`${baseUrl}/api/projects/${projectId}/preview/${workspaceScope}/renew`,
357+
{
358+
method: 'POST',
359+
headers: { 'x-od-preview-scope-renewal': '1' },
360+
},
361+
);
362+
expect(workspaceRenewal.status).toBe(200);
330363

331364
// The browser resolves runtime-created `img.src = "logos/mark.png"`
332365
// against <base>. A query-scoped raw document cannot do this because URL

0 commit comments

Comments
 (0)