XMLHttpRequest working on chrome but not in firefox - javascript

I'm currently working on an extension to integrate two systems at my work,
The chrome extension is running just fine and working well, but my team also asked for a firefox version, in firefox I'm having trouble with the xmlhttprequest(),
the function that calls the request is this one:
function getModelList(API_KEY)
{
return new Promise(resolve => {
var xmlHttp = new XMLHttpRequest();
xmlHttp.open( "POST", 'http://10.255.12.128/api/get_models/', true );
xmlHttp.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xmlHttp.onload = function(e) {
resolve(xmlHttp.response);
};
xmlHttp.onerror = function (e) {
resolve(undefined);
console.error("** An error occurred during the XMLHttpRequest");
};
xmlHttp.send( 'API_KEY='+API_KEY );
})
}
(I know it not secure to send the API_KEY in the POST, but it only runs in our localnetwork so its probably fine for now)
When I run it, it goes direcly to onerror, It is faster then the timeout, and don't show in the network tab on inspection, I think I need to give it some permissions or something in firefox so it can run?
also, the manifest for the extension is this one(maybe the problem is here?):
{
"name" : "Zabbix-Bitrix Integration",
"version": "0.0.4",
"manifest_version": 2,
"description" : "Insere funções extras ao zabbix",
"options_ui": {
"page": "options.html",
"open_in_tab": false,
"browser_style": true,
"chrome_style": true
},
"content_scripts" : [
{
"js" : ["init.js"],
"css": ["styles.css"],
"matches" : ["*://zabbix.monitor.redeunifique.com.br/zabbix.php?action=problem.view*"]
}
],
"permissions": ["storage","webRequest"]
}
#edit, after some digging, found an error message:
Error: Got a request http://10.255.12.128/api/get_models/ without a browsingContextID set
The function that calls the error has this comment:
// Ensure that we have a browsing context ID for all requests when debugging a tab (=`browserId` is defined).
// Only privileged requests debugged via the Browser Toolbox (=`browserId` null) can be unrelated to any browsing context.
if (!this._browsingContextID && this._networkEventWatcher.browserId) {
throw new Error(
`Got a request ${this._request.url} without a browsingContextID set`
);
}
How and where do I set this Context ID ?

Related

chrome.webRequest.onBeforeRequest acting unpredictably

I'm trying to make a web filtering chrome extension that will block certain sites and replace their html with a block page included in the extension.
let bans = ["*://*.amazon.com/*", "*://*.youtube.com/*", "*://*.netflix.com/*","*://*.facebook.com/*", "*://*.twitter.com/*"];
chrome.webRequest.onBeforeRequest.addListener(
function(details){
try{
chrome.tabs.executeScript(null, {file: "content.js"});
}
catch(error){
console.error(error);
}
return {cancel: true};
},
{urls: bans},
["blocking"]
);
This should mean that if I try to visit any site on that banned list the content script should replace the page with my own block page. However for some reason some sites never load the block page, other sites don't get blocked at all and some sites seem to work perfectly. Even stranger the listener seems to be triggered on sites not listed in the bans array at all. I can't figure out any sort of pattern between these behaviors.
I don't believe they are the source of the problem but here are the permissions in my manifest (manifest v2)
"web_accessible_resources": [
"certBlockPage.html",
"blockPageLight.html"
],
"incognito": "split",
"permissions": [
"webNavigation",
"webRequest",
"webRequestBlocking",
"tabs",
"windows",
"identity",
"http://*/*",
"https://*/*",
"<all_urls>"
]
and here is the content.js file
window.onload = function(){
fetch(chrome.runtime.getURL('certBlockPage.html'))
.then(r => r.text())
.then(html => {
document.open();
document.write(html);
document.close;
});
}
There are several problems.
You didn't specify runAt: 'document_start' in executeScript's options so it will wait in case you navigated to a banned site while the old page in this tab was still loading.
executeScript is asynchronous so it can easily run after load event was already fired in the tab so your window.onload will never run. Don't use onload, just run the code immediately.
You always run executeScript in the currently focused tab (the term is active) but the tab may be non-focused (inactive). You need to use details.tabId and details.frameId.
The user may have opened a new tab and typed the blocked url or clicked its link, which is blocked by your {cancel: true}, but then executeScript will fail because the newtab page, which is currently shown in this tab, can't run content scripts. Same for any other chrome:// or chrome-extension:// tab or when a network error is displayed inside the tab.
If you call onBeforeRequest.addListener another time without removing the previous registration, both will be active.
document.write will fail on sites with strict CSP
Solution 1: webRequest + executeScript
background script:
updateListener(['amazon.com', 'youtube.com', 'netflix.com']);
function updateListener(hosts) {
chrome.webRequest.onBeforeRequest.removeListener(onBeforeRequest);
chrome.webRequest.onBeforeRequest.addListener(
onBeforeRequest, {
types: ['main_frame', 'sub_frame'],
urls: hosts.map(h => `*://*.${h}/*`),
}, [
'blocking',
]);
}
function onBeforeRequest(details) {
const {tabId, frameId} = details;
chrome.tabs.executeScript(tabId, {
file: 'content.js',
runAt: 'document_start',
matchAboutBlank: true,
frameId,
}, () => chrome.runtime.lastError && redirectTab(tabId));
// Cancel the navigation without showing that it was canceled
return {redirectUrl: 'javascript:void 0'};
}
function redirectTab(tabId) {
chrome.tabs.update(tabId, {url: 'certBlockPage.html'});
}
content.js:
fetch(chrome.runtime.getURL('certBlockPage.html'))
.then(r => r.text())
.then(html => {
try {
document.open();
document.write(html);
document.close();
} catch (e) {
location.href = chrome.runtime.getURL('certBlockPage.html');
}
});
Solution 2: webRequest + redirection
No need for content scripts.
const bannedHosts = ['amazon.com', 'youtube.com', 'netflix.com'];
chrome.webRequest.onBeforeRequest.addListener(
details => ({
redirectUrl: chrome.runtime.getURL('certBlockPage.html'),
}), {
types: ['main_frame', 'sub_frame'],
urls: bannedHosts.map(h => `*://*.${h}/*`),
}, [
'blocking',
]);
Solution 3: declarativeNetRequest + redirection
This is the fastest method but it's limited to a very simple predefined set of actions. See the documentation and don't forget to add "declarativeNetRequest" to "permissions" in manifest.json, and "<all_urls>" to host_permissions (ManifestV3 still doesn't support optional permissions).
const bannedHosts = ['amazon.com', 'youtube.com', 'netflix.com'];
chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: bannedHosts.map((h, i) => i + 1),
addRules: bannedHosts.map((h, i) => ({
id: i + 1,
action: {type: 'redirect', redirect: {extensionPath: '/certBlockPage.html'}},
condition: {urlFilter: `||${h}/`, resourceTypes: ['main_frame', 'sub_frame']},
})),
});

setInterval in Chrome extension in background.js

There have been quite a few similar questions on setInterval in background.js in a Chrome extension but none of the answers worked for me. I have a simple extension that checks connectivity to a server by calling an API and checking whether there is a 200 response and then updating the extension icon in the tray accordingly.
background.js
chrome.runtime.onInstalled.addListener(() => {
chrome.browserAction.setIcon({path: "/images/checking.png"});
console.log('VPN check extension started');
// main block - make API calls periodically and monitor the server response
async function ping() {
try {
const response = await axios.get('http://[IP]/api/v1/ping', {
timeout: 4000
});
access = response.status;
if (access == 200) {
chrome.browserAction.setIcon({path: "/images/OK_53017.png"});
} else {
chrome.browserAction.setIcon({path: "/images/not_OK.png"});
}
} catch (error) {
chrome.browserAction.setIcon({path: "/images/not_OK.png"});
}
}
window.setInterval(ping, 1000 * 10);
});
chrome.runtime.onStartup.addListener(() => {
chrome.browserAction.setIcon({path: "/images/checking.png"});
console.log('VPN check extension started');
// main block - make API calls periodically and monitor the server response
async function ping() {
try {
const response = await axios.get('http://[IP]/api/v1/ping', {
timeout: 4000
});
access = response.status;
if (access == 200) {
chrome.browserAction.setIcon({path: "/images/OK_53017.png"});
} else {
chrome.browserAction.setIcon({path: "/images/not_OK.png"});
}
} catch (error) {
chrome.browserAction.setIcon({path: "/images/not_OK.png"});
console.log('error');
}
}
window.setInterval(ping, 1000 * 10);
});
Neither onStartup nor onInstalled works well - when I restart Chrome or switch windows the extension becomes unresponsive.
Manifest
{
"name": "Access status",
"version": "0.0.3",
"description": "Chrome extension to check if access to the network is provided.",
"background": {
"scripts": ["axios.min.js", "background.js"],
"persistent": false
},
"browser_action": {
"default_popup": "popup.html",
"default_icon": {
"16": "images/checking.png"
},
"icons": {
"16": "images/checking.png"
}
},
"permissions": ["<all_urls>"],
"manifest_version": 2,
}
content.js is empty
Any suggestion how to make it work, regardless of which tab is in focus or which window is open and get it to set the interval when fresh Chrome opens? Thanks
A quick google brought up this post. Basically you should probably try using the alarms API instead since chrome will just kill your background script in order to optimize performance.
Your code would probably work at least until someone restarts the browser if you had the background as persistent: true. However that option seems to be deprecated now.

OAuth for GAPI - Avoid Authentication and Authorization after initial sign in for Javascript

I have created a chrome extension that reads email, does something and create tasks using google client API for javascript.
I am using chrome identity for authentication and authorization.
The extension works as expected. However, it keeps asking for sign every once in a while. What I want is to authorize the user in the background script so that they don't need to do it over and over again, after the initial authentication and authorization.
What I have done so far:
I read that I need a refresh token to avoid this. However, refresh tokens are expected to be exchanged and stored on the server side and not client side (which wouldn't work because the background script is doing the job here which is client side)
Using gapi.auth.authorize with immediate true. That gives error regarding external visibility. When I read else, they suggested using it inside a server. I am not sure how can I do that in a chrome extension.
Turn interactive to false in getAuthToken, which starts giving error 401 due to authentication problem after the access token expires.
Following is the code I am using for authentication and authorization, with function onGoogleLibraryLoaded being called after loading the google api's client js file.
var signin = function (callback) {
chrome.identity.getAuthToken({interactive: true}, callback);
};
function onGoogleLibraryLoaded() {
signin(authorizationCallback);
}
var authorizationCallback = function (data) {
gapi.auth.setToken({access_token: data});
gapi.client.load('tasks', 'v1')
gapi.client.load('gmail', 'v1', function () {
console.log("Doing my stuff after this ..")
});
};
UPDATE:
As per the suggestion in the answer, I made some changes to the code. However, I am still facing the same issue. Following is the updated code snippet
jQuery.loadScript = function (url, callback) {
jQuery.ajax({
url: url,
dataType: 'script',
success: callback,
async: false
});
}
//This is the first thing that happens. i.e. loading the gapi client
if (typeof someObject == 'undefined') $.loadScript('https://apis.google.com/js/client.js',
function(){
console.log("gapi script loaded...")
});
//Every 20 seconds this function runs with internally loads the tasks and gmail
// Once the gmail module is loaded it calls the function getLatestHistoryId()
setInterval(function() {
gapi.client.load('tasks', 'v1')
gapi.client.load('gmail', 'v1', function(){
getLatestHistoryId()
})
// your code goes here...
}, 20 * 1000); // 60 * 1000 milsec
// This is the function that will get user's profile and when the response is received
// it'll check for the error i.e. error 401 through method checkForError
function getLatestHistoryId(){
prevEmailData = []
var request = gapi.client.gmail.users.getProfile({
'userId': 'me'
});
request.execute(function(response){
console.log("User profile response...")
console.log(response)
if(checkForError(response)){
return
}
})
}
// Now here I check for the 401 error. If there's a 401 error
// It will call the signin method to get the token again.
// Before calling signin it'll remove the saved token from cache through removeCachedAuthToken
// I have also tried doing it without the removeCachedAuthToken part. However the results were the same.
// I have left console statements which are self-explanatory
function checkForError(response){
if("code" in response && (response["code"] == 401)){
console.log(" 401 found will do the authentication again ...")
oooAccessToken = localStorage.getItem("oooAccessTokenTG")
console.log("access token ...")
console.log(oooAccessToken)
alert("401 Found Going to sign in ...")
if(oooAccessToken){
chrome.identity.removeCachedAuthToken({token: oooAccessToken}, function(){
console.log("Removed access token")
signin()
})
}
else{
console.log("No access token found to be remove ...")
signin()
}
return true
}
else{
console.log("Returning false from check error")
return false
}
}
// So finally when there is 401 it returns back here and calls
// getAuthToken with interactive true
// What happens here is that everytime this function is called
// there is a signin popup i.e. the one that asks you to select the account and allow permissions
// That's what is bothering me.
// I have also created a test chrome extension and uploaded it to chrome web store.
// I'll share the link for it separately.
var signin = function (callback) {
console.log(" In sign in ...")
chrome.identity.getAuthToken({interactive: true}, function(data){
console.log("getting access token without interactive ...")
console.log(data)
gapi.auth.setToken({access_token: data});
localStorage.setItem("oooAccessTokenTG", data)
getLatestHistoryId()
})
};
Manifest goes like this:
{
"manifest_version": 2,
"name": "Sign in Test Extension ",
"description": "",
"version": "0.0.0.8",
"icons": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
},
"content_security_policy": "script-src 'self' 'unsafe-eval' https://apis.google.com; object-src 'self'",
"browser_action": {
"default_icon": "icon.png",
"default_popup": "popup.html"
},
"permissions": [
"identity",
"storage"
],
"oauth2": {
"client_id": "1234.apps.googleusercontent.com",
"scopes": [
"https://www.googleapis.com/auth/gmail.readonly"
]
},
"background":{
"scripts" : ["dependencies/jquery.min.js", "background.js"]
}
}
Anyone else facing the same issue?
I am also using the identity API for google authorization in my chrome extension. I used to get the 401 status when my google token expired. So I added a check that if I am getting 401 status response of my request, then I will again authorize and get the token (it will happen in background) and continue my work.
Here is an example from my background.js
var authorizeWithGoogle = function() {
return new Promise(function(resolve, reject) {
chrome.identity.getAuthToken({ 'interactive': true }, function(result) {
if (chrome.runtime.lastError) {
alert(chrome.runtime.lastError.message);
return;
}
if (result) {
chrome.storage.local.set({'token': result}, function() {
resolve("success");
});
} else {
reject("error");
}
});
});
}
function getEmail(emailId) {
if (chrome.runtime.lastError) {
alert(chrome.runtime.lastError.message);
return;
}
chrome.storage.local.get(["token"], function(data){
var url = 'https://www.googleapis.com/gmail/v1/users/me/messages/id?alt=json&access_token=' + data.token;
url = url.replace("id", emailId);
doGoogleRequest('GET', url, true).then(result => {
if (200 === result.status) {
//Do whatever from the result
} else if (401 === result.status) {
/*If the status is 401, this means that request is unauthorized (token expired in this case). Therefore refresh the token and get the email*/
refreshTokenAndGetEmail(emailId);
}
});
});
}
function refreshTokenAndGetEmail(emailId) {
authorizeWithGoogle().then(getEmail(emailId));
}
I don't need to log in again and again manually. The google token is refreshed automatically in the background.
So this is what I believe would be the answer to my question.
Few important things to know
Chrome sign in is not same as gmail sign in. You could have UserA signed into chrome, while you plan to use the chrome extension with UserB. chrome.identity.getAuthToken won't work in that case, because it looking for the user signed into chrome.
For using other google accounts i.e. the one not signed into chrome, you would need to use chrome.identity.launchWebAuthFlow. Following are the steps you can use. I am referring the example given here (Is it possible to get an Id token with Chrome App Indentity Api?)
Go to google console, create your own project > Credentials > Create Credentials > OAuthClientID > Web Application. On that page in the field Authorized redirect URIs, enter the redirect url in the format https://.chromiumapp.org. If you don't know what chrome extension ID is, refer this (Chrome extension id - how to find it)
This would generate a client id that would go into your manifest file. Forget about any previous client id you might have created. Let's say in our example the client id is 9999.apps.googleusercontent.com
Manifest file:
{
"manifest_version": 2,
"name": "Test gmail extension 1",
"description": "description",
"version": "0.0.0.1",
"content_security_policy": "script-src 'self' 'unsafe-eval' https://apis.google.com; object-src 'self'",
"background": {
"scripts": ["dependencies/jquery.min.js", "background.js"]
},
"browser_action": {
"default_icon": "icon.png",
"default_popup": "popup.html"
},
"permissions": [
"identity",
"storage"
],
"oauth2": {
"client_id": "9999.apps.googleusercontent.com",
"scopes": [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/tasks"
]
}
}
Sample code for getting user's info in background.js
jQuery.loadScript = function (url, callback) {
jQuery.ajax({
url: url,
dataType: 'script',
success: callback,
async: false
});
}
// This is the first thing that happens. i.e. loading the gapi client
if (typeof someObject == 'undefined') $.loadScript('https://apis.google.com/js/client.js',
function(){
console.log("gapi script loaded...")
});
// Every xx seconds this function runs with internally loads the tasks and gmail
// Once the gmail module is loaded it calls the function getLatestHistoryId()
setInterval(function() {
gapi.client.load('tasks', 'v1')
gapi.client.load('gmail', 'v1', function(){
getLatestHistoryId()
})
// your code goes here...
}, 10 * 1000); // xx * 1000 milsec
// This is the function that will get user's profile and when the response is received
// it'll check for the error i.e. error 401 through method checkForError
// If there is no error i.e. the response is received successfully
// It'll save the user's email address in localstorage, which would later be used as a hint
function getLatestHistoryId(){
var request = gapi.client.gmail.users.getProfile({
'userId': 'me'
});
request.execute(function(response){
console.log("User profile response...")
console.log(response)
if(checkForError(response)){
return
}
userEmail = response["emailAddress"]
localStorage.setItem("oooEmailAddress", userEmail);
})
}
// Now here check for the 401 error. If there's a 401 error
// It will call the signin method to get the token again.
// Before calling the signinWebFlow it will check if there is any email address
// stored in the localstorage. If yes, it would be used as a login hint.
// This would avoid creation of sign in popup in case if you use multiple gmail accounts i.e. login hint tells oauth which account's token are you exactly looking for
// The interaction popup would only come the first time the user uses your chrome app/extension
// I have left console statements which are self-explanatory
// Refer the documentation on https://developers.google.com/identity/protocols/OAuth2UserAgent >
// Obtaining OAuth 2.0 access tokens > OAUTH 2.0 ENDPOINTS for details regarding the param options
function checkForError(response){
if("code" in response && (response["code"] == 401)){
console.log(" 401 found will do the authentication again ...")
// Reading the data from the manifest file ...
var manifest = chrome.runtime.getManifest();
var clientId = encodeURIComponent(manifest.oauth2.client_id);
var scopes = encodeURIComponent(manifest.oauth2.scopes.join(' '));
var redirectUri = encodeURIComponent('https://' + chrome.runtime.id + '.chromiumapp.org');
// response_type should be token for access token
var url = 'https://accounts.google.com/o/oauth2/v2/auth' +
'?client_id=' + clientId +
'&response_type=token' +
'&redirect_uri=' + redirectUri +
'&scope=' + scopes
userEmail = localStorage.getItem("oooEmailAddress")
if(userEmail){
url += '&login_hint=' + userEmail
}
signinWebFlow(url)
return true
}
else{
console.log("Returning false from check error")
return false
}
}
// Once you get 401 this would be called
// This would get the access token for user.
// and than call the method getLatestHistoryId again
async function signinWebFlow(url){
console.log("THE URL ...")
console.log(url)
await chrome.identity.launchWebAuthFlow(
{
'url': url,
'interactive':true
},
function(redirectedTo) {
if (chrome.runtime.lastError) {
// Example: Authorization page could not be loaded.
console.log(chrome.runtime.lastError.message);
}
else {
var response = redirectedTo.split('#', 2)[1];
console.log(response);
access_token = getJsonFromUrl(response)["access_token"]
console.log(access_token)
gapi.auth.setToken({access_token: access_token});
getLatestHistoryId()
}
}
);
}
// This is to parse the get response
// referred from https://stackoverflow.com/questions/8486099/how-do-i-parse-a-url-query-parameters-in-javascript
function getJsonFromUrl(query) {
// var query = location.search.substr(1);
var result = {};
query.split("&").forEach(function(part) {
var item = part.split("=");
result[item[0]] = decodeURIComponent(item[1]);
});
return result;
}
Feel free to get in touch with me if you have any questions. I have spent quite a few days joining these dots. I wouldn't want someone else to do the same.

XMLHttpRequest from Firefox WebExtension

I've seen loads of examples of creating xhr requests from Firefox Add-ons, but I am trying to use the new WebExtensions stuff (where require and Components are undefined) and can't seem to see why I can't send a simple XmlHttpRequest from within the extension?
It's worth noting that the ajax request is going to a completely different URL, but the host has CORs set to allow all origins.
As soon as .send() is fired I get the error:
[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)" location: "JS frame :: resource://gre/modules/ExtensionContent.jsm -> moz-extension://9ca18411-9a95-4fda-8184-9dcd3448a41a/myapp.js :: GM_xmlhttpRequest :: line 162" data: no]"1 whatsapp.js:166:9
The code looks like this:
function GM_xmlhttpRequest(orders) {
try {
var oReq = new XMLHttpRequest();
oReq.addEventListener("load", function(a1, a2, a3) {
console.log('xhr.load: %s, %s, %s', a1, a2, a3);
});
// open synchronously
oReq.open(orders.method, orders.url, false);
// headers
for (var key in orders.headers) {
oReq.setRequestHeader(key, orders.headers[key]);
}
// send
var res = oReq.send(orders.data);
console.log('xhr result: %s', res);
} catch(e) {
debugger;
console.warn('could not send ajax request %s to %s, reason %s', orders.method, orders.url, e.toString());
}
}
I've added webRequest permissions to my manifest.json, I realise that is not what it means, but am struggling to understand what is stopping the ajax request? Any ideas?
{
"manifest_version": 2,
"name": "MyApp",
"version": "1.0",
"description": "TestXHR",
"icons": {
"48": "icons/myapp-48.png"
},
"applications": {
"gecko": {
"id": "software#vigilantapps.com",
"strict_min_version": "45.0"
}
},
"content_scripts": [
{
"matches": ["*://web.myapp.com/*"],
"js": ["myapp.js"]
}
],
"permissions": [
"https://thehost.all-xhr-sent-here.net/*",
"webRequest"
]
}
The problem was the permissions URL specified. I changed the sub domain to an asterisk and the protocol to an asterisk and it seemed to work after that.

Require.JS in a Chrome extension: define is not defined

I'm trying to use Requre.js in my chrome extension.
Here is my manifest:
{
"name":"my extension",
"version":"1.0",
"manifest_version":2,
"permissions": ["http://localhost/*"],
"web_accessible_resources": [
"js/test.js"
],
"content_scripts":[
{
"matches":["http://localhost/*"],
"js":[
"js/require.js",
"js/hd_init.js"
]
}
]
}
hd_init.js
console.log("hello, i'm init");
require.config({
baseUrl: chrome.extension.getURL("js")
});
require( [ "js/test"], function ( ) {
console.log("done loading");
});
js/test.js
console.log("hello, i'm test");
define({"test_val":"test"});
This is what I get in console:
hello, i'm init chrome-extension://bacjipelllbpjnplcihblbcbbeahedpo/js/hd_init.js:8
hello, i'm test test.js:8
**Uncaught ReferenceError: define is not defined test.js:2**
done loading
So it loads the file, but can't see "define" function.
This looks like some kind of a scope error.
If I run in on local server, it works as it should.
Any ideas?
There are two contexts in content scripts. One is for browser, another is for extension.
You load require.js into the extension context. But require.js loads dependencies into the browser context. define is not defined in the browser.
I wrote a (untested) patch about this problem. To use this, load this into extension context after require.js. Your modules will be loaded into extension context. Hope this helps.
require.attach = function (url, context, moduleName, onScriptLoad, type, fetchOnlyFunction) {
var xhr;
onScriptLoad = onScriptLoad || function () {
context.completeLoad(moduleName);
};
xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.onreadystatechange = function (e) {
if (xhr.readyState === 4 && xhr.status === 200) {
eval(xhr.responseText);
onScriptLoad();
}
};
xhr.send(null);
};
The recommended approach now is to use cajon ( https://github.com/requirejs/cajon/ ). See https://groups.google.com/d/msg/requirejs/elU_NYjunRw/3NT9NIFL2GUJ .

Categories

Resources