resize-iframe 0.1.1 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # resize-iframe
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e0996b5: `srcdoc` support, for inline HTML the host owns.
8
+
9
+ `withResizeChild(html)` from `resize-iframe/inject` inlines the child script into
10
+ HTML you generate — report embeds, quizzes, model output — so the document you hand
11
+ to `srcdoc` stays self-contained: no CDN, no relative path back to the package. It
12
+ is idempotent, and wraps a fragment in a minimal document with a measurable box, so
13
+ anything from a bare string of text upwards sizes correctly. `childScriptTag()` and
14
+ `childScriptSource` are exported too, for hosts that place the script themselves.
15
+
16
+ The `<resize-iframe>` element now accepts `srcdoc` alongside `src`, applies
17
+ `sandbox` and `allow` before either so the first load carries them, and takes a
18
+ `warning-timeout` attribute (`0` silences the missing-child warning).
19
+
20
+ ## 0.2.0
21
+
22
+ ### Minor Changes
23
+
24
+ - f2e12a7: Give the framed page a way to receive what the parent sends.
25
+
26
+ `parentIframe.onMessage = (message) => …` delivers whatever the embedder posted with `sendMessage`. Until now the channel was send-only: the parent could post into the frame, but the child script never listened, so every embedded page had to hand-roll a `message` listener and work out for itself which messages came from the embedder.
27
+
28
+ Both directions now travel in the same `resize-iframe-message` envelope, and the child checks `event.source === parent` the same way the parent checks the frame's own window. Unrelated postMessage traffic aimed at your frame no longer reaches the callback.
29
+
30
+ **Breaking for hand-rolled child listeners.** A child page reading `event.data` directly now sees `{ 'resize-iframe-message': … }` rather than the bare payload. Use `parentIframe.onMessage` instead.
31
+
32
+ ### Patch Changes
33
+
34
+ - f2e12a7: Document the third-party embedding flow and cover the library with a Playwright suite.
35
+
36
+ - README: Storage Access API setup for cross-site embeds, including the `allow="storage-access"` and `allow-storage-access-by-user-activation` requirements, and why the request has to come from a click inside the frame.
37
+ - README: note that `sendMessage` is dropped if the frame is still on `about:blank`, so send it after `ready`.
38
+ - 33 specs across Chromium, Firefox and WebKit, served from two origins so they run against real cross-site restrictions rather than same-document shortcuts.
39
+
3
40
  ## 0.1.1
4
41
 
5
42
  ### Patch Changes
package/README.md CHANGED
@@ -7,8 +7,11 @@ each page, no build step, no dependencies, MIT.
7
7
  - ↔️ Vertical, horizontal, or both
8
8
  - 🔒 Messages matched against the frame's own window, which cannot be forged
9
9
  - 🍪 Storage Access API support for third-party embeds
10
- - 🧩 Use `iframeResize()` or the `<resize-iframe>` element
11
- - 📦 ~5KB unminified, across both files
10
+ - 🧩 Use `iframeResize()`, `<resize-iframe>`, or `withResizeChild` for srcdoc
11
+ - 📦 ~6KB unminified, across the files you ship
12
+
13
+ **[Live demo →](https://jagreehal.github.io/resize-iframe/)** — sandboxed, opaque-origin frames
14
+ resizing, messaging, and refusing to be read from the parent.
12
15
 
13
16
  ## Installation
14
17
 
@@ -66,6 +69,35 @@ That alone auto-resizes the frame. A cross-origin parent cannot measure your
66
69
  content, so without this script nothing happens — the parent logs a warning after
67
70
  five seconds saying exactly that.
68
71
 
72
+ ### Inline HTML (`srcdoc`) the host owns
73
+
74
+ When you build the framed HTML yourself — a report embed, a quiz, skill output —
75
+ put the child script inside it with `withResizeChild` and pass the result as
76
+ `srcdoc`. No CDN, no relative path, the document stays self-contained:
77
+
78
+ ```html
79
+ <script type="module">
80
+ import 'resize-iframe';
81
+ import { withResizeChild } from 'resize-iframe/inject';
82
+
83
+ const frame = document.createElement('resize-iframe');
84
+ frame.setAttribute('sandbox', 'allow-scripts'); // opaque origin; keep it
85
+ frame.setAttribute('min-h', '200px');
86
+ frame.setAttribute('srcdoc', withResizeChild(generatedHtml));
87
+ document.body.append(frame);
88
+ </script>
89
+ ```
90
+
91
+ `withResizeChild` is idempotent, inserts the script before `</body>`, and wraps
92
+ fragments in a minimal document — margin zeroed, content in a box — so even a bare
93
+ string of text has something measurable around it. The same module exports
94
+ `childScriptTag()` and `childScriptSource` if you would rather place the script
95
+ yourself. Remote `src` pages are unchanged: only that page's author can include the
96
+ child script.
97
+
98
+ Working with an AI agent? `.claude/skills/resize-iframe/` teaches it which of these
99
+ shapes to reach for.
100
+
69
101
  The script tag takes two optional attributes:
70
102
 
71
103
  | Attribute | Default | Description |
@@ -162,11 +194,13 @@ turned off for.
162
194
  | Attribute | Default | Description |
163
195
  | ------------- | ------------------ | ---------------------------------------- |
164
196
  | `src` | — | URL to embed |
197
+ | `srcdoc` | — | Inline HTML (prefer `withResizeChild`) |
165
198
  | `title` | `Embedded content` | Accessible name for the iframe |
166
199
  | `direction` | `vertical` | As above |
167
200
  | `offset-size` | `0` | As above |
168
201
  | `min-h` | — | Minimum height constraint |
169
202
  | `max-h` | — | Maximum height constraint |
203
+ | `warning-timeout` | `5000` | Ms before missing-child warning; `0` off |
170
204
  | `allow` | — | Passed through, e.g. `storage-access` |
171
205
  | `sandbox` | — | Passed through |
172
206
 
@@ -181,9 +215,15 @@ frame.sendMessage({ hello: 'world' }, 'https://anotherdomain.com');
181
215
  frame.disconnect(); // call before removing the iframe from the page
182
216
  ```
183
217
 
218
+ Both directions travel in the same envelope, so `parentIframe.onMessage` only ever
219
+ sees what the embedder sent — not the postMessage traffic every other script on the
220
+ page also aims at your frame. A plain `addEventListener('message', …)` in the child
221
+ would have to sort that out itself.
222
+
184
223
  Child, on `window.parentIframe`:
185
224
 
186
225
  ```javascript
226
+ parentIframe.onMessage = (message) => { … }; // ← parent's sendMessage
187
227
  parentIframe.sendMessage('ping'); // → parent's onMessage / 'frame-message' event
188
228
  parentIframe.autoResize(false); // pause resizing; returns the current state
189
229
  parentIframe.resize(); // nudge, for a change neither observer sees
@@ -227,21 +267,11 @@ pnpm exec playwright install
227
267
  pnpm test
228
268
  ```
229
269
 
230
- 33 Playwright specs across Chromium, Firefox and WebKit. The suite serves the
270
+ 42 Playwright specs across Chromium, Firefox and WebKit. The suite serves the
231
271
  parent page and the child pages from two different origins — `localhost` and
232
272
  `127.0.0.1`, which are cross-*site*, not merely cross-origin — so every test runs
233
273
  against the same partitioning and `contentDocument` restrictions as production.
234
274
 
235
- ## Testing
236
-
237
- ```bash
238
- pnpm install
239
- pnpm exec playwright install
240
- pnpm test
241
- ```
242
-
243
- 21 Playwright specs, run across Chromium, Firefox and WebKit.
244
-
245
275
  ## Browser Support
246
276
 
247
277
  - Chrome/Edge 80+
@@ -0,0 +1,2 @@
1
+ // Generated by scripts/embed-child.mjs — do not edit.
2
+ export const childScriptSource = "// Load this inside the framed page as a classic script, e.g.\n// <script src=\".../resize-iframe-child.js\"><\\/script>\n// (Written with an escaped closer so the file can also be inlined into a\n// parent <script> without the HTML parser cutting the tag short.)\n// Optional attributes on that script tag:\n// data-parent-origin=\"https://parent.example\" report size only to that embedder\n// data-size-selector=\".content\" measure these elements instead\n// Or mark elements in the page with data-iframe-size to the same effect.\nconst config = document.currentScript?.dataset ?? {};\nconst targetOrigin = config.parentOrigin || '*';\nconst sizeSelector = config.sizeSelector || '[data-iframe-size]';\n\nconst start = () => {\n let last = { height: 0, width: 0 };\n let queued = false;\n let auto = true;\n let inbound = null;\n\n // Measure the bottom and right edges of the marked elements, or of body's\n // children. Never body itself: it stretches to fill the frame in quirks mode\n // or under `body { height: 100% }`, which ratchets the frame larger and never\n // lets it shrink back.\n // Body's own bottom/right padding and margin fall outside the measurement —\n // zero them if that gap matters, or mark a wrapper with data-iframe-size.\n const measure = () => {\n const marked = document.querySelectorAll(sizeSelector);\n const elements = marked.length ? marked : document.body.children;\n if (!elements.length) {\n const body = document.body.getBoundingClientRect();\n return { height: Math.ceil(body.bottom), width: Math.ceil(body.right) };\n }\n let height = 0;\n let width = 0;\n for (const element of elements) {\n const box = element.getBoundingClientRect();\n height = Math.max(height, box.bottom);\n width = Math.max(width, box.right);\n }\n return { height: Math.ceil(height), width: Math.ceil(width) };\n };\n\n const post = () => {\n queued = false;\n const size = measure();\n if (size.height === last.height && size.width === last.width) return;\n last = size;\n parent.postMessage({ 'resize-iframe': size }, targetOrigin);\n };\n\n // Batch to one measurement per frame: mutations arrive in bursts and every\n // measure() forces a layout.\n const schedule = () => {\n if (queued || !auto) return;\n queued = true;\n requestAnimationFrame(post);\n };\n\n // ResizeObserver catches reflow (images, fonts, viewport). MutationObserver\n // catches DOM and style changes, which the observer misses entirely whenever\n // body is stretched to the frame and so never changes size itself.\n new ResizeObserver(schedule).observe(document.body);\n new MutationObserver(schedule).observe(document.body, {\n subtree: true,\n childList: true,\n attributes: true,\n characterData: true,\n });\n addEventListener('load', schedule); // images and fonts landing late\n\n // Messages from the embedder. event.source is set by the browser and cannot be\n // forged, so this is the same guard the parent uses in the other direction, and\n // the envelope keeps unrelated postMessage traffic (analytics, wallets, other\n // embeds) out of the callback.\n addEventListener('message', (event) => {\n if (event.source !== parent) return;\n const data = event.data;\n if (data && typeof data === 'object' && 'resize-iframe-message' in data) {\n inbound?.(data['resize-iframe-message']);\n }\n });\n\n // Third-party cookies: an embedded page gets partitioned storage until the user\n // grants access. Probe the API rather than sniffing the browser — Safari and\n // Chrome both partition, and which browsers do is not a stable list.\n const hasStorageAccess = () =>\n document.hasStorageAccess?.().catch(() => false) ?? Promise.resolve(true);\n\n const reportStorageAccess = async () => {\n const access = await hasStorageAccess();\n parent.postMessage({ 'resize-iframe-storage': { hasAccess: access } }, targetOrigin);\n return access;\n };\n\n window.parentIframe = {\n // Assign a function to receive what the parent sends with sendMessage().\n get onMessage() {\n return inbound;\n },\n set onMessage(fn) {\n inbound = fn;\n },\n autoResize(state) {\n if (state !== undefined) auto = state;\n if (auto) schedule();\n return auto;\n },\n resize() {\n post(); // nudge, for the rare change neither observer sees\n },\n sendMessage(message, origin = targetOrigin) {\n parent.postMessage({ 'resize-iframe-message': message }, origin);\n },\n hasStorageAccess,\n // MUST be called from a click or tap handler in this page: the browser denies\n // the request without a user gesture here, and the parent cannot supply one.\n // Reload afterwards if your page reads cookies during startup.\n async requestStorageAccess() {\n if (!document.requestStorageAccess) return true;\n try {\n await document.requestStorageAccess();\n } catch {\n // Denied, or the user has never visited this site first-party.\n }\n return reportStorageAccess();\n },\n };\n\n reportStorageAccess();\n post();\n};\n\nif (parent !== window) {\n if (document.body) start();\n else addEventListener('DOMContentLoaded', start);\n}\n";
package/inject.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /** Source of resize-iframe-child.js, for hosts that want to inject it themselves. */
2
+ export const childScriptSource: string;
3
+
4
+ /** The inline `<script data-resize-iframe-child>…</script>` tag. */
5
+ export function childScriptTag(): string;
6
+
7
+ /**
8
+ * Returns HTML that includes the child script exactly once, ready for srcdoc.
9
+ * Fragments are wrapped in a minimal document with a measurable box to sit in.
10
+ */
11
+ export function withResizeChild(html: string): string;
package/inject.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Prepare HTML so a parent using resize-iframe can size a srcdoc frame.
3
+ *
4
+ * A cross-origin (or opaque-origin sandboxed) parent cannot measure the framed
5
+ * page. The child script has to run inside it. For HTML the host owns — skill
6
+ * output, quizzes, inlined report fragments — call this before setting srcdoc
7
+ * and the script is inlined, so the report stays self-contained: no CDN, no
8
+ * relative path to the package.
9
+ *
10
+ * Remote `src` pages are not covered: only that page's author can include
11
+ * resize-iframe-child.js.
12
+ */
13
+ import { childScriptSource } from "./child-source.js";
14
+
15
+ export { childScriptSource };
16
+
17
+ const MARKER = "data-resize-iframe-child";
18
+
19
+ /** The inline script tag `withResizeChild` inserts. */
20
+ export function childScriptTag() {
21
+ // A literal </script> anywhere in the source — even inside a JS comment —
22
+ // closes an HTML <script> element. Break the sequence for the parser; JS
23
+ // still sees <\/script> as </script>.
24
+ const safe = childScriptSource.replace(/<\/(script)/gi, "<\\/$1");
25
+ return `<script ${MARKER}>${safe}</script>`;
26
+ }
27
+
28
+ /**
29
+ * Returns HTML that includes the child script exactly once.
30
+ *
31
+ * - Full documents: script inserted before `</body>` (or `</html>`).
32
+ * - Fragments: wrapped in a minimal document so `document.body` exists for
33
+ * the child (it waits on body before measuring).
34
+ * - Idempotent: HTML that already carries the marker is returned unchanged.
35
+ */
36
+ export function withResizeChild(html) {
37
+ const source = typeof html === "string" ? html : String(html ?? "");
38
+ if (source.includes(MARKER)) return source;
39
+
40
+ const tag = childScriptTag();
41
+ if (/<\/body>/i.test(source)) return source.replace(/<\/body>/i, `${tag}</body>`);
42
+ if (/<\/html>/i.test(source)) return source.replace(/<\/html>/i, `${tag}</html>`);
43
+ // The child measures body's element children, so bare text needs a box around
44
+ // it, and the default body margin would land inside the measured top edge
45
+ // while its counterpart fell outside the bottom one.
46
+ return `<!doctype html><html><head><style>body{margin:0}</style></head><body><div>${source}</div>${tag}</body></html>`;
47
+ }
package/package.json CHANGED
@@ -1,15 +1,31 @@
1
1
  {
2
2
  "name": "resize-iframe",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Iframes that size themselves to their content, cross-origin included. No build step, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "resize-iframe.js",
7
7
  "module": "resize-iframe.js",
8
8
  "types": "resize-iframe.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./resize-iframe.d.ts",
12
+ "import": "./resize-iframe.js",
13
+ "default": "./resize-iframe.js"
14
+ },
15
+ "./inject": {
16
+ "types": "./inject.d.ts",
17
+ "import": "./inject.js",
18
+ "default": "./inject.js"
19
+ },
20
+ "./resize-iframe-child.js": "./resize-iframe-child.js"
21
+ },
9
22
  "files": [
10
23
  "resize-iframe.js",
11
24
  "resize-iframe-child.js",
12
25
  "resize-iframe.d.ts",
26
+ "inject.js",
27
+ "inject.d.ts",
28
+ "child-source.js",
13
29
  "CHANGELOG.md"
14
30
  ],
15
31
  "publishConfig": {
@@ -41,9 +57,11 @@
41
57
  "typescript": "6.0.3"
42
58
  },
43
59
  "scripts": {
60
+ "embed-child": "node scripts/embed-child.mjs",
61
+ "pretest": "pnpm embed-child",
44
62
  "test": "playwright test",
45
63
  "test:ui": "playwright test --ui",
46
- "typecheck": "tsc --noEmit --strict --lib es2022,dom resize-iframe.d.ts",
64
+ "typecheck": "tsc --noEmit --strict --lib es2022,dom resize-iframe.d.ts inject.d.ts",
47
65
  "changeset": "changeset",
48
66
  "version-packages": "changeset version",
49
67
  "release": "changeset publish"
@@ -1,5 +1,7 @@
1
- // Load this inside the framed page:
2
- // <script src=".../resize-iframe-child.js"></script>
1
+ // Load this inside the framed page as a classic script, e.g.
2
+ // <script src=".../resize-iframe-child.js"><\/script>
3
+ // (Written with an escaped closer so the file can also be inlined into a
4
+ // parent <script> without the HTML parser cutting the tag short.)
3
5
  // Optional attributes on that script tag:
4
6
  // data-parent-origin="https://parent.example" report size only to that embedder
5
7
  // data-size-selector=".content" measure these elements instead
@@ -12,13 +14,14 @@ const start = () => {
12
14
  let last = { height: 0, width: 0 };
13
15
  let queued = false;
14
16
  let auto = true;
17
+ let inbound = null;
15
18
 
16
19
  // Measure the bottom and right edges of the marked elements, or of body's
17
20
  // children. Never body itself: it stretches to fill the frame in quirks mode
18
21
  // or under `body { height: 100% }`, which ratchets the frame larger and never
19
22
  // lets it shrink back.
20
- // ponytail: body's own bottom/right padding and margin are ignored zero them
21
- // if that gap matters, or wrap your content and mark it with data-iframe-size.
23
+ // Body's own bottom/right padding and margin fall outside the measurement
24
+ // zero them if that gap matters, or mark a wrapper with data-iframe-size.
22
25
  const measure = () => {
23
26
  const marked = document.querySelectorAll(sizeSelector);
24
27
  const elements = marked.length ? marked : document.body.children;
@@ -64,6 +67,18 @@ const start = () => {
64
67
  });
65
68
  addEventListener('load', schedule); // images and fonts landing late
66
69
 
70
+ // Messages from the embedder. event.source is set by the browser and cannot be
71
+ // forged, so this is the same guard the parent uses in the other direction, and
72
+ // the envelope keeps unrelated postMessage traffic (analytics, wallets, other
73
+ // embeds) out of the callback.
74
+ addEventListener('message', (event) => {
75
+ if (event.source !== parent) return;
76
+ const data = event.data;
77
+ if (data && typeof data === 'object' && 'resize-iframe-message' in data) {
78
+ inbound?.(data['resize-iframe-message']);
79
+ }
80
+ });
81
+
67
82
  // Third-party cookies: an embedded page gets partitioned storage until the user
68
83
  // grants access. Probe the API rather than sniffing the browser — Safari and
69
84
  // Chrome both partition, and which browsers do is not a stable list.
@@ -77,6 +92,13 @@ const start = () => {
77
92
  };
78
93
 
79
94
  window.parentIframe = {
95
+ // Assign a function to receive what the parent sends with sendMessage().
96
+ get onMessage() {
97
+ return inbound;
98
+ },
99
+ set onMessage(fn) {
100
+ inbound = fn;
101
+ },
80
102
  autoResize(state) {
81
103
  if (state !== undefined) auto = state;
82
104
  if (auto) schedule();
@@ -32,6 +32,8 @@ export function iframeResize(
32
32
 
33
33
  /** Available inside the framed page once resize-iframe-child.js has loaded. */
34
34
  export interface ParentIframe {
35
+ /** Called with whatever the parent sends via `sendMessage`. Assign to subscribe. */
36
+ onMessage: ((message: unknown) => void) | null;
35
37
  autoResize(state?: boolean): boolean;
36
38
  resize(): void;
37
39
  sendMessage(message: unknown, targetOrigin?: string): void;
@@ -43,11 +45,15 @@ export interface ParentIframe {
43
45
  /** Attributes of the <resize-iframe> element. */
44
46
  export interface ResizeIframeAttributes {
45
47
  src?: string;
48
+ /** Inline HTML. Prefer `withResizeChild(html)` from `resize-iframe/inject` so the frame can report its size. */
49
+ srcdoc?: string;
46
50
  title?: string;
47
51
  direction?: 'vertical' | 'horizontal' | 'both' | 'none';
48
52
  'offset-size'?: number | string;
49
53
  'min-h'?: string;
50
54
  'max-h'?: string;
55
+ /** Ms to wait before warning that the child script is missing. `0` silences. */
56
+ 'warning-timeout'?: number | string;
51
57
  allow?: string;
52
58
  sandbox?: string;
53
59
  /** Whatever else the host framework puts on an element: class, ref, key. */
package/resize-iframe.js CHANGED
@@ -88,8 +88,10 @@ function connect(iframe, settings) {
88
88
  clearTimeout(warning);
89
89
  delete iframe.iframeResizer;
90
90
  },
91
+ // Same envelope the child sends back in, so the framed page can tell an
92
+ // embedder message from every other script posting at it.
91
93
  sendMessage(message, targetOrigin = '*') {
92
- iframe.contentWindow?.postMessage(message, targetOrigin);
94
+ iframe.contentWindow?.postMessage({ 'resize-iframe-message': message }, targetOrigin);
93
95
  },
94
96
  };
95
97
  return iframe.iframeResizer;
@@ -97,7 +99,9 @@ function connect(iframe, settings) {
97
99
 
98
100
  class ResizeIframe extends HTMLElement {
99
101
  static get observedAttributes() {
100
- return ['src', 'min-h', 'max-h', 'allow', 'sandbox'];
102
+ // src / srcdoc last: setting either starts the navigation, and sandbox/allow
103
+ // have to be on the element before that or the first load runs without them.
104
+ return ['min-h', 'max-h', 'allow', 'sandbox', 'warning-timeout', 'srcdoc', 'src'];
101
105
  }
102
106
 
103
107
  connectedCallback() {
@@ -115,10 +119,14 @@ class ResizeIframe extends HTMLElement {
115
119
  this.attributeChangedCallback(name, null, this.getAttribute(name));
116
120
  }
117
121
  }
122
+ const warningTimeoutAttr = this.getAttribute('warning-timeout');
118
123
  iframeResize(
119
124
  {
120
125
  direction: this.getAttribute('direction') || DEFAULTS.direction,
121
126
  offsetSize: Number(this.getAttribute('offset-size')) || 0,
127
+ ...(warningTimeoutAttr !== null
128
+ ? { warningTimeout: Number(warningTimeoutAttr) }
129
+ : {}),
122
130
  },
123
131
  this.iframe
124
132
  );
@@ -130,6 +138,8 @@ class ResizeIframe extends HTMLElement {
130
138
 
131
139
  attributeChangedCallback(name, oldValue, newValue) {
132
140
  if (!this.iframe || newValue === null) return;
141
+ // srcdoc wins over src when both are set (HTML behaviour). Prefer one.
142
+ if (name === 'srcdoc') this.iframe.srcdoc = newValue;
133
143
  if (name === 'src') this.iframe.src = newValue;
134
144
  if (name === 'min-h') this.iframe.style.minHeight = newValue;
135
145
  if (name === 'max-h') this.iframe.style.maxHeight = newValue;
@@ -137,6 +147,7 @@ class ResizeIframe extends HTMLElement {
137
147
  // denied outright unless the frame carries allow="storage-access", and a
138
148
  // sandboxed frame also needs allow-storage-access-by-user-activation.
139
149
  if (name === 'allow' || name === 'sandbox') this.iframe.setAttribute(name, newValue);
150
+ // warning-timeout is read once, in connectedCallback.
140
151
  }
141
152
 
142
153
  get height() {