@fusion.dev/helix 0.199.1 → 0.201.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.
@@ -173,6 +173,8 @@ registerElementEvents({
173
173
  'helix-reorder',
174
174
  'input',
175
175
  ],
176
+ 'helix-log': ['helix-before-pin', 'helix-expand', 'helix-pin'],
177
+ 'helix-log-group': ['helix-before-close', 'helix-before-open', 'toggle'],
176
178
  'helix-map': [
177
179
  'change',
178
180
  'helix-activate',
package/dist/index.d.ts CHANGED
@@ -60,6 +60,7 @@ export * from './line-chart/index.js';
60
60
  export * from './list/index.js';
61
61
  export * from './listbox/index.js';
62
62
  export * from './loader/index.js';
63
+ export * from './log/index.js';
63
64
  export * from './map/index.js';
64
65
  export * from './megamenu/index.js';
65
66
  export * from './menu/filter/index.js';
package/dist/index.js CHANGED
@@ -55,6 +55,7 @@ export * from './line-chart/index.js';
55
55
  export * from './list/index.js';
56
56
  export * from './listbox/index.js';
57
57
  export * from './loader/index.js';
58
+ export * from './log/index.js';
58
59
  export * from './map/index.js';
59
60
  export * from './megamenu/index.js';
60
61
  export * from './menu/filter/index.js';
@@ -0,0 +1 @@
1
+ export declare const styles: import("lit").CSSResult;
@@ -0,0 +1,2 @@
1
+ import { createSheet } from '@fusion.dev/helix-core';
2
+ export const styles = createSheet(`:host {display: block}[part="summary"] {all: unset;box-sizing: border-box;align-items: center;color: var(--helix-color-foreground);column-gap: var(--helix-space-xs);cursor: pointer;display: flex;font-family: var(--helix-font-sans);font-weight: var(--helix-weight-medium);inline-size: 100%;padding-block: var(--helix-space-2xs);padding-inline: var(--log-padding-inline, var(--helix-space-md));white-space: nowrap}[part="summary"]:hover {background-color: color-mix(in oklab, var(--helix-color-foreground) 5%, transparent)}[part="summary"]:focus-visible {outline: var(--helix-size-focus-ring) solid var(--helix-color-focus-ring);outline-offset: calc(var(--helix-size-focus-offset) * -1)}[part="mark"] {block-size: var(--helix-size-icon-sm);color: var(--helix-color-foreground-subtle);flex: none;font-family: var(--log-font-family, var(--helix-font-mono));inline-size: var(--helix-size-icon-sm);margin-inline-end: calc(var(--helix-space-md) - var(--helix-space-xs));margin-inline-start: calc(max(var(--helix-size-icon-sm), calc(var(--log-digits, 2) * 1ch)) - var(--helix-size-icon-sm));rotate: -90deg;transition: rotate var(--helix-duration-fast) var(--helix-easing-standard)}:host(:dir(rtl)) [part="mark"] {rotate: 90deg}:host(:state(open)) [part="mark"] {rotate: none}[part="status"] {block-size: var(--helix-size-icon-sm);color: var(--helix-color-foreground-subtle);flex: none;inline-size: var(--helix-size-icon-sm)}:host([status="done"]) [part="status"] {color: var(--helix-color-success)}:host([status="failed"]) [part="status"] {color: var(--helix-color-danger)}:host([status="running"]) [part="status"] {color: var(--helix-color-info)}[part="heading"] {min-inline-size: 0;overflow: hidden;text-overflow: ellipsis}[part="duration"] {color: var(--helix-color-foreground-muted);flex: none;font-variant-numeric: tabular-nums;font-weight: var(--helix-weight-regular);margin-inline-start: auto}[part="content"][hidden] {display: none}@media print {:host {print-color-adjust: economy}}`);
@@ -0,0 +1,24 @@
1
+ import { HelixHeadlessLogGroup } from '@fusion.dev/helix-headless/log';
2
+ import type { CSSResultGroup } from 'lit';
3
+ /**
4
+ * @summary One step of a log, which folds its lines under a heading with its status and how long it took.
5
+ *
6
+ * @description
7
+ * The heading is a row in the sans face with a caret, the status mark in its
8
+ * color, the heading, and the duration at the far end. A step that is `done`
9
+ * takes the success color, one that `failed` the danger color, and one that
10
+ * is `running` the info color.
11
+ *
12
+ * @since 0.199.0
13
+ * @status experimental
14
+ * @documentation https://fusion.dev/helix/components/log
15
+ */
16
+ export declare class HelixLogGroup extends HelixHeadlessLogGroup {
17
+ static tier: string;
18
+ static styles: CSSResultGroup;
19
+ }
20
+ declare global {
21
+ interface HTMLElementTagNameMap {
22
+ 'helix-log-group': HelixLogGroup;
23
+ }
24
+ }
@@ -0,0 +1,12 @@
1
+ import { __decorate } from "tslib";
2
+ import { customElement } from '@fusion.dev/helix-core';
3
+ import { HelixHeadlessLogGroup } from '@fusion.dev/helix-headless/log';
4
+ import { styles } from './log-group.css.js';
5
+ let HelixLogGroup = class HelixLogGroup extends HelixHeadlessLogGroup {
6
+ static { this.tier = 'helix-'; }
7
+ static { this.styles = styles; }
8
+ };
9
+ HelixLogGroup = __decorate([
10
+ customElement('helix-log-group')
11
+ ], HelixLogGroup);
12
+ export { HelixLogGroup };
@@ -0,0 +1,3 @@
1
+ export { HelixLogGroup } from './group/log-group.js';
2
+ export { HelixLogLine } from './line/log-line.js';
3
+ export { HelixLog } from './root/log-root.js';
@@ -0,0 +1,3 @@
1
+ export { HelixLogGroup } from './group/log-group.js';
2
+ export { HelixLogLine } from './line/log-line.js';
3
+ export { HelixLog } from './root/log-root.js';
@@ -0,0 +1 @@
1
+ export declare const styles: import("lit").CSSResult;
@@ -0,0 +1,2 @@
1
+ import { createSheet } from '@fusion.dev/helix-core';
2
+ export const styles = createSheet(`:host {display: block}[part~="line"] {column-gap: var(--helix-space-md);display: flex;inline-size: var(--_log-line-size, max-content);min-inline-size: 100%;padding-inline: var(--log-padding-inline, var(--helix-space-md))}[part~="line"]:hover {background-color: color-mix(in oklab, var(--helix-color-foreground) 4%, transparent)}[part="number"] {color: var(--helix-color-foreground-muted);flex: none;font-variant-numeric: tabular-nums;min-inline-size: max(var(--helix-size-icon-sm), calc(var(--log-digits, 2) * 1ch));text-align: end;text-decoration: none;user-select: none}a[part="number"]:hover {color: var(--helix-color-foreground);text-decoration: underline}a[part="number"]:focus-visible {border-radius: var(--helix-radii-sm);outline: var(--helix-size-focus-ring) solid var(--helix-color-focus-ring);outline-offset: calc(var(--helix-size-focus-offset) * -1)}[part="time"] {color: var(--helix-color-foreground-muted);flex: none;font-variant-numeric: tabular-nums;user-select: none}[part="text"] {flex: 1 1 auto;min-inline-size: 0;overflow-wrap: anywhere;white-space: var(--_log-white-space, pre)}[part~="line-debug"] {color: var(--helix-color-foreground-subtle)}[part~="line-warn"] {background-color: color-mix(in oklab, var(--log-warn-color, var(--helix-color-warning)) 10%, transparent);color: var(--log-warn-color, var(--helix-color-warning))}[part~="line-error"] {background-color: color-mix(in oklab, var(--log-error-color, var(--helix-color-danger)) 10%, transparent);color: var(--log-error-color, var(--helix-color-danger))}[part~="line-marked"] {background-color: var(--log-mark-background-color, color-mix(in oklab, var(--helix-color-primary) 12%, transparent))}[part~="run"] {background-color: var(--log-run-background-color, transparent);color: var(--log-run-color, inherit)}[part~="run-bold"] {font-weight: var(--helix-weight-semibold)}[part~="run-dim"] {opacity: 0.7}[part~="run-italic"] {font-style: italic}[part~="run-underline"] {text-decoration-line: underline}[part~="run-strike"] {text-decoration-line: line-through}[part~="run-underline"][part~="run-strike"] {text-decoration-line: underline line-through}[part="match"] {background-color: var(--log-match-background-color, var(--helix-color-warning-surface));border-radius: var(--helix-radii-sm);color: inherit}@media print {[part~="line"] {print-color-adjust: exact}}`);
@@ -0,0 +1,28 @@
1
+ import { HelixHeadlessLogLine } from '@fusion.dev/helix-headless/log';
2
+ import type { CSSResultGroup } from 'lit';
3
+ /**
4
+ * @summary One line of a log, with its number, its time, and its text.
5
+ *
6
+ * @description
7
+ * A row with the number, the time, and the text side by side. A line at
8
+ * `warn` or `error` takes a band of its color, and a line `mark-lines` names
9
+ * takes the band `Code Block` draws behind a marked line.
10
+ *
11
+ * @since 0.199.0
12
+ * @status experimental
13
+ * @documentation https://fusion.dev/helix/components/log
14
+ *
15
+ * @cssproperty --log-error-color - The color of a line at `error`, and of the band behind it. Defaults to the danger color.
16
+ * @cssproperty --log-mark-background-color - The band behind a line `mark-lines` names.
17
+ * @cssproperty --log-match-background-color - The fill behind text that matches the log's `query`.
18
+ * @cssproperty --log-warn-color - The color of a line at `warn`, and of the band behind it. Defaults to the warning color.
19
+ */
20
+ export declare class HelixLogLine extends HelixHeadlessLogLine {
21
+ static tier: string;
22
+ static styles: CSSResultGroup;
23
+ }
24
+ declare global {
25
+ interface HTMLElementTagNameMap {
26
+ 'helix-log-line': HelixLogLine;
27
+ }
28
+ }
@@ -0,0 +1,12 @@
1
+ import { __decorate } from "tslib";
2
+ import { customElement } from '@fusion.dev/helix-core';
3
+ import { HelixHeadlessLogLine } from '@fusion.dev/helix-headless/log';
4
+ import { styles } from './log-line.css.js';
5
+ let HelixLogLine = class HelixLogLine extends HelixHeadlessLogLine {
6
+ static { this.tier = 'helix-'; }
7
+ static { this.styles = styles; }
8
+ };
9
+ HelixLogLine = __decorate([
10
+ customElement('helix-log-line')
11
+ ], HelixLogLine);
12
+ export { HelixLogLine };
@@ -0,0 +1 @@
1
+ export declare const styles: import("lit").CSSResult;
@@ -0,0 +1,2 @@
1
+ import { createSheet } from '@fusion.dev/helix-core';
2
+ export const styles = createSheet(`:host {--log-ansi-background: var(--log-background-color, var(--helix-color-background));--log-ansi-black: var(--helix-color-foreground);--log-ansi-blue: var(--helix-color-info);--log-ansi-bright-black: var(--helix-color-foreground-subtle);--log-ansi-bright-blue: var(--helix-color-info);--log-ansi-bright-cyan: color-mix(in oklab, var(--helix-color-info), var(--helix-color-success));--log-ansi-bright-green: var(--helix-color-success);--log-ansi-bright-magenta: var(--helix-color-syntax-callable);--log-ansi-bright-red: var(--helix-color-danger);--log-ansi-bright-white: var(--helix-color-foreground);--log-ansi-bright-yellow: var(--helix-color-warning);--log-ansi-cyan: color-mix(in oklab, var(--helix-color-info), var(--helix-color-success));--log-ansi-foreground: var(--log-color, var(--helix-color-foreground));--log-ansi-green: var(--helix-color-success);--log-ansi-magenta: var(--helix-color-syntax-callable);--log-ansi-red: var(--helix-color-danger);--log-ansi-white: var(--helix-color-foreground-muted);--log-ansi-yellow: var(--helix-color-warning);background-color: var(--log-background-color, var(--helix-color-background));border: var(--helix-border-thin) solid var(--log-border-color, var(--helix-color-border));border-radius: var(--log-radius, var(--helix-radii-lg));color: var(--log-color, var(--helix-color-foreground));display: grid;font-family: var(--log-font-family, var(--helix-font-mono));font-size: var(--log-font-size, var(--helix-text-xs));grid-template-columns: minmax(0, 1fr);grid-template-rows: auto auto minmax(0, 1fr) auto;line-height: var(--log-line-height, var(--helix-leading-normal));max-block-size: var(--log-max-block-size, 30rem);min-block-size: 0;overflow: hidden;tab-size: 4}:host([wrap]) {--_log-line-size: auto;--_log-white-space: pre-wrap}::slotted([slot="toolbar"]) {--helix-size-control: var(--helix-size-control-sm);--helix-size-control-loose: calc(var(--helix-size-control) * 1.2);--helix-size-control-tight: calc(var(--helix-size-control) * 0.8);--helix-size-icon: var(--helix-size-icon-sm);--helix-size-icon-loose: calc(var(--helix-size-icon) * 1.2);--helix-size-icon-tight: calc(var(--helix-size-icon) * 0.8);border-block-end: var(--helix-border-thin) solid var(--log-border-color, var(--helix-color-border));font-family: var(--helix-font-sans);grid-row: 1;padding-block: var(--helix-space-xs);padding-inline: var(--log-padding-inline, var(--helix-space-md))}[part="dropped"] {background-color: var(--helix-color-surface-sunken);border-block-end: var(--helix-border-thin) solid var(--log-border-color, var(--helix-color-border));color: var(--helix-color-foreground-muted);font-family: var(--helix-font-sans);grid-row: 2;margin: var(--helix-space-none);padding-block: var(--helix-space-2xs);padding-inline: var(--log-padding-inline, var(--helix-space-md))}[part="dropped"][hidden], [part="resume"][hidden] {display: none}[part="log"] {grid-row: 3;overflow: auto;overscroll-behavior: contain;padding-block: var(--helix-space-xs);scrollbar-color: var(--log-scrollbar-thumb-color, var(--helix-scrollbar-thumb-color)) var(--log-scrollbar-track-color, var(--helix-scrollbar-track-color));scrollbar-width: thin}[part="log"]:focus-visible {outline: var(--helix-size-focus-ring) solid var(--helix-color-focus-ring);outline-offset: calc(var(--helix-size-focus-offset) * -1)}[part="row"] {min-inline-size: 100%;inline-size: var(--_log-line-size, max-content)}[part="resume"] {display: grid;grid-row: 4;justify-items: center;margin-block-start: calc(var(--helix-space-xl) * -1 - var(--helix-space-xs));padding-block-end: var(--helix-space-xs);pointer-events: none;position: relative}[part="resume"] > * {pointer-events: auto}[part="resume"] slot::slotted(*) {border: var(--helix-border-floating) solid var(--log-border-color, var(--helix-color-border));box-shadow: var(--helix-shadow-floating)}[part="resume-button"] {all: unset;box-sizing: border-box;align-items: center;background-color: var(--helix-color-surface-raised);block-size: var(--helix-size-control-sm);border: var(--helix-border-floating) solid var(--log-border-color, var(--helix-color-border));border-radius: var(--helix-radii-sm);box-shadow: var(--helix-shadow-floating);color: var(--helix-color-foreground);cursor: pointer;display: inline-flex;font-family: var(--helix-font-sans);font-size: var(--helix-text-xs);font-weight: var(--helix-weight-medium);gap: var(--helix-space-2xs);line-height: var(--helix-leading-tight);padding-inline: var(--helix-space-sm);white-space: nowrap}[part="resume-button"]:hover {background-color: color-mix(in oklab, var(--helix-color-foreground) 6%, var(--helix-color-surface-raised))}[part="resume-button"]:focus-visible {outline: var(--helix-size-focus-ring) solid var(--helix-color-focus-ring);outline-offset: var(--helix-size-focus-offset)}[part="resume-mark"] {block-size: var(--helix-size-icon-sm);display: inline-flex;inline-size: var(--helix-size-icon-sm)}[part="resume-mark"] svg {block-size: 100%;inline-size: 100%}[part="empty"], [part="loading"] {--_collection-inset-block: var(--helix-space-lg);--_collection-inset-inline: var(--helix-space-md)}@media print {:host {max-block-size: none}[part="log"] {overflow: visible}[part="resume"] {display: none}}`);
@@ -0,0 +1,47 @@
1
+ import { HelixHeadlessLog } from '@fusion.dev/helix-headless/log';
2
+ import type { CSSResultGroup } from 'lit';
3
+ import '../group/log-group.js';
4
+ import '../line/log-line.js';
5
+ /**
6
+ * @summary Output that arrives while you watch it, such as a CI run, a deployment, or a build, in a box that follows the newest line.
7
+ *
8
+ * @description
9
+ * A bordered box in the monospaced face, at the size of the page's small
10
+ * text, that scrolls after 30rem (`--log-max-block-size`). Lines keep their
11
+ * spaces and run past the edge of the box, which scrolls sideways, until
12
+ * `wrap` is set.
13
+ *
14
+ * The sixteen ANSI colors come from your theme: red is the danger color,
15
+ * green success, yellow warning, and blue info. Set `--log-ansi-red` and its
16
+ * siblings on the log to choose them yourself.
17
+ *
18
+ * Put a search field or the controls for the log in the `toolbar` slot, in
19
+ * one element such as a `Toolbar` or a `Flex`. It sits above the lines with a
20
+ * rule under it.
21
+ *
22
+ * @since 0.199.0
23
+ * @status experimental
24
+ * @category feedback
25
+ * @documentation https://fusion.dev/helix/components/log
26
+ *
27
+ * @cssproperty --log-background-color - The fill behind the lines. Defaults to the page background.
28
+ * @cssproperty --log-border-color - The edge around the log and the rules inside it.
29
+ * @cssproperty --log-color - The text color of the lines.
30
+ * @cssproperty --log-font-family - The face of the lines. Defaults to the monospaced face.
31
+ * @cssproperty --log-font-size - The size of the lines. Defaults to the small text step.
32
+ * @cssproperty --log-line-height - The height of one line.
33
+ * @cssproperty --log-max-block-size - How tall the log may get before the lines scroll. Defaults to 30rem.
34
+ * @cssproperty --log-padding-inline - Space either side of every line and heading.
35
+ * @cssproperty --log-radius - The corners of the log.
36
+ * @cssproperty --log-scrollbar-thumb-color - The thumb of the scrollbar. Defaults to `--helix-scrollbar-thumb-color`.
37
+ * @cssproperty --log-scrollbar-track-color - The track of the scrollbar. Defaults to `--helix-scrollbar-track-color`.
38
+ */
39
+ export declare class HelixLog extends HelixHeadlessLog {
40
+ static tier: string;
41
+ static styles: CSSResultGroup;
42
+ }
43
+ declare global {
44
+ interface HTMLElementTagNameMap {
45
+ 'helix-log': HelixLog;
46
+ }
47
+ }
@@ -0,0 +1,16 @@
1
+ import { __decorate } from "tslib";
2
+ import { customElement } from '@fusion.dev/helix-core';
3
+ import { HelixHeadlessLog } from '@fusion.dev/helix-headless/log';
4
+ import { styles as collectionStateStyles } from '../../shared/collection/state.css.js';
5
+ import { styles as liveStyles } from '../../shared/live/live.css.js';
6
+ import { styles } from './log-root.css.js';
7
+ import '../group/log-group.js';
8
+ import '../line/log-line.js';
9
+ let HelixLog = class HelixLog extends HelixHeadlessLog {
10
+ static { this.tier = 'helix-'; }
11
+ static { this.styles = [liveStyles, collectionStateStyles, styles]; }
12
+ };
13
+ HelixLog = __decorate([
14
+ customElement('helix-log')
15
+ ], HelixLog);
16
+ export { HelixLog };
@@ -14,6 +14,7 @@
14
14
  */
15
15
  export { isRender, LAYOUT_RULES, loggedText, type ServePreviewOptions, servePreview, withoutStylesheets, } from './frame.js';
16
16
  export * from './preview/sandbox-preview.js';
17
+ export type { PreviewFetch, PreviewFetched } from './relay.js';
17
18
  export * from './root/sandbox-root.js';
18
19
  export { type ManifestLike, manifestWords } from './source/editing.js';
19
20
  export * from './source/sandbox-source.js';
@@ -1,6 +1,7 @@
1
1
  import { HelixElement } from '@fusion.dev/helix-core';
2
2
  import type { CSSResultGroup } from 'lit';
3
3
  import { type PropertyValues, type TemplateResult } from 'lit';
4
+ import { type PreviewFetch } from '../relay.js';
4
5
  /** The frame saying it has a listener, and where to answer it. */
5
6
  export interface PreviewReady {
6
7
  type: 'helix-preview-ready';
@@ -39,7 +40,7 @@ export interface PreviewSize {
39
40
  size: number;
40
41
  type: 'helix-preview-size';
41
42
  }
42
- export type PreviewMessage = PreviewConsole | PreviewPress | PreviewReady | PreviewRender | PreviewSize;
43
+ export type PreviewMessage = PreviewConsole | PreviewFetch | PreviewPress | PreviewReady | PreviewRender | PreviewSize;
43
44
  /**
44
45
  * @summary A frame that renders sandbox source in a separate document.
45
46
  *
@@ -106,6 +107,21 @@ export type PreviewMessage = PreviewConsole | PreviewPress | PreviewReady | Prev
106
107
  * reader reads it with the frame. Hide it with `::part(error) { display: none }`
107
108
  * where the page draws its own.
108
109
  *
110
+ * **`relay` lets a frame on a login-protected site load its own files.** The
111
+ * frame's opaque origin makes every request it sends cross-site, and a login
112
+ * cookie is not sent on one, so behind a login such as a Vercel preview the
113
+ * frame's script and stylesheet come back as the login page. With `relay` set
114
+ * to path prefixes, such as `/sandbox/ /fonts/`, the frame can ask this
115
+ * element for a file under them and gets the body back, fetched from this
116
+ * page with its cookies. The page fetches only a `GET` on its own origin,
117
+ * under those prefixes, in plain characters, following no redirect, and it
118
+ * passes on only a script, a stylesheet, a font, a model, or an image of up
119
+ * to 8 MB. Name only prefixes that hold files the site serves to anyone: an
120
+ * example in the frame is untrusted, and it can ask too. Empty, the default,
121
+ * relays nothing. A frame asks only while its own origin is opaque, so a page
122
+ * elsewhere that frames the same document without a sandbox cannot use the
123
+ * answer to run script on the site.
124
+ *
109
125
  * **`controls` draws two buttons in the preview's corner**: one reloads the
110
126
  * frame's document, as `reload()` does, and one opens the example in a new
111
127
  * tab, as `open()` does. The tab runs it in a frame sandboxed as this one is.
@@ -211,6 +227,12 @@ export declare class HelixSandboxPreview extends HelixElement {
211
227
  * `controls`.
212
228
  */
213
229
  openLabel: string;
230
+ /**
231
+ * Path prefixes the frame may ask this page to fetch for it, separated by
232
+ * spaces, each starting and ending with a slash, such as `/sandbox/ /fonts/`.
233
+ * Empty relays nothing.
234
+ */
235
+ relay: string;
214
236
  /**
215
237
  * The name of the button that reloads the frame's document, under
216
238
  * `controls`.
@@ -252,6 +274,18 @@ export declare class HelixSandboxPreview extends HelixElement {
252
274
  * imports.
253
275
  */
254
276
  private map;
277
+ /** How many files the frame has asked for that are still being fetched. */
278
+ private relaying;
279
+ /**
280
+ * Fetches a file the frame asked for and sends it back to the window that
281
+ * asked, or says it could not.
282
+ *
283
+ * The address is judged by `relayAddress` against this page's origin and the
284
+ * prefixes in `relay`, so a request this page would not make is answered
285
+ * with a refusal and never sent. The frame document's address is the base,
286
+ * so a relative path means what it meant there.
287
+ */
288
+ private answer;
255
289
  /** Says the preview failed with `text`, or, with none, that it has not. */
256
290
  private fail;
257
291
  /** What the frame is sent as its source: the markup, and the script after it. */
@@ -6,6 +6,7 @@ import { html, nothing } from 'lit';
6
6
  import { property, state } from 'lit/decorators.js';
7
7
  import { COMPILED, compile } from '../compile.js';
8
8
  import { defaultFrame, withScript } from '../frame.js';
9
+ import { isFetch, RELAY_OPEN, relayAddress, relayFetch, relayPrefixes, } from '../relay.js';
9
10
  import { styles as sheet } from './sandbox-preview.css.js';
10
11
  const SANDBOX = 'allow-scripts allow-forms';
11
12
  const RELATIVE = /(\bfrom\s*|\bimport\s*\(?\s*)(["'])(\.{1,2}\/[^"']+)\2/g;
@@ -17,6 +18,7 @@ let HelixSandboxPreview = class HelixSandboxPreview extends HelixElement {
17
18
  this.controls = false;
18
19
  this.label = 'Preview';
19
20
  this.openLabel = 'Open preview in a new tab';
21
+ this.relay = '';
20
22
  this.reloadLabel = 'Reload preview';
21
23
  this.script = '';
22
24
  this.source = '';
@@ -26,6 +28,7 @@ let HelixSandboxPreview = class HelixSandboxPreview extends HelixElement {
26
28
  this.compiled = new Map();
27
29
  this.compiling = new Set();
28
30
  this.map = {};
31
+ this.relaying = 0;
29
32
  this.handleFrameLoad = () => {
30
33
  this.ready = true;
31
34
  this.toggleState('loading', false);
@@ -36,6 +39,10 @@ let HelixSandboxPreview = class HelixSandboxPreview extends HelixElement {
36
39
  return;
37
40
  }
38
41
  const data = event.data;
42
+ if (isFetch(data)) {
43
+ void this.answer(data, event.source);
44
+ return;
45
+ }
39
46
  if (data?.type === 'helix-preview-ready') {
40
47
  this.handleFrameLoad();
41
48
  return;
@@ -59,6 +66,27 @@ let HelixSandboxPreview = class HelixSandboxPreview extends HelixElement {
59
66
  }
60
67
  static { HelixSandboxPreview_1 = this; }
61
68
  static { this.words = 'helix-sandbox-preview'; }
69
+ async answer(request, to) {
70
+ const reply = (fetched) => {
71
+ const message = { ...fetched, id: request.id, type: 'helix-preview-fetched' };
72
+ const transfer = fetched.body instanceof ArrayBuffer ? [fetched.body] : [];
73
+ to.postMessage(message, { targetOrigin: HelixSandboxPreview_1.target, transfer });
74
+ };
75
+ const base = new URL(this.src || '/', location.href).href;
76
+ const url = relayAddress(request.path, base, location.origin, relayPrefixes(this.relay));
77
+ if (!url || this.relaying >= RELAY_OPEN) {
78
+ reply({ ok: false });
79
+ return;
80
+ }
81
+ this.relaying += 1;
82
+ try {
83
+ const fetched = await relayFetch(url, request.as);
84
+ reply(fetched ? { ...fetched, ok: true } : { ok: false });
85
+ }
86
+ finally {
87
+ this.relaying -= 1;
88
+ }
89
+ }
62
90
  fail(text) {
63
91
  this.failure = text;
64
92
  this.toggleState('failed', text !== undefined);
@@ -155,6 +183,15 @@ let HelixSandboxPreview = class HelixSandboxPreview extends HelixElement {
155
183
  else {
156
184
  frame.src = this.frame?.src || this.src;
157
185
  }
186
+ if (relayPrefixes(this.relay).length > 0) {
187
+ tab.addEventListener('message', (event) => {
188
+ if (event.source === frame.contentWindow &&
189
+ event.origin === 'null' &&
190
+ isFetch(event.data)) {
191
+ void this.answer(event.data, event.source);
192
+ }
193
+ });
194
+ }
158
195
  const send = tab.document.createElement('script');
159
196
  send.textContent = `const frame = document.currentScript.previousElementSibling
160
197
  frame.onload = () => frame.contentWindow.postMessage(${JSON.stringify(this.message)}, '*')`;
@@ -270,6 +307,9 @@ __decorate([
270
307
  words,
271
308
  property({ attribute: 'open-label' })
272
309
  ], HelixSandboxPreview.prototype, "openLabel", void 0);
310
+ __decorate([
311
+ property()
312
+ ], HelixSandboxPreview.prototype, "relay", void 0);
273
313
  __decorate([
274
314
  words,
275
315
  property({ attribute: 'reload-label' })
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The page's half of a relay that fetches a frame's own files for it.
3
+ *
4
+ * **A sandboxed frame cannot always load its own files.** It has an opaque
5
+ * origin, so every request it makes is cross-site. Where the site sits behind
6
+ * a login, such as a Vercel preview, the cookie that proves the login is not
7
+ * sent on a cross-site request, and the frame's script, its stylesheet, its
8
+ * fonts, and its models come back as the login page. The page around the frame
9
+ * is on the site's own origin and sends the cookie, so the frame asks it.
10
+ *
11
+ * **What the frame can ask for is narrow, and the page decides it.** A path
12
+ * is fetched only where it resolves to the page's own origin, under one of the
13
+ * prefixes the page named, in plain characters, with a `GET` that follows no
14
+ * redirect, and the answer is passed on only when it is a script, a
15
+ * stylesheet, a font, a model, or an image under the size limit. An example in
16
+ * the frame is untrusted, and with this it can read only what the site already
17
+ * serves to anyone under those prefixes.
18
+ */
19
+ /** A file the frame asks the page to fetch for it. */
20
+ export interface PreviewFetch {
21
+ /** Whether the frame wants the body as text, for a script or a stylesheet, or as bytes. */
22
+ as: 'bytes' | 'text';
23
+ /** What the answer carries back, so the frame can match it to the request. */
24
+ id: string;
25
+ /** The file, as a path on the site, such as `/sandbox/frame.js?v=1a2b3c4d`. */
26
+ path: string;
27
+ type: 'helix-preview-fetch';
28
+ }
29
+ /** The page's answer to a `PreviewFetch`. */
30
+ export interface PreviewFetched {
31
+ /** The file's body, where the page fetched it. */
32
+ body?: ArrayBuffer | string;
33
+ /** The file's content type, where the page fetched it. */
34
+ contentType?: string;
35
+ /** The `id` of the request it answers. */
36
+ id: string;
37
+ /** Whether the page fetched the file. */
38
+ ok: boolean;
39
+ type: 'helix-preview-fetched';
40
+ }
41
+ /** The most bytes the page passes on for one file: four times the largest the site serves. */
42
+ export declare const RELAY_LIMIT: number;
43
+ /** The most requests one frame may have open at once. */
44
+ export declare const RELAY_OPEN = 16;
45
+ /** Whether `data` is a request the frame sent, in the shape the page reads. */
46
+ export declare const isFetch: (data: unknown) => data is PreviewFetch;
47
+ /**
48
+ * The prefixes a `relay` value names: each a path that starts and ends with a
49
+ * slash, such as `/sandbox/`. Anything else in the value is ignored, so `/`
50
+ * alone, which would be the whole site, is not a prefix.
51
+ */
52
+ export declare const relayPrefixes: (value: string) => string[];
53
+ /**
54
+ * The address the page fetches for `path`, or undefined where it may not.
55
+ *
56
+ * `path` is resolved against `base`, the frame document's address, so a
57
+ * relative path the frame wrote means what it meant there. The answer has to
58
+ * be on `origin`, the page's own, under one of `prefixes`, with no user, no
59
+ * password, and no fragment. The resolved path is held to plain characters, so
60
+ * an encoded slash or dot, which a server may decode after this check, is
61
+ * refused rather than trusted.
62
+ */
63
+ export declare const relayAddress: (path: string, base: string, origin: string, prefixes: readonly string[]) => URL | undefined;
64
+ /**
65
+ * Fetches a file the frame asked for, from an address `relayAddress` allowed,
66
+ * and returns it, or undefined where it is not one to pass on.
67
+ *
68
+ * Same origin only, with the page's cookies, as a `GET` that follows no
69
+ * redirect: a login page redirecting is a refusal, never a file. The body is
70
+ * passed on only for a script, a stylesheet, a font, a model, or an image, and
71
+ * only under `RELAY_LIMIT`, counted on what arrived as well as on what the
72
+ * headers claimed.
73
+ */
74
+ export declare const relayFetch: (url: URL, as: PreviewFetch['as']) => Promise<Pick<PreviewFetched, 'body' | 'contentType'> | undefined>;
@@ -0,0 +1,72 @@
1
+ export const RELAY_LIMIT = 8 * 1024 * 1024;
2
+ export const RELAY_OPEN = 16;
3
+ const PATH = /^\/[A-Za-z0-9._~/-]*$/;
4
+ const QUERY = /^(?:\?[A-Za-z0-9=&._~-]*)?$/;
5
+ const KINDS = /^(?:text\/css|text\/javascript|application\/javascript|application\/x-javascript|font\/[a-z0-9.+-]+|model\/[a-z0-9.+-]+|image\/(?:png|jpeg|webp|avif|gif)|application\/octet-stream)(?:\s*;|$)/;
6
+ export const isFetch = (data) => {
7
+ if (typeof data !== 'object' || data === null) {
8
+ return false;
9
+ }
10
+ const { as, id, path, type } = data;
11
+ return (type === 'helix-preview-fetch' &&
12
+ (as === 'bytes' || as === 'text') &&
13
+ typeof id === 'string' &&
14
+ id.length > 0 &&
15
+ id.length <= 64 &&
16
+ typeof path === 'string');
17
+ };
18
+ export const relayPrefixes = (value) => value
19
+ .split(/\s+/)
20
+ .filter((prefix) => PATH.test(prefix) && prefix.length > 2 && prefix.endsWith('/'))
21
+ .filter((prefix) => !prefix.split('/').some((part) => part === '.' || part === '..'));
22
+ export const relayAddress = (path, base, origin, prefixes) => {
23
+ if (path.length === 0 || path.length > 1024 || prefixes.length === 0) {
24
+ return undefined;
25
+ }
26
+ let url;
27
+ try {
28
+ url = new URL(path, base);
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ if (url.origin !== origin ||
34
+ url.username !== '' ||
35
+ url.password !== '' ||
36
+ url.hash !== '' ||
37
+ !PATH.test(url.pathname) ||
38
+ !QUERY.test(url.search) ||
39
+ url.pathname.split('/').some((part) => part === '.' || part === '..')) {
40
+ return undefined;
41
+ }
42
+ return prefixes.some((prefix) => url.pathname.startsWith(prefix)) ? url : undefined;
43
+ };
44
+ export const relayFetch = async (url, as) => {
45
+ let response;
46
+ try {
47
+ response = await fetch(url, {
48
+ cache: 'default',
49
+ credentials: 'same-origin',
50
+ method: 'GET',
51
+ mode: 'same-origin',
52
+ redirect: 'error',
53
+ referrerPolicy: 'same-origin',
54
+ });
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
60
+ const claimed = Number(response.headers.get('content-length') ?? '0');
61
+ if (!response.ok || !KINDS.test(contentType) || claimed > RELAY_LIMIT) {
62
+ return undefined;
63
+ }
64
+ const bytes = await response.arrayBuffer();
65
+ if (bytes.byteLength > RELAY_LIMIT) {
66
+ return undefined;
67
+ }
68
+ return {
69
+ body: as === 'text' ? new TextDecoder().decode(bytes) : bytes,
70
+ contentType,
71
+ };
72
+ };