Skip to content

Instantly share code, notes, and snippets.

@simonw
Created November 15, 2024 03:39
Show Gist options
  • Save simonw/54d91de17fdde1bce715f6a7859c935e to your computer and use it in GitHub Desktop.
Save simonw/54d91de17fdde1bce715f6a7859c935e to your computer and use it in GitHub Desktop.

Improving API Requests from Sandboxed Iframe

human (Nov 14, 2024, 07:32 PM)

I have this system that runs in a sandbox iframe and requests the parent page makes API requests on behalf of the child:

                    function makeRequest() {
                        const url = document.getElementById('urlInput').value;
                        const output = document.getElementById('output');
                        
                        // Send message to parent window
                        window.parent.postMessage({
                            type: 'api-request',
                            url: url,
                            method: 'GET'
                        }, '*');

                        output.textContent = 'Loading...';
                    }

                    // Listen for response from parent
                    window.addEventListener('message', function(event) {
                        const output = document.getElementById('output');
                        
                        if (event.data.type === 'api-response') {
                            if (event.data.error) {
                                output.textContent = 'Error: ' + event.data.error;
                                output.classList.add('error');
                            } else {
                                output.textContent = JSON.stringify(event.data.response, null, 2);
                                output.classList.remove('error');
                            }
                        }
                    });

Suggest ways I can make that API nicer to use. Ideally I'd like an async callAPi(url) function I can call which returns the results from a promise when they arrive from the parent

assistant (Nov 14, 2024, 07:32 PM)

I'll help you create a nicer Promise-based API wrapper for the postMessage communication. Here's a better way to structure this:

create Promise-based iframe API client

// Create a class to manage the API communication
class IframeAPIClient {
    constructor(targetOrigin = '*') {
        this.targetOrigin = targetOrigin;
        this.pendingRequests = new Map();
        this.requestId = 0;
        
        // Set up message listener
        window.addEventListener('message', this.handleMessage.bind(this));
    }
    
    handleMessage(event) {
        if (event.data?.type !== 'api-response' || !event.data?.requestId) {
            return;
        }
        
        const { requestId, error, response } = event.data;
        const pendingRequest = this.pendingRequests.get(requestId);
        
        if (!pendingRequest) {
            return;
        }
        
        this.pendingRequests.delete(requestId);
        
        if (error) {
            pendingRequest.reject(new Error(error));
        } else {
            pendingRequest.resolve(response);
        }
    }
    
    async callAPI(url, method = 'GET', body = null) {
        const requestId = ++this.requestId;
        
        const promise = new Promise((resolve, reject) => {
            this.pendingRequests.set(requestId, { resolve, reject });
            
            // Set a timeout to prevent hanging promises
            setTimeout(() => {
                if (this.pendingRequests.has(requestId)) {
                    this.pendingRequests.delete(requestId);
                    reject(new Error('Request timed out'));
                }
            }, 30000); // 30 second timeout
        });
        
        // Send the request to parent
        window.parent.postMessage({
            type: 'api-request',
            requestId,
            url,
            method,
            body
        }, this.targetOrigin);
        
        return promise;
    }
    
    // Convenience methods for different HTTP methods
    get(url) {
        return this.callAPI(url, 'GET');
    }
    
    post(url, body) {
        return this.callAPI(url, 'POST', body);
    }
    
    put(url, body) {
        return this.callAPI(url, 'PUT', body);
    }
    
    delete(url) {
        return this.callAPI(url, 'DELETE');
    }
}

// Create a singleton instance
const api = new IframeAPIClient();

// Example usage:
async function fetchData() {
    try {
        const data = await api.get('https://api.example.com/data');
        console.log('Received data:', data);
    } catch (error) {
        console.error('API error:', error);
    }
}

// Or use with Promise chaining
api.get('https://api.example.com/data')
    .then(data => console.log('Received:', data))
    .catch(error => console.error('Error:', error));

This implementation provides several improvements over the original:

  1. Promise-based API: Each request returns a Promise that resolves with the response data or rejects with an error.

  2. Request tracking: Uses unique request IDs to match responses with their corresponding requests, allowing multiple concurrent API calls.

  3. Timeout handling: Includes a timeout mechanism to prevent hanging promises if the parent never responds.

  4. Convenience methods: Provides helper methods for common HTTP verbs (get, post, put, delete).

  5. Error handling: Proper error propagation through the Promise chain.

To use this in your iframe, you would just need to change your parent window handler to include the requestId in responses:

// In parent window
window.addEventListener('message', async function(event) {
    if (event.data?.type === 'api-request') {
        const { url, method, body, requestId } = event.data;
        
        try {
            const response = await fetch(url, {
                method,
                body: body ? JSON.stringify(body) : undefined,
                headers: {
                    'Content-Type': 'application/json'
                }
            });
            const data = await response.json();
            
            event.source.postMessage({
                type: 'api-response',
                requestId,
                response: data
            }, '*');
        } catch (error) {
            event.source.postMessage({
                type: 'api-response',
                requestId,
                error: error.message
            }, '*');
        }
    }
});

Now you can make API calls from your iframe like this:

// Simple GET request
const data = await api.get('https://api.example.com/data');

// POST request with body
const response = await api.post('https://api.example.com/create', {
    name: 'New Item',
    value: 123
});

The code is much cleaner to use and handles error cases more robustly. You can also easily extend the IframeAPIClient class to add more features like:

  • Request cancellation
  • Request queuing
  • Retry logic
  • Request/response interceptors
  • Custom headers
  • Response type handling (JSON, text, blob, etc.)

Would you like me to demonstrate how to implement any of these additional features?

human (Nov 14, 2024, 07:34 PM)

I like that. Ditch the post/put/delete methods, rename the class to just APIClient and rewrite this HTML here to use the new pattern instead

paste.txt

<!DOCTYPE html>
<html>
<head>
    <title>API Explorer</title>
    <style>
        body {
            font-family: system-ui, -apple-system, sans-serif;
            margin: 0;
            padding: 20px;
            background: #f0f0f0;
        }
        iframe {
            width: 100%;
            border: 1px solid #ccc;
            border-radius: 4px;
            background: white;
            transition: height 0.2s ease;
        }
    </style>
</head>
<body>
    <iframe id="explorerFrame" sandbox="allow-scripts"></iframe>

    <script>
        // Create the HTML content for the iframe
        const iframeContent = `
            <!DOCTYPE html>
            <html>
            <head>
                <style>
                    body {
                        font-family: system-ui, -apple-system, sans-serif;
                        margin: 0;
                        padding: 20px;
                    }
                    .container {
                        max-width: 800px;
                        margin: 0 auto;
                    }
                    .input-group {
                        display: flex;
                        gap: 8px;
                        margin-bottom: 20px;
                    }
                    input[type="text"] {
                        flex: 1;
                        padding: 8px;
                        border: 1px solid #ccc;
                        border-radius: 4px;
                        font-size: 16px;
                    }
                    button {
                        padding: 8px 16px;
                        background: #0066cc;
                        color: white;
                        border: none;
                        border-radius: 4px;
                        cursor: pointer;
                        font-size: 16px;
                    }
                    button:hover {
                        background: #0052a3;
                    }
                    pre {
                        background: #f5f5f5;
                        padding: 20px;
                        border-radius: 4px;
                        overflow-x: auto;
                        white-space: pre-wrap;
                        word-wrap: break-word;
                    }
                    .error {
                        color: #cc0000;
                    }
                </style>
            </head>
            <body>
                <div class="container">
                    <h1>API Explorer</h1>
                    <p>This is an experiment in <code>&lt;iframe&gt;</code> sandboxing and <code>window.parent.postMessage()</code>.</p>
                    <form onsubmit="makeRequest(); return false;">
                        <div class="input-group">    
                            <input type="text" id="urlInput" placeholder="Enter API URL" value="https://datasette.io/content/stats.json?_size=1">
                            <button onclick="makeRequest()">Submit</button>
                        </div>
                    </form>
                    <pre id="output">// Response will appear here</pre>
                </div>

                <script>
                    let currentHeight = null;
                    function updateParentHeight() {
                        // Borrowed from https://github.com/FaiblUG/setIframeHeight/blob/5509feb0f281251d03065991b1c5f451f40fa8bd/dist/set-iframe-height-child.js#L31C1-L31C220
                        let newHeight = Math.min(
                            Math.max(
                                document.body.offsetHeight, document.documentElement.offsetHeight
                            ),
                            Math.max(
                                Math.max(
                                    document.body.scrollHeight, document.documentElement.scrollHeight
                                ),
                                Math.max(
                                    document.body.clientHeight, document.documentElement.clientHeight
                                )
                            )
                        );
                        if (currentHeight === newHeight) {
                            return;
                        }
                        currentHeight = newHeight;
                        window.parent.postMessage({
                            type: 'resize',
                            height: newHeight
                        }, '*');
                    }

                    updateParentHeight();

                    setInterval(updateParentHeight, 1000);

                    function makeRequest() {
                        const url = document.getElementById('urlInput').value;
                        const output = document.getElementById('output');
                        
                        // Send message to parent window
                        window.parent.postMessage({
                            type: 'api-request',
                            url: url,
                            method: 'GET'
                        }, '*');

                        output.textContent = 'Loading...';
                    }

                    // Listen for response from parent
                    window.addEventListener('message', function(event) {
                        const output = document.getElementById('output');
                        
                        if (event.data.type === 'api-response') {
                            if (event.data.error) {
                                output.textContent = 'Error: ' + event.data.error;
                                output.classList.add('error');
                            } else {
                                output.textContent = JSON.stringify(event.data.response, null, 2);
                                output.classList.remove('error');
                            }
                            // Content has changed, update height
                            setTimeout(() => {
                                updateParentHeight();
                            }, 100);
                        }
                    });
                SCUB
            </body>
            </html>
        `.replace('SCUB', '</' + 'script>');

        // Set up the iframe with the content
        const iframe = document.getElementById('explorerFrame');
        iframe.src = 'data:text/html;charset=utf-8,' + encodeURIComponent(iframeContent);

        // Listen for messages from the iframe
        window.addEventListener('message', async function(event) {
            if (event.data.type === 'api-request') {
                try {
                    const response = await fetch(event.data.url, {
                        method: event.data.method
                    });
                    
                    const data = await response.json();
                    
                    // Send response back to iframe
                    iframe.contentWindow.postMessage({
                        type: 'api-response',
                        response: data
                    }, '*');
                } catch (error) {
                    // Send error back to iframe
                    iframe.contentWindow.postMessage({
                        type: 'api-response',
                        error: error.message
                    }, '*');
                }
            } else if (event.data.type === 'resize') {
                // Update iframe height when child requests it
                iframe.style.height = event.data.height + 'px';
            }
        });
    </script>
</body>
</html>


assistant (Nov 14, 2024, 07:34 PM)

I'll simplify the APIClient class and integrate it into your HTML. Here's the rewritten version:

create API Explorer with Promise-based client

<!DOCTYPE html>
<html>
<head>
    <title>API Explorer</title>
    <style>
        body {
            font-family: system-ui, -apple-system, sans-serif;
            margin: 0;
            padding: 20px;
            background: #f0f0f0;
        }
        iframe {
            width: 100%;
            border: 1px solid #ccc;
            border-radius: 4px;
            background: white;
            transition: height 0.2s ease;
        }
    </style>
</head>
<body>
    <iframe id="explorerFrame" sandbox="allow-scripts"></iframe>

    <script>
        // Create the HTML content for the iframe
        const iframeContent = `
            <!DOCTYPE html>
            <html>
            <head>
                <style>
                    body {
                        font-family: system-ui, -apple-system, sans-serif;
                        margin: 0;
                        padding: 20px;
                    }
                    .container {
                        max-width: 800px;
                        margin: 0 auto;
                    }
                    .input-group {
                        display: flex;
                        gap: 8px;
                        margin-bottom: 20px;
                    }
                    input[type="text"] {
                        flex: 1;
                        padding: 8px;
                        border: 1px solid #ccc;
                        border-radius: 4px;
                        font-size: 16px;
                    }
                    button {
                        padding: 8px 16px;
                        background: #0066cc;
                        color: white;
                        border: none;
                        border-radius: 4px;
                        cursor: pointer;
                        font-size: 16px;
                    }
                    button:hover {
                        background: #0052a3;
                    }
                    pre {
                        background: #f5f5f5;
                        padding: 20px;
                        border-radius: 4px;
                        overflow-x: auto;
                        white-space: pre-wrap;
                        word-wrap: break-word;
                    }
                    .error {
                        color: #cc0000;
                    }
                </style>
            </head>
            <body>
                <div class="container">
                    <h1>API Explorer</h1>
                    <p>This is an experiment in <code>&lt;iframe&gt;</code> sandboxing and <code>window.parent.postMessage()</code>.</p>
                    <form onsubmit="makeRequest(); return false;">
                        <div class="input-group">    
                            <input type="text" id="urlInput" placeholder="Enter API URL" value="https://datasette.io/content/stats.json?_size=1">
                            <button type="submit">Submit</button>
                        </div>
                    </form>
                    <pre id="output">// Response will appear here</pre>
                </div>

                <script>
                    class APIClient {
                        constructor(targetOrigin = '*') {
                            this.targetOrigin = targetOrigin;
                            this.pendingRequests = new Map();
                            this.requestId = 0;
                            
                            // Set up message listener
                            window.addEventListener('message', this.handleMessage.bind(this));
                        }
                        
                        handleMessage(event) {
                            if (event.data?.type !== 'api-response' || !event.data?.requestId) {
                                return;
                            }
                            
                            const { requestId, error, response } = event.data;
                            const pendingRequest = this.pendingRequests.get(requestId);
                            
                            if (!pendingRequest) {
                                return;
                            }
                            
                            this.pendingRequests.delete(requestId);
                            
                            if (error) {
                                pendingRequest.reject(new Error(error));
                            } else {
                                pendingRequest.resolve(response);
                            }
                        }
                        
                        async callAPI(url) {
                            const requestId = ++this.requestId;
                            
                            const promise = new Promise((resolve, reject) => {
                                this.pendingRequests.set(requestId, { resolve, reject });
                                
                                // Set a timeout to prevent hanging promises
                                setTimeout(() => {
                                    if (this.pendingRequests.has(requestId)) {
                                        this.pendingRequests.delete(requestId);
                                        reject(new Error('Request timed out'));
                                    }
                                }, 30000); // 30 second timeout
                            });
                            
                            // Send the request to parent
                            window.parent.postMessage({
                                type: 'api-request',
                                requestId,
                                url,
                                method: 'GET'
                            }, this.targetOrigin);
                            
                            return promise;
                        }
                    }

                    const api = new APIClient();
                    
                    // Height management
                    let currentHeight = null;
                    function updateParentHeight() {
                        let newHeight = Math.min(
                            Math.max(
                                document.body.offsetHeight, document.documentElement.offsetHeight
                            ),
                            Math.max(
                                Math.max(
                                    document.body.scrollHeight, document.documentElement.scrollHeight
                                ),
                                Math.max(
                                    document.body.clientHeight, document.documentElement.clientHeight
                                )
                            )
                        );
                        if (currentHeight === newHeight) {
                            return;
                        }
                        currentHeight = newHeight;
                        window.parent.postMessage({
                            type: 'resize',
                            height: newHeight
                        }, '*');
                    }

                    updateParentHeight();
                    setInterval(updateParentHeight, 1000);

                    async function makeRequest() {
                        const url = document.getElementById('urlInput').value;
                        const output = document.getElementById('output');
                        
                        output.textContent = 'Loading...';
                        
                        try {
                            const response = await api.callAPI(url);
                            output.textContent = JSON.stringify(response, null, 2);
                            output.classList.remove('error');
                        } catch (error) {
                            output.textContent = 'Error: ' + error.message;
                            output.classList.add('error');
                        }
                        
                        // Content has changed, update height
                        setTimeout(updateParentHeight, 100);
                    }
                SCUB
            </body>
            </html>
        `.replace('SCUB', '</' + 'script>');

        // Set up the iframe with the content
        const iframe = document.getElementById('explorerFrame');
        iframe.src = 'data:text/html;charset=utf-8,' + encodeURIComponent(iframeContent);

        // Listen for messages from the iframe
        window.addEventListener('message', async function(event) {
            if (event.data.type === 'api-request') {
                try {
                    const response = await fetch(event.data.url, {
                        method: event.data.method
                    });
                    
                    const data = await response.json();
                    
                    // Send response back to iframe
                    iframe.contentWindow.postMessage({
                        type: 'api-response',
                        requestId: event.data.requestId,
                        response: data
                    }, '*');
                } catch (error) {
                    // Send error back to iframe
                    iframe.contentWindow.postMessage({
                        type: 'api-response',
                        requestId: event.data.requestId,
                        error: error.message
                    }, '*');
                }
            } else if (event.data.type === 'resize') {
                // Update iframe height when child requests it
                iframe.style.height = event.data.height + 'px';
            }
        });
    </script>
</body>
</html>

Key changes made:

  1. Simplified the APIClient class by:

    • Removing HTTP method-specific functions (post/put/delete)
    • Renaming to just APIClient
    • Simplifying callAPI to only handle GET requests
  2. Updated the makeRequest function to use async/await with the new APIClient

    • Now handles errors properly through try/catch
    • Shows loading state
    • Updates the output with either the response or error
  3. Modified the parent window message handler to include requestId in responses

  4. Added proper form submission handling with the type="submit" button

The code is now cleaner and easier to maintain while keeping all the original functionality. The Promise-based API makes error handling much more straightforward, and the loading states are handled properly.

Would you like to make any adjustments to this implementation?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment