mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
Modernize appcache package (#9600)
This commit is contained in:
committed by
Ben Newman
parent
b5a5935b09
commit
552764635e
@@ -1,67 +1,69 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
if (window.applicationCache) {
|
||||
|
||||
var appCacheStatuses = [
|
||||
'uncached',
|
||||
'idle',
|
||||
'checking',
|
||||
'downloading',
|
||||
'updateready',
|
||||
'obsolete'
|
||||
];
|
||||
const appCacheStatuses = [
|
||||
'uncached',
|
||||
'idle',
|
||||
'checking',
|
||||
'downloading',
|
||||
'updateready',
|
||||
'obsolete'
|
||||
];
|
||||
|
||||
var updatingAppcache = false;
|
||||
var reloadRetry = null;
|
||||
var appcacheUpdated = false;
|
||||
let updatingAppcache = false;
|
||||
let reloadRetry = null;
|
||||
let appcacheUpdated = false;
|
||||
|
||||
Reload._onMigrate('appcache', function (retry) {
|
||||
if (appcacheUpdated)
|
||||
return [true];
|
||||
|
||||
// An uncached application (one that does not have a manifest) cannot
|
||||
// be updated.
|
||||
if (window.applicationCache.status === window.applicationCache.UNCACHED)
|
||||
return [true];
|
||||
|
||||
if (!updatingAppcache) {
|
||||
try {
|
||||
window.applicationCache.update();
|
||||
} catch (e) {
|
||||
Meteor._debug('applicationCache update error', e);
|
||||
// There's no point in delaying the reload if we can't update the cache.
|
||||
Reload._onMigrate('appcache', retry => {
|
||||
if (appcacheUpdated)
|
||||
return [true];
|
||||
|
||||
// An uncached application (one that does not have a manifest) cannot
|
||||
// be updated.
|
||||
if (window.applicationCache.status === window.applicationCache.UNCACHED)
|
||||
return [true];
|
||||
|
||||
if (!updatingAppcache) {
|
||||
try {
|
||||
window.applicationCache.update();
|
||||
} catch (e) {
|
||||
Meteor._debug('applicationCache update error', e);
|
||||
// There's no point in delaying the reload if we can't update the cache.
|
||||
return [true];
|
||||
}
|
||||
updatingAppcache = true;
|
||||
}
|
||||
updatingAppcache = true;
|
||||
}
|
||||
|
||||
// Delay migration until the app cache has been updated.
|
||||
reloadRetry = retry;
|
||||
return false;
|
||||
});
|
||||
// Delay migration until the app cache has been updated.
|
||||
reloadRetry = retry;
|
||||
return false;
|
||||
});
|
||||
|
||||
// If we're migrating and the app cache is now up to date, signal that
|
||||
// we're now ready to migrate.
|
||||
var cacheIsNowUpToDate = function () {
|
||||
if (!updatingAppcache)
|
||||
return;
|
||||
appcacheUpdated = true;
|
||||
reloadRetry();
|
||||
};
|
||||
|
||||
window.applicationCache.addEventListener('updateready', cacheIsNowUpToDate, false);
|
||||
window.applicationCache.addEventListener('noupdate', cacheIsNowUpToDate, false);
|
||||
|
||||
// We'll get the obsolete event on a 404 fetching the app.manifest:
|
||||
// we had previously been running with an app cache, but the app
|
||||
// cache has now been disabled or the appcache package removed.
|
||||
// Reload to get the new non-cached code.
|
||||
|
||||
window.applicationCache.addEventListener('obsolete', (function () {
|
||||
if (reloadRetry) {
|
||||
cacheIsNowUpToDate();
|
||||
} else {
|
||||
// If we're migrating and the app cache is now up to date, signal that
|
||||
// we're now ready to migrate.
|
||||
const cacheIsNowUpToDate = () => {
|
||||
if (!updatingAppcache)
|
||||
return;
|
||||
appcacheUpdated = true;
|
||||
Reload._reload();
|
||||
}
|
||||
}), false);
|
||||
reloadRetry();
|
||||
};
|
||||
|
||||
window.applicationCache.addEventListener('updateready', cacheIsNowUpToDate, false);
|
||||
window.applicationCache.addEventListener('noupdate', cacheIsNowUpToDate, false);
|
||||
|
||||
// We'll get the obsolete event on a 404 fetching the app.manifest:
|
||||
// we had previously been running with an app cache, but the app
|
||||
// cache has now been disabled or the appcache package removed.
|
||||
// Reload to get the new non-cached code.
|
||||
|
||||
window.applicationCache.addEventListener('obsolete', () => {
|
||||
if (reloadRetry) {
|
||||
cacheIsNowUpToDate();
|
||||
} else {
|
||||
appcacheUpdated = true;
|
||||
Reload._reload();
|
||||
}
|
||||
}, false);
|
||||
|
||||
} // if window.applicationCache
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
var crypto = Npm.require('crypto');
|
||||
var fs = Npm.require('fs');
|
||||
var path = Npm.require('path');
|
||||
import { Meteor } from 'meteor/meteor'
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
var _disableSizeCheck = false;
|
||||
let _disableSizeCheck = false;
|
||||
let disabledBrowsers = {};
|
||||
|
||||
Meteor.AppCache = {
|
||||
config: function (options) {
|
||||
_.each(options, function (value, option) {
|
||||
config: options => {
|
||||
Object.keys(options).forEach(option => {
|
||||
value = options[option];
|
||||
if (option === 'browsers') {
|
||||
disabledBrowsers = {};
|
||||
_.each(value, function (browser) {
|
||||
disabledBrowsers[browser] = false;
|
||||
});
|
||||
value.each(browser => disabledBrowsers[browser] = false);
|
||||
}
|
||||
else if (option === 'onlineOnly') {
|
||||
_.each(value, function (urlPrefix) {
|
||||
RoutePolicy.declare(urlPrefix, 'static-online');
|
||||
});
|
||||
value.forEach(urlPrefix =>
|
||||
RoutePolicy.declare(urlPrefix, 'static-online')
|
||||
);
|
||||
}
|
||||
// option to suppress warnings for tests.
|
||||
else if (option === '_disableSizeCheck') {
|
||||
@@ -34,27 +35,22 @@ Meteor.AppCache = {
|
||||
}
|
||||
};
|
||||
|
||||
var disabledBrowsers = {};
|
||||
var browserDisabled = function (request) {
|
||||
return disabledBrowsers[request.browser.name];
|
||||
};
|
||||
const browserDisabled = request => disabledBrowsers[request.browser.name];
|
||||
|
||||
function isDynamic(resource) {
|
||||
return resource.type === 'dynamic js' ||
|
||||
const isDynamic = resource =>
|
||||
resource.type === 'dynamic js' ||
|
||||
(resource.type === 'json' &&
|
||||
// TODO Update this test with PR #9439.
|
||||
resource.url.startsWith('/dynamic/') &&
|
||||
resource.url.endsWith('.map'))
|
||||
}
|
||||
resource.url.endsWith('.map'));
|
||||
|
||||
WebApp.addHtmlAttributeHook(function (request) {
|
||||
if (browserDisabled(request))
|
||||
return null;
|
||||
else
|
||||
return { manifest: "/app.manifest" };
|
||||
});
|
||||
WebApp.addHtmlAttributeHook(request =>
|
||||
browserDisabled(request) ?
|
||||
null :
|
||||
{ manifest: "/app.manifest" }
|
||||
);
|
||||
|
||||
WebApp.connectHandlers.use(function (req, res, next) {
|
||||
WebApp.connectHandlers.use((req, res, next) => {
|
||||
if (req.url !== '/app.manifest') {
|
||||
return next();
|
||||
}
|
||||
@@ -75,7 +71,7 @@ WebApp.connectHandlers.use(function (req, res, next) {
|
||||
return;
|
||||
}
|
||||
|
||||
var manifest = "CACHE MANIFEST\n\n";
|
||||
let manifest = "CACHE MANIFEST\n\n";
|
||||
|
||||
// After the browser has downloaded the app files from the server and
|
||||
// has populated the browser's application cache, the browser will
|
||||
@@ -85,7 +81,7 @@ WebApp.connectHandlers.use(function (req, res, next) {
|
||||
// So to ensure that the client updates if client resources change,
|
||||
// include a hash of client resources in the manifest.
|
||||
|
||||
manifest += "# " + WebApp.clientHash() + "\n";
|
||||
manifest += `# ${WebApp.clientHash()}\n`;
|
||||
|
||||
// When using the autoupdate package, also include
|
||||
// AUTOUPDATE_VERSION. Otherwise the client will get into an
|
||||
@@ -94,16 +90,16 @@ WebApp.connectHandlers.use(function (req, res, next) {
|
||||
// reload again trying to get the new code.
|
||||
|
||||
if (Package.autoupdate) {
|
||||
var version = Package.autoupdate.Autoupdate.autoupdateVersion;
|
||||
const version = Package.autoupdate.Autoupdate.autoupdateVersion;
|
||||
if (version !== WebApp.clientHash())
|
||||
manifest += "# " + version + "\n";
|
||||
manifest += `# ${version}\n`;
|
||||
}
|
||||
|
||||
manifest += "\n";
|
||||
|
||||
manifest += "CACHE:" + "\n";
|
||||
manifest += "/" + "\n";
|
||||
_.each(WebApp.clientPrograms[WebApp.defaultArch].manifest, function (resource) {
|
||||
manifest += "CACHE:\n";
|
||||
manifest += "/\n";
|
||||
WebApp.clientPrograms[WebApp.defaultArch].manifest.forEach(resource => {
|
||||
if (resource.where === 'client' &&
|
||||
! RoutePolicy.classify(resource.url) &&
|
||||
! isDynamic(resource)) {
|
||||
@@ -116,7 +112,7 @@ WebApp.connectHandlers.use(function (req, res, next) {
|
||||
// the user can't modify the asset until the cache headers
|
||||
// expire.
|
||||
if (!resource.cacheable)
|
||||
manifest += "?" + resource.hash;
|
||||
manifest += `?${resource.hash}`;
|
||||
|
||||
manifest += "\n";
|
||||
}
|
||||
@@ -124,7 +120,7 @@ WebApp.connectHandlers.use(function (req, res, next) {
|
||||
manifest += "\n";
|
||||
|
||||
manifest += "FALLBACK:\n";
|
||||
manifest += "/ /" + "\n";
|
||||
manifest += "/ /\n";
|
||||
// Add a fallback entry for each uncacheable asset we added above.
|
||||
//
|
||||
// This means requests for the bare url ("/image.png" instead of
|
||||
@@ -133,13 +129,12 @@ WebApp.connectHandlers.use(function (req, res, next) {
|
||||
// request to the server and have the asset served from cache by
|
||||
// specifying the full URL with hash in their code (manually, with
|
||||
// some sort of URL rewriting helper)
|
||||
_.each(WebApp.clientPrograms[WebApp.defaultArch].manifest, function (resource) {
|
||||
WebApp.clientPrograms[WebApp.defaultArch].manifest.forEach(resource => {
|
||||
if (resource.where === 'client' &&
|
||||
! RoutePolicy.classify(resource.url) &&
|
||||
! resource.cacheable &&
|
||||
! isDynamic(resource)) {
|
||||
manifest += resource.url + " " + resource.url +
|
||||
"?" + resource.hash + "\n";
|
||||
manifest += `${resource.url} ${resource.url}?${resource.hash}\n`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -148,29 +143,24 @@ WebApp.connectHandlers.use(function (req, res, next) {
|
||||
manifest += "NETWORK:\n";
|
||||
// TODO adding the manifest file to NETWORK should be unnecessary?
|
||||
// Want more testing to be sure.
|
||||
manifest += "/app.manifest" + "\n";
|
||||
_.each(
|
||||
[].concat(
|
||||
RoutePolicy.urlPrefixesFor('network'),
|
||||
RoutePolicy.urlPrefixesFor('static-online')
|
||||
),
|
||||
function (urlPrefix) {
|
||||
manifest += urlPrefix + "\n";
|
||||
}
|
||||
);
|
||||
manifest += "*" + "\n";
|
||||
manifest += "/app.manifest\n";
|
||||
[
|
||||
...RoutePolicy.urlPrefixesFor('network'),
|
||||
...RoutePolicy.urlPrefixesFor('static-online')
|
||||
].forEach(urlPrefix => manifest += `${urlPrefix}\n`);
|
||||
manifest += "*\n";
|
||||
|
||||
// content length needs to be based on bytes
|
||||
var body = Buffer.from(manifest);
|
||||
const body = Buffer.from(manifest);
|
||||
|
||||
res.setHeader('Content-Type', 'text/cache-manifest');
|
||||
res.setHeader('Content-Length', body.length);
|
||||
return res.end(body);
|
||||
});
|
||||
|
||||
var sizeCheck = function () {
|
||||
var totalSize = 0;
|
||||
_.each(WebApp.clientPrograms[WebApp.defaultArch].manifest, function (resource) {
|
||||
const sizeCheck = () => {
|
||||
let totalSize = 0;
|
||||
WebApp.clientPrograms[WebApp.defaultArch].manifest.forEach(resource => {
|
||||
if (resource.where === 'client' &&
|
||||
! RoutePolicy.classify(resource.url) &&
|
||||
! isDynamic(resource)) {
|
||||
@@ -181,7 +171,7 @@ var sizeCheck = function () {
|
||||
Meteor._debug(
|
||||
"** You are using the appcache package but the total size of the\n" +
|
||||
"** cached resources is " +
|
||||
(totalSize / 1024 / 1024).toFixed(1) + "MB.\n" +
|
||||
`${(totalSize / 1024 / 1024).toFixed(1)}MB.\n` +
|
||||
"**\n" +
|
||||
"** This is over the recommended maximum of 5 MB and may break your\n" +
|
||||
"** app in some browsers! See http://docs.meteor.com/#appcache\n" +
|
||||
@@ -195,7 +185,4 @@ var sizeCheck = function () {
|
||||
// want cached. Otherwise, the size check warning will still print even
|
||||
// if the user excludes their large files with
|
||||
// `Meteor.AppCache.config({onlineOnly: files})`.
|
||||
Meteor.startup(function () {
|
||||
if (! _disableSizeCheck)
|
||||
sizeCheck();
|
||||
});
|
||||
Meteor.startup(() => ! _disableSizeCheck ? sizeCheck() : null);
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
var manifestUrl = '/app.manifest';
|
||||
const manifestUrl = '/app.manifest';
|
||||
|
||||
var appcacheTest = function (name, cb) {
|
||||
Tinytest.addAsync('appcache - ' + name, function (test, next) {
|
||||
HTTP.get(manifestUrl, function (err, res) {
|
||||
if (err) {
|
||||
test.fail(err);
|
||||
} else {
|
||||
cb(test, res);
|
||||
}
|
||||
const appcacheTest = (name, cb) => {
|
||||
Tinytest.addAsync(`appcache - ${name}`, (test, next) => {
|
||||
HTTP.get(manifestUrl, (err, res) => {
|
||||
err ? test.fail(err) : cb(test, res);
|
||||
next();
|
||||
});
|
||||
});
|
||||
@@ -15,31 +11,34 @@ var appcacheTest = function (name, cb) {
|
||||
|
||||
|
||||
// Verify that the code status of the HTTP response is "OK"
|
||||
appcacheTest('presence', function (test, manifest) {
|
||||
test.equal(manifest.statusCode, 200, 'manifest not served');
|
||||
});
|
||||
appcacheTest('presence', (test, manifest) =>
|
||||
test.equal(manifest.statusCode, 200, 'manifest not served'));
|
||||
|
||||
|
||||
// Verify the content-type HTTP header
|
||||
appcacheTest('content type', function (test, manifest) {
|
||||
test.equal(manifest.headers['content-type'], 'text/cache-manifest');
|
||||
});
|
||||
appcacheTest('content type', (test, manifest) =>
|
||||
test.equal(manifest.headers['content-type'], 'text/cache-manifest'));
|
||||
|
||||
|
||||
// Verify that each section header is only set once.
|
||||
appcacheTest('sections uniqueness', function (test, manifest) {
|
||||
var content = manifest.content;
|
||||
var mandatorySectionHeaders = ['CACHE:', 'NETWORK:', 'FALLBACK:'];
|
||||
var optionalSectionHeaders = ['SETTINGS'];
|
||||
_.each(_.union(mandatorySectionHeaders, optionalSectionHeaders),
|
||||
function (sectionHeader) {
|
||||
var globalSearch = new RegExp(sectionHeader, "g");
|
||||
var matches = content.match(globalSearch) || [];
|
||||
test.isTrue(matches.length <= 1, sectionHeader + ' is set twice');
|
||||
if (_.contains(mandatorySectionHeaders, sectionHeader)) {
|
||||
test.isTrue(matches.length == 1, sectionHeader + ' is not set');
|
||||
}
|
||||
});
|
||||
appcacheTest('sections uniqueness', (test, manifest) => {
|
||||
const { content } = manifest;
|
||||
const mandatorySectionHeaders = ['CACHE:', 'NETWORK:', 'FALLBACK:'];
|
||||
const optionalSectionHeaders = ['SETTINGS'];
|
||||
const allSectionHeaders = [
|
||||
...mandatorySectionHeaders,
|
||||
...optionalSectionHeaders.filter(
|
||||
header => !mandatorySectionHeaders.includes(header)
|
||||
),
|
||||
];
|
||||
allSectionHeaders.forEach(sectionHeader => {
|
||||
const globalSearch = new RegExp(sectionHeader, "g");
|
||||
const matches = content.match(globalSearch) || [];
|
||||
test.isTrue(matches.length <= 1, `${sectionHeader} is set twice`);
|
||||
if (mandatorySectionHeaders.includes(sectionHeader)) {
|
||||
test.isTrue(matches.length == 1, `${sectionHeader} is not set`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -47,25 +46,24 @@ appcacheTest('sections uniqueness', function (test, manifest) {
|
||||
// regular expressions. Regular expressions matches malformed URIs but that's
|
||||
// not what we're trying to catch here (the user is free to add its own content
|
||||
// in the manifest -- even malformed).
|
||||
appcacheTest('sections validity', function (test, manifest) {
|
||||
var lines = manifest.content.split('\n');
|
||||
var i = 0;
|
||||
var currentRegex = null, line = null;
|
||||
appcacheTest('sections validity', (test, manifest) => {
|
||||
const lines = manifest.content.split('\n');
|
||||
let i = 0;
|
||||
let currentRegex = null;
|
||||
let line = null;
|
||||
|
||||
var nextLine = function () {
|
||||
return lines[i++];
|
||||
};
|
||||
const nextLine = () => lines[i++];
|
||||
|
||||
var eof = function () {
|
||||
return i >= lines.length;
|
||||
};
|
||||
const eof = () => i >= lines.length;
|
||||
|
||||
var nextLineMatches = function (expected, n) {
|
||||
const nextLineMatches = (expected, n) => {
|
||||
n = n || 1;
|
||||
_.times(n, function () {
|
||||
var testFunc = _.isRegExp(expected) ? 'matches' : 'equal';
|
||||
for(let j = 0; j < n; j++) {
|
||||
const testFunc = toString.call(expected) === '[object RegExp]' ?
|
||||
'matches' :
|
||||
'equal';
|
||||
test[testFunc](nextLine(), expected);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Verify header validity
|
||||
@@ -96,7 +94,7 @@ appcacheTest('sections validity', function (test, manifest) {
|
||||
|
||||
// Outside sections, only blanks lines and comments are valid
|
||||
else if (currentRegex === null)
|
||||
test.fail('Invalid line ' + i + ': ' + line);
|
||||
test.fail(`Invalid line ${i}: ${line}`);
|
||||
|
||||
// Inside a section, a star is a valid expression
|
||||
else if (line === '*')
|
||||
@@ -105,7 +103,7 @@ appcacheTest('sections validity', function (test, manifest) {
|
||||
// If it is not a blank line, not a comment, and not a header it must
|
||||
// match the current section format
|
||||
else
|
||||
test.matches(line, currentRegex, 'line ' + i);
|
||||
test.matches(line, currentRegex, `line ${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,30 +112,30 @@ appcacheTest('sections validity', function (test, manifest) {
|
||||
// are present in the network section of the manifest. The `appcache` package
|
||||
// also automatically add the manifest (`app.manifest`) add the star symbol to
|
||||
// this list and therefore we also check the presence of these two elements.
|
||||
appcacheTest('network section content', function (test, manifest) {
|
||||
var shouldBePresentInNetworkSection = [
|
||||
appcacheTest('network section content', (test, manifest) => {
|
||||
const shouldBePresentInNetworkSection = [
|
||||
"/app.manifest",
|
||||
"/online/",
|
||||
"/bigimage.jpg",
|
||||
"/largedata.json",
|
||||
"*"
|
||||
];
|
||||
var lines = manifest.content.split('\n');
|
||||
var startNetworkSection = lines.indexOf('NETWORK:');
|
||||
const lines = manifest.content.split('\n');
|
||||
const startNetworkSection = lines.indexOf('NETWORK:');
|
||||
|
||||
// We search the end of the 'NETWORK:' section by looking at the beginning
|
||||
// of any potential other section. By default we set this value to
|
||||
// `lines.length - 1` which is the index of the last line.
|
||||
var otherSections = ['CACHE:', 'FALLBACK:', 'SETTINGS'];
|
||||
var endNetworkSection = _.reduce(otherSections, function (min, sectionName) {
|
||||
var position = lines.indexOf(sectionName);
|
||||
const otherSections = ['CACHE:', 'FALLBACK:', 'SETTINGS'];
|
||||
const endNetworkSection = otherSections.reduce((min, sectionName) => {
|
||||
const position = lines.indexOf(sectionName);
|
||||
return position > startNetworkSection && position < min ? position : min;
|
||||
}, lines.length - 1);
|
||||
|
||||
// We remove the first line because it's the 'NETWORK:' header line.
|
||||
var networkLines = lines.slice(startNetworkSection + 1, endNetworkSection);
|
||||
const networkLines = lines.slice(startNetworkSection + 1, endNetworkSection);
|
||||
|
||||
_.each(shouldBePresentInNetworkSection, function (item) {
|
||||
test.include(networkLines, item);
|
||||
});
|
||||
shouldBePresentInNetworkSection.forEach(
|
||||
item => test.include(networkLines, item)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
// real hook. We point to a non-existent file to clear the appcache in
|
||||
// case there was previously a site running with appcache on
|
||||
// localhost:3000.
|
||||
WebApp.addHtmlAttributeHook(function (request) {
|
||||
return { manifest: "/no-such-file" };
|
||||
});
|
||||
WebApp.addHtmlAttributeHook(request => ({ manifest: "/no-such-file" }));
|
||||
|
||||
|
||||
// Let's add some resources in the 'NETWORK' section
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
Package.describe({
|
||||
summary: "Enable the application cache in the browser",
|
||||
version: "1.1.1"
|
||||
version: "1.1.2",
|
||||
});
|
||||
|
||||
Package.onUse(function (api) {
|
||||
api.use('webapp', 'server');
|
||||
Package.onUse(api => {
|
||||
api.use('ecmascript', ['client', 'server']);
|
||||
api.use(['webapp', 'routepolicy'], 'server');
|
||||
api.use('reload', 'client');
|
||||
api.use('routepolicy', 'server');
|
||||
api.use('underscore', 'server');
|
||||
api.use('autoupdate', 'server', {weak: true});
|
||||
api.addFiles('appcache-client.js', 'client');
|
||||
api.addFiles('appcache-server.js', 'server');
|
||||
api.mainModule('appcache-client.js', 'client');
|
||||
api.mainModule('appcache-server.js', 'server');
|
||||
});
|
||||
|
||||
Package.onTest(function (api) {
|
||||
Package.onTest(api => {
|
||||
api.use('tinytest');
|
||||
api.use('appcache');
|
||||
api.use('http', 'client');
|
||||
api.use('underscore', 'client');
|
||||
api.use('webapp', 'server');
|
||||
api.addFiles('appcache_tests-server.js', 'server');
|
||||
api.addFiles('appcache_tests-client.js', 'client');
|
||||
|
||||
Reference in New Issue
Block a user