Search code examples
angularjsonp

How to do a JSONP request with Angular to a static file when you can't set the callback function name?


Hello fellow problem solvers! I'm hoping to learn something from you. I ran into a challenge that could probably teach me more about Angular, and I would appreciate any input anyone might have.

Background

My company shares a common footer among many web sites, and we lazy-load that into each site using JSONP. The footer content is managed by a system that generates static HTML, for high performance, low costs, and high uptime. When we lazy-load that footer into pages, we cannot control the name of the callback function call included in the response.

Our footer might look something like this footer.json file:

    footerCallback(["<footer>FROM THE FOOTER</footer>"]);

We lazy-load that into HTML pages with simple pure-Javascript code like this:

    <div id="footer"></div>
    <script>
      function footerCallback(json_data){
        document.getElementById('footer').outerHTML = json_data[0];
      }
      window.onload = function() {
        var script = document.createElement('script');
        script.src = 'assets/static-footer.json'
        script.async = true;
        document.getElementsByTagName('head')[0].appendChild(script);
      }
    </script>

I posted a working demo on GitHub, here. Unfortunately I couldn't post it on Stackblitz to make it easier to run, since it uses JSONP requests. This version includes the shared footer in exactly the way that I described above, right here.

Challenge

We need to include that same central footer in Angluar apps, so I'm trying to create a footer component that will insert that lazy-loaded JSONP footer into an Angular app.

Approach

I created a footer component and a footer service, and I'm using HttpClientJsonpModule to do a JSONP request. That part works. The service sends the JSONP request and I can see the response in my inspector in Chrome.

Problem

The part that does not work is the callback. I can't remove the original global callback function from the main index.html because I don't know how to replace it with a function that the JSONP response can trigger. I was able to move the callback function into the component, but only by continuing to define it globally:

    function footerCallback(json_data){
      document.getElementById('footer').outerHTML = json_data[0];
    }
    const _global = (window) as any
    _global.footerCallback = footerCallback

Angular expects to be able to tell the JSONP server the name of the callback function to include in the JSONP response. But there is no server, it's just a static file.

Angular is flexible enough that I can tell it the URL parameter to use to specify that callback function. If I specify "callback" as the callback function name with this.http.jsonp(this.footerURL,'callback') then it will issue a request like this:

    https://...footer.min.json?callback=ng_jsonp_callback_0

That's a nice feature and all, but it's the ng_jsonp_callback_0 that I need to customize, not the URL parameter. Because the server cannot adjust its response to include a reference to the function ng_jsonp_callback_0. Because there is no server, it's just a static file.

Ugly solution

My workaround was to define the callback function in the global scope. That way, I can support whatever callback function names the API or static file requires.

This at least enables me to encapsulate everything related to the lazy-loading footer into a footer component with its own service. But I feel like I'm not really doing this in the Angular Way, if I'm polluting the global scope.

Question

Is there a way for me to manually specify the name of that callback function myself, rather than letting Angular pick the name ng_jsonp_callback_0 or whatever?

If not, then is there some other elegant way to handle this? Other than using a global callback function?


Solution

  • Well..the first thing is that Angular has hardcoded the callback, if you check the source code you will see something like this:

    /**
         * Get the name of the next callback method, by incrementing the global `nextRequestId`.
         */
        nextCallback() {
            return `ng_jsonp_callback_${nextRequestId++}`;
        }
    

    So when the ng_jsonp_callback_ is not called, Angular throws an error:

     this.resolvedPromise.then(() => {
                        // Cleanup the page.
                        cleanup();
                        // Check whether the response callback has run.
                        if (!finished) {
                            // It hasn't, something went wrong with the request. Return an error via
                            // the Observable error path. All JSONP errors have status 0.
                            observer.error(new HttpErrorResponse({
                                url,
                                status: 0,
                                statusText: 'JSONP Error',
                                error: new Error(JSONP_ERR_NO_CALLBACK),
                            }));
                            return;
                        }
                        // Success. body either contains the response body or null if none was
                        // returned.
                        observer.next(new HttpResponse({
                            body,
                            status: 200,
                            statusText: 'OK',
                            url,
                        }));
                        // Complete the stream, the response is over.
                        observer.complete();
                    });
    

    Angular is just loading the url with the callback that they define and uses the window object or an empty object, as you can see in the source code:

    /**
     * Factory function that determines where to store JSONP callbacks.
     *
     * Ordinarily JSONP callbacks are stored on the `window` object, but this may not exist
     * in test environments. In that case, callbacks are stored on an anonymous object instead.
     *
     *
     */
    export function jsonpCallbackContext() {
        if (typeof window === 'object') {
            return window;
        }
        return {};
    }
    

    Now that we know this, we will do the following:

    1. Invoke the ng_jsonp_callback_ so we can skip the error and subscribe to the response. We need to insert the script for this.
    2. Use Renderer2 to manipulate the DOM.
    3. Inject ElementRef in the component
    4. Remove the content in the html we dont need it.
    5. Create and insert an element with the json_data into the elementRef of the component

    Now in the component do the following:

    export class FooterComponent implements AfterViewInit {
    
      // Steps 2 and 3
      constructor(private footerService: FooterService,
        private elementRef: ElementRef,
        private renderer: Renderer2) { }
    
      ngAfterViewInit() {
        this.addScript();
        this.footerService.getFooter().subscribe((data: string[]) => { this.footerCallback(data) });
      }
     
     // step 1
      private addScript() {
        // Wrap the ng_jsonp_callback_0 with your footerCallback 
        const scriptSrc = `window.footerCallback = function(json_data) {window.ng_jsonp_callback_0(json_data);}`;
        const scriptElement: HTMLScriptElement = this.renderer.createElement('script');
        scriptElement.innerHTML = scriptSrc;
        this.renderer.appendChild(this.elementRef.nativeElement, scriptElement);
      }
    
      // Insert a new Element with the json_data
      private footerCallback(json_data: string[]) {
        const footerElement: HTMLElement = this.renderer.createElement('div');
        footerElement.innerHTML = json_data[0];
        this.renderer.appendChild(this.elementRef.nativeElement, footerElement);
      }
    }
    

    That is it, hope it helps.

    EDIT Don't forget to do cleanup.