@botiverse/porthole 0.1.0 → 0.1.2

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.
Files changed (84) hide show
  1. package/dist/bridge/bridge.js +20 -6
  2. package/dist/bridge/types.d.ts +19 -3
  3. package/dist/compile/policy.d.ts +6 -5
  4. package/dist/compile/policy.js +10 -12
  5. package/dist/config/schema.d.ts +29 -0
  6. package/dist/config/schema.js +16 -1
  7. package/dist/config/validate.d.ts +3 -1
  8. package/dist/config/validate.js +55 -10
  9. package/dist/conformance/behavior.js +50 -0
  10. package/dist/conformance/index.d.ts +11 -5
  11. package/dist/conformance/index.js +2 -2
  12. package/dist/conformance/probes.d.ts +26 -2
  13. package/dist/conformance/probes.js +51 -40
  14. package/dist/conformance/served.d.ts +7 -0
  15. package/dist/conformance/served.js +31 -7
  16. package/dist/guide/guide.js +6 -2
  17. package/dist/host/create-host.js +46 -2
  18. package/dist/host/locate.js +6 -1
  19. package/dist/host/outcome.d.ts +25 -0
  20. package/dist/host/outcome.js +147 -0
  21. package/dist/host/types.d.ts +39 -2
  22. package/dist/index.d.ts +6 -3
  23. package/dist/index.js +4 -1
  24. package/dist/isolation/served.d.ts +3 -0
  25. package/dist/isolation/served.js +20 -0
  26. package/dist/isolation/types.d.ts +9 -0
  27. package/dist/isolation/types.js +2 -1
  28. package/dist/platform/types.d.ts +7 -0
  29. package/dist/platform/web.js +15 -2
  30. package/dist/presets/presets.d.ts +11 -3
  31. package/dist/presets/presets.js +17 -1
  32. package/dist/react/frame.d.ts +16 -0
  33. package/dist/react/frame.js +44 -10
  34. package/dist/server/crypto.d.ts +19 -0
  35. package/dist/server/crypto.js +144 -0
  36. package/dist/server/index.d.ts +12 -4
  37. package/dist/server/index.js +11 -3
  38. package/dist/server/keys.js +12 -9
  39. package/dist/server/naming.d.ts +17 -0
  40. package/dist/server/naming.js +34 -0
  41. package/dist/server/pages.d.ts +11 -6
  42. package/dist/server/pages.js +86 -12
  43. package/dist/server/server.js +191 -23
  44. package/dist/server/tickets.d.ts +24 -0
  45. package/dist/server/tickets.js +46 -0
  46. package/dist/server/types.d.ts +76 -5
  47. package/dist/shell/document.d.ts +3 -2
  48. package/dist/shell/document.js +4 -3
  49. package/dist/shell/plan.d.ts +2 -0
  50. package/dist/shell/plan.js +1 -0
  51. package/dist/shell/runtime.js +20 -4
  52. package/package.json +2 -9
  53. package/src/bridge/bridge.ts +28 -6
  54. package/src/bridge/types.ts +21 -3
  55. package/src/compile/policy.ts +10 -12
  56. package/src/config/schema.ts +38 -0
  57. package/src/config/validate.ts +63 -14
  58. package/src/conformance/behavior.ts +55 -0
  59. package/src/conformance/index.ts +18 -4
  60. package/src/conformance/probes.ts +88 -52
  61. package/src/conformance/served.ts +37 -8
  62. package/src/guide/guide.ts +8 -2
  63. package/src/host/create-host.ts +47 -2
  64. package/src/host/locate.ts +6 -1
  65. package/src/host/outcome.ts +177 -0
  66. package/src/host/types.ts +42 -2
  67. package/src/index.ts +19 -1
  68. package/src/isolation/served.ts +24 -0
  69. package/src/isolation/types.ts +15 -1
  70. package/src/platform/types.ts +7 -0
  71. package/src/platform/web.ts +15 -2
  72. package/src/presets/presets.ts +31 -3
  73. package/src/react/frame.tsx +78 -14
  74. package/src/server/crypto.ts +166 -0
  75. package/src/server/index.ts +20 -3
  76. package/src/server/keys.ts +21 -13
  77. package/src/server/naming.ts +42 -0
  78. package/src/server/pages.ts +106 -11
  79. package/src/server/server.ts +233 -23
  80. package/src/server/tickets.ts +65 -0
  81. package/src/server/types.ts +84 -7
  82. package/src/shell/document.ts +7 -6
  83. package/src/shell/plan.ts +3 -0
  84. package/src/shell/runtime.ts +20 -4
package/src/host/types.ts CHANGED
@@ -52,9 +52,44 @@ export interface MountOptions {
52
52
  }
53
53
 
54
54
  /** What a display reports, by event name. */
55
+ /** Why a display's frame showed nothing of its content (`FrameOutcome`). */
56
+ export type FrameBlock =
57
+ /** The page that frames it isn't one its server allows (frame-ancestors), or it never ran. */
58
+ | "framing"
59
+ /** The browser keeps no cookie for it inside a frame (WebKit, P025): open it on a tab. */
60
+ | "cookies"
61
+ /** Its ticket was used or its session ended. */
62
+ | "session"
63
+ /** Its app didn't answer. */
64
+ | "upstream"
65
+ /** The host's own check refused it (revoked, no longer a member): `code` says which. */
66
+ | "denied"
67
+ | "not-found"
68
+ | "error";
69
+
70
+ /**
71
+ * How a display's document went: it loaded (a shell or view finished its handshake; a url-mode
72
+ * app's document loaded), it was blocked (and why), or nothing settled in
73
+ * `limits.loadTimeoutMs`. Once per run, plus a later `blocked` if the frame shows a status page
74
+ * after it loaded (access revoked mid-session). A host shows its own fallback ("Open in a new
75
+ * tab") on anything but loaded.
76
+ */
77
+ export type FrameOutcome =
78
+ | { readonly state: "loaded" }
79
+ | {
80
+ readonly state: "blocked";
81
+ readonly reason: FrameBlock;
82
+ readonly status?: number;
83
+ /** The host's own reason code, for `denied` (`Authorizer`). */
84
+ readonly code?: string;
85
+ }
86
+ | { readonly state: "timeout" };
87
+
55
88
  export interface DisplayEvents {
56
- /** The frame finished its handshake. */
89
+ /** The frame finished its handshake (url mode, which has none: its first document loaded). */
57
90
  readonly initialized: undefined;
91
+ /** How the frame's first document went (`FrameOutcome`). */
92
+ readonly outcome: FrameOutcome;
58
93
  /** The content's size, after the config's limits. */
59
94
  readonly resize: { readonly height: number; readonly width?: number };
60
95
  /** What the content threw, or what the sandbox blocked (its own words: untrusted). */
@@ -69,8 +104,13 @@ export interface DisplayEvents {
69
104
 
70
105
  export interface DisplayControls {
71
106
  readonly displayId: string;
72
- /** The theme flipped: the frame is re-themed in place, without a reload. */
107
+ /**
108
+ * The theme flipped: the frame is re-themed in place, without a reload. The host's values are
109
+ * read two animation frames later, once a host that restyles its page asynchronously has.
110
+ */
73
111
  setTheme(scheme: ColorScheme): void;
112
+ /** The host's theme values changed (same scheme): read them now and re-theme the frame. */
113
+ refreshTheme(): void;
74
114
  on<E extends keyof DisplayEvents>(
75
115
  event: E,
76
116
  listener: (detail: DisplayEvents[E]) => void,
package/src/index.ts CHANGED
@@ -31,7 +31,21 @@ export type {
31
31
  ThemeConfig,
32
32
  ThemeValue,
33
33
  } from "./config/schema.ts";
34
- export { BUILT_IN_TOPICS, ConfigError, isOriginSource, validateConfig } from "./config/validate.ts";
34
+ export {
35
+ BUILT_IN_TOPICS,
36
+ ConfigError,
37
+ isOriginSource,
38
+ originSourceProblem,
39
+ validateConfig,
40
+ } from "./config/validate.ts";
41
+ export {
42
+ URL_PERMISSIONS,
43
+ URL_SANDBOX_EXTRAS,
44
+ type UrlPermission,
45
+ type UrlSandboxExtra,
46
+ } from "./config/schema.ts";
47
+ export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
48
+ export { FRAME_STATUS_METHOD } from "./isolation/types.ts";
35
49
 
36
50
  // Presets.
37
51
  export {
@@ -103,7 +117,9 @@ export type {
103
117
  CapabilityHandler,
104
118
  CapabilityHandlers,
105
119
  Confirm,
120
+ ConfirmAnswer,
106
121
  ConfirmRequest,
122
+ Proceed,
107
123
  HostFunctionBridge,
108
124
  Refusal,
109
125
  } from "./bridge/types.ts";
@@ -111,6 +127,8 @@ export { createHost } from "./host/create-host.ts";
111
127
  export type {
112
128
  DisplayControls,
113
129
  DisplayEvents,
130
+ FrameBlock,
131
+ FrameOutcome,
114
132
  DisplayHandle,
115
133
  HostOptions,
116
134
  MountOptions,
@@ -32,6 +32,30 @@ export function isExactOrigin(origin: unknown): origin is string {
32
32
  return typeof origin === "string" && parseOrigin(origin) !== null;
33
33
  }
34
34
 
35
+ /**
36
+ * A host page's origin, exactly as `location.origin` and a message's `event.origin` give it:
37
+ * `scheme://host[:port]`, lowercase, any scheme (an Electron or Tauri app's own registered scheme,
38
+ * `ferry://app`, `app://local`), never `"null"` (an opaque origin isn't one origin: every opaque
39
+ * page says "null") and never a path. The shell takes messages only from these, exactly.
40
+ */
41
+ const HOST_ORIGIN =
42
+ /^[a-z][a-z0-9+.-]*:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])(:\d{1,5})?$/u;
43
+
44
+ export function isHostOrigin(origin: unknown): origin is string {
45
+ return typeof origin === "string" && origin.length <= 300 && HOST_ORIGIN.test(origin);
46
+ }
47
+
48
+ /** Why `origin` can't be a host origin, in words a host's developer can act on; null if it can. */
49
+ export function hostOriginProblem(origin: unknown): string | null {
50
+ if (isHostOrigin(origin)) {
51
+ return null;
52
+ }
53
+ if (origin === "null") {
54
+ return 'the host page\'s origin is opaque ("null"): serve it from a real origin (an Electron custom protocol must be registered as standard, and file: pages have none)';
55
+ }
56
+ return `${JSON.stringify(origin)} is not an exact origin (scheme://host[:port], lowercase, no path)`;
57
+ }
58
+
35
59
  /**
36
60
  * Whether two hosts may be the same site. Without the public-suffix list this errs one way only:
37
61
  * it says "same site" whenever the hosts are equal or share their last two labels, so a pair it
@@ -57,8 +57,17 @@ export interface FrameLocation {
57
57
  readonly url: string;
58
58
  /** Its origin: the host takes messages only from this origin, from this frame. */
59
59
  readonly origin: string;
60
+ /**
61
+ * Whether the pages there report how loading went (`porthole/notifications/frame-status`):
62
+ * Porthole's `/server` does, so a page that never ran (refused framing) can be told apart from
63
+ * one that loaded. A host's own gateway may send the same message without this.
64
+ */
65
+ readonly reports?: boolean;
60
66
  }
61
67
 
68
+ /** The message a served page sends its host about loading (own-prefixed, P040). */
69
+ export const FRAME_STATUS_METHOD = "porthole/notifications/frame-status";
70
+
62
71
  /**
63
72
  * Turns an instance into the URL its frame loads. Implemented by the host, usually by asking its
64
73
  * own backend (which authenticates the viewer and calls `PortholeServer.issue`). Called on every
@@ -74,4 +83,9 @@ export interface InstanceLocator {
74
83
  */
75
84
  export type FrameSource =
76
85
  | { readonly kind: "srcdoc"; readonly html: string }
77
- | { readonly kind: "url"; readonly url: string; readonly origin: string };
86
+ | {
87
+ readonly kind: "url";
88
+ readonly url: string;
89
+ readonly origin: string;
90
+ readonly reports?: boolean;
91
+ };
@@ -17,6 +17,11 @@ export interface SurfaceSpec {
17
17
  readonly allow: string;
18
18
  readonly referrerPolicy: "no-referrer";
19
19
  readonly colorScheme: ColorScheme;
20
+ /**
21
+ * Fill the container's height (a url-mode app, which reports no size), at least
22
+ * `initialHeightPx`, instead of taking the content's.
23
+ */
24
+ readonly fill?: boolean;
20
25
  /** The height to start at (a remembered height, so a frame mounting again doesn't jump). */
21
26
  readonly initialHeightPx: number;
22
27
  }
@@ -38,6 +43,8 @@ export interface Surface {
38
43
  * framework starts it again from its content and reports it.
39
44
  */
40
45
  onNavigated(listener: () => void): () => void;
46
+ /** Every load of the frame's document, the first included. */
47
+ onLoad(listener: () => void): () => void;
41
48
  destroy(): void;
42
49
  }
43
50
 
@@ -26,7 +26,9 @@ export const webPlatform: PlatformHost = {
26
26
  border: "0",
27
27
  background: "transparent",
28
28
  colorScheme: spec.colorScheme,
29
- height: `${Math.max(0, Math.round(spec.initialHeightPx))}px`,
29
+ ...(spec.fill === true
30
+ ? { height: "100%", minHeight: `${Math.max(0, Math.round(spec.initialHeightPx))}px` }
31
+ : { height: `${Math.max(0, Math.round(spec.initialHeightPx))}px` }),
30
32
  });
31
33
  const origin = spec.source.kind === "srcdoc" ? "null" : spec.source.origin;
32
34
  if (spec.source.kind === "srcdoc") {
@@ -36,9 +38,13 @@ export const webPlatform: PlatformHost = {
36
38
  }
37
39
 
38
40
  const navigated = new Set<() => void>();
41
+ const loaded = new Set<() => void>();
39
42
  let loads = 0;
40
43
  frame.addEventListener("load", () => {
41
44
  loads += 1;
45
+ for (const listener of loaded) {
46
+ listener();
47
+ }
42
48
  if (loads > 1) {
43
49
  for (const listener of navigated) {
44
50
  listener();
@@ -53,7 +59,9 @@ export const webPlatform: PlatformHost = {
53
59
  transport,
54
60
  element: frame,
55
61
  setHeight(px: number) {
56
- frame.style.height = `${Math.max(0, Math.round(px))}px`;
62
+ if (spec.fill !== true) {
63
+ frame.style.height = `${Math.max(0, Math.round(px))}px`;
64
+ }
57
65
  },
58
66
  setColorScheme(scheme: ColorScheme) {
59
67
  frame.style.colorScheme = scheme;
@@ -64,12 +72,17 @@ export const webPlatform: PlatformHost = {
64
72
  ).userActivation;
65
73
  return activation === undefined ? undefined : activation.isActive;
66
74
  },
75
+ onLoad(listener) {
76
+ loaded.add(listener);
77
+ return () => loaded.delete(listener);
78
+ },
67
79
  onNavigated(listener) {
68
80
  navigated.add(listener);
69
81
  return () => navigated.delete(listener);
70
82
  },
71
83
  destroy() {
72
84
  navigated.clear();
85
+ loaded.clear();
73
86
  transport.close();
74
87
  frame.remove();
75
88
  },
@@ -10,6 +10,8 @@ import type {
10
10
  Limits,
11
11
  PortholeConfig,
12
12
  ThemeValue,
13
+ UrlPermission,
14
+ UrlSandboxExtra,
13
15
  } from "../config/schema.ts";
14
16
  import { validateConfig } from "../config/validate.ts";
15
17
  import { DEFAULT_THEME_EVENT, DEFAULT_THEME_VARIABLES } from "../theme/variables.ts";
@@ -27,10 +29,14 @@ export interface PresetOptions {
27
29
  readonly capabilities?: { readonly [N in CapabilityName]?: CapabilityConfig | false };
28
30
  /** Origins to open, added to the preset's (closed) egress. */
29
31
  readonly egress?: EgressConfig;
30
- /** Theme defaults to change or variables to add, and the theme event's name. */
32
+ /**
33
+ * Theme defaults to change, variables to add, or (null) defaults to drop; the theme event's
34
+ * name; and whether whole documents follow the person's scheme (`ThemeConfig.documents`).
35
+ */
31
36
  readonly theme?: {
32
- readonly variables?: Readonly<Record<string, ThemeValue>>;
37
+ readonly variables?: Readonly<Record<string, ThemeValue | null>>;
33
38
  readonly event?: string;
39
+ readonly documents?: "declared" | "always";
34
40
  };
35
41
  readonly limits?: Partial<Limits>;
36
42
  readonly layout?: Partial<LayoutConfig>;
@@ -44,6 +50,10 @@ export interface PresetOptions {
44
50
  /** The url preset's options: where the app is served, and the embedding page's extra frames. */
45
51
  export interface UrlPresetOptions {
46
52
  readonly contentOrigins: readonly string[];
53
+ /** Sandbox tokens to add for the app (popups for an OAuth flow, modals, downloads; P037). */
54
+ readonly sandbox?: readonly UrlSandboxExtra[];
55
+ /** Features to delegate to the app (clipboard writes, full screen; P049). */
56
+ readonly permissions?: readonly UrlPermission[];
47
57
  readonly limits?: Partial<Limits>;
48
58
  readonly layout?: Partial<LayoutConfig>;
49
59
  readonly embedder?: EmbedderConfig;
@@ -116,6 +126,21 @@ const mergeEgress = (base: EgressConfig, extra: EgressConfig | undefined): Egres
116
126
  return merged;
117
127
  };
118
128
 
129
+ /** The default variables, with the host's changes and additions, less those it dropped (null). */
130
+ function themeVariables(
131
+ changes: Readonly<Record<string, ThemeValue | null>> | undefined,
132
+ ): Record<string, ThemeValue> {
133
+ const variables: Record<string, ThemeValue> = { ...DEFAULT_THEME_VARIABLES };
134
+ for (const [name, value] of Object.entries(changes ?? {})) {
135
+ if (value === null) {
136
+ delete variables[name];
137
+ } else {
138
+ variables[name] = value;
139
+ }
140
+ }
141
+ return variables;
142
+ }
143
+
119
144
  function build(
120
145
  mode: PortholeConfig["mode"],
121
146
  defaults: { readonly limits?: Partial<Limits> },
@@ -140,8 +165,9 @@ function build(
140
165
  capabilities,
141
166
  egress: mergeEgress({}, options.egress),
142
167
  theme: {
143
- variables: { ...DEFAULT_THEME_VARIABLES, ...options.theme?.variables },
168
+ variables: themeVariables(options.theme?.variables),
144
169
  event: options.theme?.event ?? DEFAULT_THEME_EVENT,
170
+ ...(options.theme?.documents === undefined ? {} : { documents: options.theme.documents }),
145
171
  },
146
172
  limits: { ...BASE_LIMITS, ...defaults.limits, ...options.limits },
147
173
  layout: { ...BASE_LAYOUT, ...options.layout },
@@ -187,6 +213,8 @@ export const presets = {
187
213
  guide: {},
188
214
  contentOrigins: options.contentOrigins,
189
215
  embedder: options.embedder ?? {},
216
+ ...(options.sandbox === undefined ? {} : { sandbox: options.sandbox }),
217
+ ...(options.permissions === undefined ? {} : { permissions: options.permissions }),
190
218
  }),
191
219
  } as const;
192
220
 
@@ -18,13 +18,15 @@ import type {
18
18
  CallContext,
19
19
  CapabilityHandlers,
20
20
  Confirm,
21
+ ConfirmAnswer,
21
22
  ConfirmRequest,
23
+ Proceed,
22
24
  Refusal,
23
25
  } from "../bridge/types.ts";
24
26
  import type { CapabilityName } from "../config/capabilities.ts";
25
27
  import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
26
28
  import { createHost } from "../host/create-host.ts";
27
- import type { DisplayEvents, DisplayHandle } from "../host/types.ts";
29
+ import type { DisplayEvents, DisplayHandle, FrameOutcome } from "../host/types.ts";
28
30
  import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
29
31
  import { ConfirmDialog } from "./dialog.tsx";
30
32
  import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
@@ -72,7 +74,8 @@ export interface PortholeProviderProps {
72
74
 
73
75
  interface Pending {
74
76
  readonly request: ConfirmRequest;
75
- readonly resolve: (accepted: boolean) => void;
77
+ readonly resolve: (answer: ConfirmAnswer) => void;
78
+ readonly proceed: Proceed;
76
79
  }
77
80
 
78
81
  export function PortholeProvider(props: PortholeProviderProps): ReactElement {
@@ -85,11 +88,11 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
85
88
  own.current = props.confirm;
86
89
  });
87
90
  const confirm = useCallback<Confirm>(
88
- (request) =>
91
+ (request, proceed) =>
89
92
  own.current !== undefined
90
- ? own.current(request)
91
- : new Promise<boolean>((resolve) =>
92
- setQueue((waiting) => [...waiting, { request, resolve }]),
93
+ ? own.current(request, proceed)
94
+ : new Promise<ConfirmAnswer>((resolve) =>
95
+ setQueue((waiting) => [...waiting, { request, resolve, proceed }]),
93
96
  ),
94
97
  [],
95
98
  );
@@ -113,7 +116,11 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
113
116
  request={current?.request ?? null}
114
117
  {...(props.dialogClassName === undefined ? {} : { className: props.dialogClassName })}
115
118
  onAnswer={(accepted) => {
116
- current?.resolve(accepted);
119
+ // A yes runs the effect inside this very click (a window it opens is the person's).
120
+ if (accepted) {
121
+ current?.proceed();
122
+ }
123
+ current?.resolve(accepted ? "accepted" : "declined");
117
124
  setQueue((waiting) => waiting.slice(1));
118
125
  }}
119
126
  />
@@ -164,6 +171,21 @@ export interface PortholeFrameProps {
164
171
  readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
165
172
  /** Too many declines: the display stops asking until it runs again. */
166
173
  readonly onMuted?: () => void;
174
+ /**
175
+ * How the frame's first document went: loaded, blocked (and why), or a timeout. A host shows its
176
+ * own fallback (a url-mode app "Open in a new tab") on anything but loaded.
177
+ */
178
+ readonly onOutcome?: (outcome: FrameOutcome) => void;
179
+ /**
180
+ * Other ids this display had (the call's id, before the display's own settled): its remembered
181
+ * height is found under them, for a frame that mounts again under its new id.
182
+ */
183
+ readonly aliases?: readonly string[];
184
+ /**
185
+ * Change it when the host's theme values change without a scheme flip (or after an async
186
+ * restyle): the running frame reads them again.
187
+ */
188
+ readonly themeKey?: string | number;
167
189
  /** Draws the placeholder instead of the default one. */
168
190
  readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
169
191
  readonly className?: string;
@@ -198,6 +220,21 @@ function DefaultPlaceholder({ state, title, run }: PlaceholderProps): ReactEleme
198
220
  );
199
221
  }
200
222
 
223
+ /** A display's remembered height: under its id, else under the first of its other ids that has one. */
224
+ function rememberedHeight(
225
+ heights: ReadonlyMap<string, number>,
226
+ displayId: string,
227
+ aliases: readonly string[] | undefined,
228
+ ): number {
229
+ for (const id of [displayId, ...(aliases ?? [])]) {
230
+ const height = heights.get(id);
231
+ if (height !== undefined) {
232
+ return height;
233
+ }
234
+ }
235
+ return 0;
236
+ }
237
+
201
238
  export function PortholeFrame(props: PortholeFrameProps): ReactElement {
202
239
  const page = useContext(Page);
203
240
  const key = useId();
@@ -240,7 +277,17 @@ export function PortholeFrame(props: PortholeFrameProps): ReactElement {
240
277
  };
241
278
  }, [page, key, max, eager]);
242
279
 
243
- const height = page.heights.get(props.displayId) ?? 0;
280
+ const height = rememberedHeight(page.heights, props.displayId, props.aliases);
281
+ // A display renamed while it runs (its id settled) keeps its height under its new id.
282
+ const previousId = useRef(props.displayId);
283
+ useLayoutEffect(() => {
284
+ const before = previousId.current;
285
+ previousId.current = props.displayId;
286
+ const kept = page.heights.get(before);
287
+ if (before !== props.displayId && kept !== undefined && !page.heights.has(props.displayId)) {
288
+ page.heights.set(props.displayId, kept);
289
+ }
290
+ }, [page, props.displayId]);
244
291
  const run = () => page.live.admit(key, max);
245
292
  const placeholder = props.renderPlaceholder ?? DefaultPlaceholder;
246
293
  return (
@@ -290,6 +337,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
290
337
  // The height this display started at: later reports don't make it start again.
291
338
  const [startHeight] = useState(initialHeightPx);
292
339
  const { config, document: view, instance, content, partial, scheme } = frame;
340
+ const fills = config.mode === "url";
293
341
 
294
342
  // The latest props, read when the display calls back (never during render).
295
343
  const latest = useEffectEvent(() => frame);
@@ -320,6 +368,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
320
368
  case "muted":
321
369
  frame.onMuted?.();
322
370
  return;
371
+ case "outcome":
372
+ frame.onOutcome?.(detail as DisplayEvents["outcome"]);
373
+ return;
323
374
  }
324
375
  },
325
376
  );
@@ -353,11 +404,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
353
404
  handlers,
354
405
  ...(asks
355
406
  ? {
356
- confirm: (request: ConfirmRequest) =>
357
- (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))({
358
- ...request,
359
- display: { id: latest().displayId, title: latest().title },
360
- }),
407
+ confirm: (request: ConfirmRequest, proceed: Proceed) =>
408
+ (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))(
409
+ { ...request, display: { id: latest().displayId, title: latest().title } },
410
+ proceed,
411
+ ),
361
412
  }
362
413
  : {}),
363
414
  theme: (each) => latest().theme?.(each) ?? {},
@@ -383,6 +434,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
383
434
  "navigated",
384
435
  "refused",
385
436
  "muted",
437
+ "outcome",
386
438
  ];
387
439
  for (const event of events) {
388
440
  display.on(event, (detail) => notify(event, detail));
@@ -406,16 +458,28 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
406
458
  useEffect(() => {
407
459
  handle.current?.setTheme(scheme);
408
460
  }, [scheme]);
461
+ const { themeKey } = frame;
462
+ useEffect(() => {
463
+ if (themeKey !== undefined) {
464
+ handle.current?.refreshTheme();
465
+ }
466
+ }, [themeKey]);
409
467
 
410
468
  return (
411
469
  <div
412
470
  data-porthole-ready={String(ready)}
413
471
  style={{
414
472
  position: "relative",
473
+ // A url-mode app fills the frame's box (give PortholeFrame a height); the rest size to content.
474
+ ...(fills ? { height: "100%" } : {}),
415
475
  ...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
416
476
  }}
417
477
  >
418
- <div ref={holder} data-porthole-mount="" style={{ opacity: ready ? 1 : 0 }} />
478
+ <div
479
+ ref={holder}
480
+ data-porthole-mount=""
481
+ style={{ opacity: ready ? 1 : 0, ...(fills ? { height: "100%" } : {}) }}
482
+ />
419
483
  {ready ? null : placeholder({ state: "loading", title: frame.title, run })}
420
484
  </div>
421
485
  );
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The hashing `/server` needs, in plain JavaScript over web-standard types (`Uint8Array`,
3
+ * `TextEncoder`, `crypto.getRandomValues`), so it runs the same on Node, Cloudflare Workers and
4
+ * any other runtime (P044). Synchronous on purpose: Web Crypto's HMAC is async, and an origin
5
+ * (`originFor`) is computed synchronously. SHA-256 per FIPS 180-4, HMAC per RFC 2104; checked
6
+ * against the standard vectors and `node:crypto` in `test/crypto.test.ts`.
7
+ */
8
+
9
+ const K = new Uint32Array([
10
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
11
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
12
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
13
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
14
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
15
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
16
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
17
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
18
+ ]);
19
+
20
+ const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
21
+
22
+ export function sha256(data: Uint8Array): Uint8Array {
23
+ const length = data.length;
24
+ // Padded to a multiple of 64 bytes: a 1 bit, zeros, then the length in bits (64-bit big-endian).
25
+ const padded = new Uint8Array(Math.ceil((length + 9) / 64) * 64);
26
+ padded.set(data);
27
+ padded[length] = 0x80;
28
+ const view = new DataView(padded.buffer);
29
+ view.setUint32(padded.length - 8, Math.floor(length / 0x20000000), false);
30
+ view.setUint32(padded.length - 4, (length * 8) >>> 0, false);
31
+
32
+ const h = new Uint32Array([
33
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
34
+ ]);
35
+ const w = new Uint32Array(64);
36
+ for (let offset = 0; offset < padded.length; offset += 64) {
37
+ for (let i = 0; i < 16; i += 1) {
38
+ w[i] = view.getUint32(offset + i * 4, false);
39
+ }
40
+ for (let i = 16; i < 64; i += 1) {
41
+ const a = w[i - 15] as number;
42
+ const b = w[i - 2] as number;
43
+ const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
44
+ const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
45
+ w[i] = ((w[i - 16] as number) + s0 + (w[i - 7] as number) + s1) >>> 0;
46
+ }
47
+ let [a, b, c, d, e, f, g, hh] = h as unknown as number[] as [
48
+ number,
49
+ number,
50
+ number,
51
+ number,
52
+ number,
53
+ number,
54
+ number,
55
+ number,
56
+ ];
57
+ for (let i = 0; i < 64; i += 1) {
58
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
59
+ const ch = (e & f) ^ (~e & g);
60
+ const t1 = (hh + s1 + ch + (K[i] as number) + (w[i] as number)) >>> 0;
61
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
62
+ const maj = (a & b) ^ (a & c) ^ (b & c);
63
+ const t2 = (s0 + maj) >>> 0;
64
+ hh = g;
65
+ g = f;
66
+ f = e;
67
+ e = (d + t1) >>> 0;
68
+ d = c;
69
+ c = b;
70
+ b = a;
71
+ a = (t1 + t2) >>> 0;
72
+ }
73
+ const next = [a, b, c, d, e, f, g, hh];
74
+ for (let i = 0; i < 8; i += 1) {
75
+ h[i] = ((h[i] as number) + (next[i] as number)) >>> 0;
76
+ }
77
+ }
78
+ const out = new Uint8Array(32);
79
+ const outView = new DataView(out.buffer);
80
+ for (let i = 0; i < 8; i += 1) {
81
+ outView.setUint32(i * 4, h[i] as number, false);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ const BLOCK = 64;
87
+
88
+ export function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array {
89
+ const block = new Uint8Array(BLOCK);
90
+ block.set(key.length > BLOCK ? sha256(key) : key);
91
+ const inner = new Uint8Array(BLOCK + message.length);
92
+ const outer = new Uint8Array(BLOCK + 32);
93
+ for (let i = 0; i < BLOCK; i += 1) {
94
+ inner[i] = (block[i] as number) ^ 0x36;
95
+ outer[i] = (block[i] as number) ^ 0x5c;
96
+ }
97
+ inner.set(message, BLOCK);
98
+ outer.set(sha256(inner), BLOCK);
99
+ return sha256(outer);
100
+ }
101
+
102
+ const encoder = new TextEncoder();
103
+ const decoder = new TextDecoder();
104
+
105
+ export const utf8 = (text: string): Uint8Array => encoder.encode(text);
106
+ export const fromUtf8 = (bytes: Uint8Array): string => decoder.decode(bytes);
107
+
108
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
109
+
110
+ /** Base64url without padding (RFC 4648 §5). */
111
+ export function base64url(bytes: Uint8Array): string {
112
+ let out = "";
113
+ for (let i = 0; i < bytes.length; i += 3) {
114
+ const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
115
+ out += B64[(n >>> 18) & 63];
116
+ out += B64[(n >>> 12) & 63];
117
+ if (i + 1 < bytes.length) {
118
+ out += B64[(n >>> 6) & 63];
119
+ }
120
+ if (i + 2 < bytes.length) {
121
+ out += B64[n & 63];
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** Decodes base64url without padding; null for anything else. */
128
+ export function fromBase64url(text: string): Uint8Array | null {
129
+ if (!/^[A-Za-z0-9_-]*$/u.test(text) || text.length % 4 === 1) {
130
+ return null;
131
+ }
132
+ const out = new Uint8Array(Math.floor((text.length * 3) / 4));
133
+ let bits = 0;
134
+ let buffer = 0;
135
+ let index = 0;
136
+ for (const char of text) {
137
+ buffer = (buffer << 6) | B64.indexOf(char);
138
+ bits += 6;
139
+ if (bits >= 8) {
140
+ bits -= 8;
141
+ out[index] = (buffer >>> bits) & 0xff;
142
+ index += 1;
143
+ }
144
+ }
145
+ // The leftover bits of a canonical encoding are zero.
146
+ return (buffer & ((1 << bits) - 1)) === 0 ? out : null;
147
+ }
148
+
149
+ /** Equal bytes, compared in time that depends only on the length. */
150
+ export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
151
+ if (a.length !== b.length) {
152
+ return false;
153
+ }
154
+ let difference = 0;
155
+ for (let i = 0; i < a.length; i += 1) {
156
+ difference |= (a[i] as number) ^ (b[i] as number);
157
+ }
158
+ return difference === 0;
159
+ }
160
+
161
+ /** Random bytes from the runtime's CSPRNG (`crypto.getRandomValues`: Node, Workers, browsers). */
162
+ export function randomBytes(length: number): Uint8Array {
163
+ const bytes = new Uint8Array(length);
164
+ globalThis.crypto.getRandomValues(bytes);
165
+ return bytes;
166
+ }