Written by Maciej Cieślar✏️
JSONP has always been one of the most poorly explained concepts in all of web development. This is likely due to its confusing name and overall sketchy background. Prior to the adoption of the Cross-Origin Resource Sharing (CORS) standard, JSONP was the only option to get a JSON response from a server of a different origin.
After sending a request to a server of a different origin that doesn’t support CORS, the following error would be thrown:
Upon seeing this, many people would Google it just to find out that JSONP would be needed to bypass the same-origin policy. Then jQuery, ubiquitous back in the day, would swoop in with its convenient JSONP implementation baked right into the core library so that we could get it working by switching just one parameter. Many people never understood that what changed completely was the underlying mechanism of sending the request.
$.ajax({
url: 'http://twitter.com/status/user_timeline/padraicb.json?count=10',
dataType: 'jsonp',
success: function onSuccess() { }
});
In order to understand what went on behind the scenes, let’s take a look at what JSONP really is.
What is JSONP?
JSON with Padding — JSONP for short — is a technique that allows developers to bypass the same-origin policy enforced by browsers by using the <script>
element’s nature. The policy disallows reading any responses sent by websites whose origins are different from the one currently used. Incidentally, the policy allows sending a request, but not reading one.
A website’s origin consists of three parts. First, there’s the URI scheme (i.e., https://
), then the host name (i.e., logrocket.com
), and, finally, the port (i.e., 443
). Websites like http://logrocket.com
and https://logrocket.com
have two different origins due to the URI Scheme difference.
If you wish to learn more about this policy, look no further.
How does it work?
Let’s assume that we are on localhost:8000
and we send a request to a server providing a JSON API.
https://www.server.com/api/person/1
The response may look like this:
{
"firstName": "Maciej",
"lastName": "Cieslar"
}
But due to the aforementioned policy, the request would be blocked because the origins of the website and the server differ.
Instead of sending the request ourselves, the <script>
element can be used, to which the policy doesn’t apply — it can load and execute JavaScript from a source of foreign origin. This way, a website located on https://logrocket.com
can load the Google Maps library from its provider located under a different origin (i.e., CDN).
By providing the API’s endpoint URL to the <script>
’s src
attribute, the <script>
would fetch the response and execute it inside the browser context.
<script src="https://www.server.com/api/person/1" async="true"></script>
The problem, though, is that the <script>
element automatically parses and executes the returned code. In this case, the returned code would be the JSON snippet shown above. The JSON would be parsed as JavaScript code and, thus, throw an error because it is not a valid JavaScript.
A fully working JavaScript code has to be returned for it to be parsed and executed correctly by the <script>
. The JSON code would work just fine had we assigned it to a variable or passed it as an argument to a function — after all, the JSON format is just a JavaScript object.
So instead of returning a pure JSON response, the server can return a JavaScript code. In the returned code, a function is wrapped around the JSON object. The function name has to be passed by the client since the code is going to be executed in the browser. The function name is provided in the query parameter called callback
.
After providing the callback’s name in the query, we create a function in the global (window
) context, which will be called once the response is parsed and executed.
https://www.server.com/api/person/1?callback=callbackName
callbackName({
"firstName": "Maciej",
"lastName": "Cieslar"
})
Which is the same as:
window.callbackName({
"firstName": "Maciej",
"lastName": "Cieslar"
})
The code is executed in the browser’s context. The function will be executed from inside the code downloaded in <script>
in the global scope.
In order for JSONP to work, both the client and the server have to support it. While there’s no standard name for the parameter that defines the name of the function, the client will usually send it in the query parameter named callback
.
Implementation
Let’s create a function called jsonp
that will send the request in the JSONP fashion.
let jsonpID = 0;
function jsonp(url, timeout = 7500) {
const head = document.querySelector('head');
jsonpID += 1;
return new Promise((resolve, reject) => {
let script = document.createElement('script');
const callbackName = `jsonpCallback${jsonpID}`;
script.src = encodeURI(`${url}?callback=${callbackName}`);
script.async = true;
const timeoutId = window.setTimeout(() => {
cleanUp();
return reject(new Error('Timeout'));
}, timeout);
window[callbackName] = data => {
cleanUp();
return resolve(data);
};
script.addEventListener('error', error => {
cleanUp();
return reject(error);
});
function cleanUp() {
window[callbackName] = undefined;
head.removeChild(script);
window.clearTimeout(timeoutId);
script = null;
}
head.appendChild(script);
});
}
As you can see, there’s a shared variable called jsonpID
— it will be used to make sure that each request has its own unique function name.
First, we save the reference to the <head>
object inside a variable called head
. Then we increment the jsonpID
to make sure the function name is unique. Inside the callback provided to the returned promise, we create a <script>
element and the callbackName
consisting of the string jsonpCallback
concatenated with the unique ID.
Then, we set the src
attribute of the <script>
element to the provided URL. Inside the query, we set the callback parameter to equal callbackName
. Note that this simplified implementation doesn’t support URLs that have predefined query parameters, so it wouldn’t work for something like https://logrocket.com/?param=true
, because we would append ?
at the end once again.
We also set the async
attribute to true
in order for the script to be non-blocking.
There are three possible outcomes of the request:
- The request is successful and, hopefully, executes the
window[callbackName]
, which resolves the promise with the result (JSON) - The
<script>
element throws an error and we reject the promise - The request takes longer than expected and the timeout callback kicks in, throwing a timeout error
const timeoutId = window.setTimeout(() => {
cleanUp();
return reject(new Error('Timeout'));
}, timeout);
window[callbackName] = data => {
cleanUp();
return resolve(data);
};
script.addEventListener('error', error => {
cleanUp();
return reject(error);
});
The callback has to be registered on the window
object for it to be available from inside the created <script>
context. Executing a function called callback()
in the global scope is equivalent to calling window.callback()
.
By abstracting the cleanup process in the cleanUp
function, the three callbacks — timeout, success, and error listener — look exactly the same. The only difference is whether they resolve or reject the promise.
function cleanUp() {
window[callbackName] = undefined;
head.removeChild(script);
window.clearTimeout(timeoutId);
script = null;
}
The cleanUp
function is an abstraction of what needs to be done in order to clean up after the request. The function first removes the callback registered on the window, which is called upon successful response. Then it removes the <script>
element from <head>
and clears the timeout. Also, just to be sure, it sets the script
reference to null
so that it is garbage-collected.
Finally, we append the <script>
element to <head>
in order to fire the request. <script>
will send the request automatically once it is appended.
Here’s the example of the usage:
jsonp('https://gist.github.com/maciejcieslar/1c1f79d5778af4c2ee17927de769cea3.json')
.then(console.log)
.catch(console.error);
Here’s a live example.
Summary
By understanding the underlying mechanism of JSONP, you probably won’t gain much in terms of directly applicable web skills, but it’s always interesting to see how people’s ingenuity can bypass even the strictest policies.
JSONP is a relic of the past and shouldn’t be used due to numerous limitations (e.g., being able to send GET requests only) and many security concerns (e.g., the server can respond with whatever JavaScript code it wants — not necessarily the one we expect — which then has access to everything in the context of the window, including localStorage
and cookies
). Read more here.
Instead, we should rely on the CORS mechanism to provide safe cross-origin requests.
Editor's note: Seeing something wrong with this post? You can find the correct version here.
Plug: LogRocket, a DVR for web apps
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
Try it for free.
The post JSONP demystified: What it is and why it exists appeared first on LogRocket Blog.
Top comments (0)