Refactor scriptlets injection code

Builtin scriptlets are no longer parsed as text-based resources,
they are imported as JS functions, and `toString()` is used to
obtain text-based representation of a scriptlet.

Scriptlet parameters are now passed as function call arguments
rather than by replacing text-based occurrences of `{{i}}`. The
arguments are always string values (see below for exception).

Support for argument as Object has been added. This opens the
door to have scriptlets using named arguments rather than
positional arguments, and hence easier to extend functionality
of existing scriptlets. Example:

    example.com##+js(scriplet, { "prop": "adblock", "value": false, "log": true })

Compatibility with user-provided scriptlets has been preserved.

User-provided scriptlets can benefit some of the changes:

Use the form `function(..){..}` instead of `(function(..){..})();`
in order to received scriptlet arguments as part of function call
-- instead of using `{{i}}`.

If using the form `function(..){..}`, you can choose to receive
an Object as argument -- just be sure that your scriptlet's
parameter is valid JSON notation.
This commit is contained in:
Raymond Hill 2023-03-24 14:05:18 -04:00
parent 56b8201196
commit 18a84d2819
No known key found for this signature in database
GPG Key ID: 25E1490B761470C2
5 changed files with 531 additions and 315 deletions

View File

@ -2,7 +2,7 @@
"browser": true,
"devel": true,
"eqeqeq": true,
"esversion": 9,
"esversion": 11,
"globals": {
"chrome": false, // global variable in Chromium, Chrome, Opera
"globalThis": false,

File diff suppressed because it is too large Load Diff

View File

@ -325,13 +325,20 @@ RedirectEngine.prototype.loadBuiltinResources = function(fetcher) {
this.aliases = new Map();
const fetches = [
fetcher(
'/assets/resources/scriptlets.js'
).then(result => {
const content = result.content;
if ( typeof content !== 'string' ) { return; }
if ( content.length === 0 ) { return; }
this.resourcesFromString(content);
import('/assets/resources/scriptlets.js').then(module => {
for ( const scriptlet of module.builtinScriptlets ) {
const { name, aliases, fn } = scriptlet;
const entry = RedirectEntry.fromContent(
mimeFromName(name),
fn.toString()
);
this.resources.set(name, entry);
if ( Array.isArray(aliases) === false ) { continue; }
for ( const alias of aliases ) {
this.aliases.set(alias, name);
}
}
this.modifyTime = Date.now();
}),
];
@ -426,7 +433,7 @@ RedirectEngine.prototype.getResourceDetails = function() {
/******************************************************************************/
const RESOURCES_SELFIE_VERSION = 6;
const RESOURCES_SELFIE_VERSION = 7;
const RESOURCES_SELFIE_NAME = 'compiled/redirectEngine/resources';
RedirectEngine.prototype.selfieFromResources = function(storage) {

View File

@ -149,7 +149,7 @@ const lookupScriptlet = function(rawToken, reng, toInject) {
let content = scriptletCache.lookup(rawToken);
if ( content === undefined ) {
const pos = rawToken.indexOf(',');
let token, args;
let token, args = '';
if ( pos === -1 ) {
token = rawToken;
} else {
@ -165,10 +165,7 @@ const lookupScriptlet = function(rawToken, reng, toInject) {
}
content = reng.resourceContentFromName(token, 'text/javascript');
if ( !content ) { return; }
if ( args ) {
content = patchScriptlet(content, args);
if ( !content ) { return; }
}
content = patchScriptlet(content, args);
content =
'try {\n' +
content + '\n' +
@ -180,26 +177,36 @@ const lookupScriptlet = function(rawToken, reng, toInject) {
// Fill-in scriptlet argument placeholders.
const patchScriptlet = function(content, args) {
let s = args;
let len = s.length;
let beg = 0, pos = 0;
let i = 1;
while ( beg < len ) {
pos = s.indexOf(',', pos);
// Escaped comma? If so, skip.
if ( pos > 0 && s.charCodeAt(pos - 1) === 0x5C /* '\\' */ ) {
s = s.slice(0, pos - 1) + s.slice(pos);
len -= 1;
continue;
}
if ( pos === -1 ) { pos = len; }
content = content.replace(
`{{${i}}}`,
s.slice(beg, pos).trim().replace(reEscapeScriptArg, '\\$&')
);
beg = pos = pos + 1;
i++;
if ( content.startsWith('function') ) {
content = `(${content})({{args}});`;
}
if ( args.startsWith('{') && args.endsWith('}') ) {
return content.replace('{{args}}', args);
}
const arglist = [];
if ( args !== '' ) {
let s = args;
let len = s.length;
let beg = 0, pos = 0;
let i = 1;
while ( beg < len ) {
pos = s.indexOf(',', pos);
// Escaped comma? If so, skip.
if ( pos > 0 && s.charCodeAt(pos - 1) === 0x5C /* '\\' */ ) {
s = s.slice(0, pos - 1) + s.slice(pos);
len -= 1;
continue;
}
if ( pos === -1 ) { pos = len; }
arglist.push(s.slice(beg, pos).trim().replace(reEscapeScriptArg, '\\$&'));
beg = pos = pos + 1;
i++;
}
}
for ( let i = 0; i < arglist.length; i++ ) {
content = content.replace(`{{${i+1}}}`, arglist[i]);
}
content = content.replace('{{args}}', arglist.map(a => `'${a}'`).join(', '));
return content;
};

View File

@ -118,6 +118,7 @@ export const NODE_TYPE_EXT_PATTERN_HTML = iota++;
export const NODE_TYPE_EXT_PATTERN_RESPONSEHEADER = iota++;
export const NODE_TYPE_EXT_PATTERN_SCRIPTLET = iota++;
export const NODE_TYPE_EXT_PATTERN_SCRIPTLET_TOKEN = iota++;
export const NODE_TYPE_EXT_PATTERN_SCRIPTLET_ARGS = iota++;
export const NODE_TYPE_EXT_PATTERN_SCRIPTLET_ARG = iota++;
export const NODE_TYPE_NET_RAW = iota++;
export const NODE_TYPE_NET_EXCEPTION = iota++;
@ -276,6 +277,7 @@ export const nodeNameFromNodeType = new Map([
[ NODE_TYPE_EXT_PATTERN_RESPONSEHEADER, 'extPatternResponseheader' ],
[ NODE_TYPE_EXT_PATTERN_SCRIPTLET, 'extPatternScriptlet' ],
[ NODE_TYPE_EXT_PATTERN_SCRIPTLET_TOKEN, 'extPatternScriptletToken' ],
[ NODE_TYPE_EXT_PATTERN_SCRIPTLET_ARGS, 'extPatternScriptletArgs' ],
[ NODE_TYPE_EXT_PATTERN_SCRIPTLET_ARG, 'extPatternScriptletArg' ],
[ NODE_TYPE_NET_RAW, 'netRaw' ],
[ NODE_TYPE_NET_EXCEPTION, 'netException' ],
@ -748,6 +750,7 @@ export class AstFilterParser {
this.reHostnamePatternPart = /^[^\x00-\x24\x26-\x29\x2B\x2C\x2F\x3A-\x40\x5B-\x5E\x60\x7B-\x7F]+/;
this.reHostnameLabel = /[^.]+/g;
this.reResponseheaderPattern = /^\^responseheader\(.*\)$/;
this.rePatternScriptletJsonArgs = /^\{.*\}$/;
// TODO: mind maxTokenLength
this.reGoodRegexToken = /[^\x01%0-9A-Za-z][%0-9A-Za-z]{7,}|[^\x01%0-9A-Za-z][%0-9A-Za-z]{1,6}[^\x01%0-9A-Za-z]/;
this.reBadCSP = /(?:=|;)\s*report-(?:to|uri)\b/;
@ -2118,53 +2121,59 @@ export class AstFilterParser {
let prev = head, next = 0;
const s = this.getNodeString(parent);
const argsEnd = s.length;
let argCount = 0;
let argBeg = 0, argEnd = 0, argBodyBeg = 0, argBodyEnd = 0;
let rawArg = '';
while ( argBeg < argsEnd ) {
argEnd = this.indexOfNextScriptletArgSeparator(s, argBeg);
rawArg = s.slice(argBeg, argEnd);
argBodyBeg = argBeg + this.leftWhitespaceCount(rawArg);
if ( argBodyBeg !== argBodyEnd ) {
next = this.allocTypedNode(
NODE_TYPE_EXT_DECORATION,
parentBeg + argBodyEnd,
parentBeg + argBodyBeg
);
prev = this.linkRight(prev, next);
}
argBodyEnd = argEnd - this.rightWhitespaceCount(rawArg);
if ( argCount === 0 ) {
rawArg = s.slice(argBodyBeg, argBodyEnd);
const tokenEnd = rawArg.endsWith('.js')
? argBodyEnd - 3
: argBodyEnd;
next = this.allocTypedNode(
NODE_TYPE_EXT_PATTERN_SCRIPTLET_TOKEN,
parentBeg + argBodyBeg,
parentBeg + tokenEnd
);
prev = this.linkRight(prev, next);
if ( tokenEnd !== argBodyEnd ) {
next = this.allocTypedNode(
NODE_TYPE_IGNORE,
parentBeg + argBodyEnd - 3,
parentBeg + argBodyEnd
);
prev = this.linkRight(prev, next);
}
} else {
next = this.allocTypedNode(
NODE_TYPE_EXT_PATTERN_SCRIPTLET_ARG,
parentBeg + argBodyBeg,
parentBeg + argBodyEnd
);
prev = this.linkRight(prev, next);
}
argBeg = argEnd + 1;
argCount += 1;
// token
let argEnd = this.indexOfNextScriptletArgSeparator(s, 0);
let rawArg = s.slice(0, argEnd);
let argBodyBeg = this.leftWhitespaceCount(rawArg);
if ( argBodyBeg !== 0 ) {
next = this.allocTypedNode(
NODE_TYPE_EXT_DECORATION,
parentBeg,
parentBeg + argBodyBeg
);
prev = this.linkRight(prev, next);
}
if ( argsEnd !== argBodyEnd ) {
let argBodyEnd = argEnd - this.rightWhitespaceCount(rawArg);
rawArg = s.slice(argBodyBeg, argBodyEnd);
const tokenEnd = rawArg.endsWith('.js')
? argBodyEnd - 3
: argBodyEnd;
next = this.allocTypedNode(
NODE_TYPE_EXT_PATTERN_SCRIPTLET_TOKEN,
parentBeg + argBodyBeg,
parentBeg + tokenEnd
);
prev = this.linkRight(prev, next);
// ignore pointless `.js`
if ( tokenEnd !== argBodyEnd ) {
next = this.allocTypedNode(
NODE_TYPE_IGNORE,
parentBeg + argBodyEnd - 3,
parentBeg + argBodyEnd
);
prev = this.linkRight(prev, next);
}
// all args
argBodyBeg = argEnd + 1;
const rawArgs = s.slice(argBodyBeg, argsEnd);
argBodyBeg += this.leftWhitespaceCount(rawArgs);
next = this.allocTypedNode(
NODE_TYPE_EXT_DECORATION,
parentBeg + argBodyEnd,
parentBeg + argBodyBeg
);
prev = this.linkRight(prev, next);
argBodyEnd = argsEnd - this.rightWhitespaceCount(rawArgs);
if ( argBodyBeg !== argBodyEnd ) {
next = this.allocTypedNode(
NODE_TYPE_EXT_PATTERN_SCRIPTLET_ARGS,
parentBeg + argBodyBeg,
parentBeg + argBodyEnd
);
this.linkDown(next, this.parseExtPatternScriptletArglist(next));
prev = this.linkRight(prev, next);
}
if ( argBodyEnd !== argsEnd ) {
next = this.allocTypedNode(
NODE_TYPE_EXT_DECORATION,
parentBeg + argBodyEnd,
@ -2175,6 +2184,57 @@ export class AstFilterParser {
return this.throwHeadNode(head);
}
parseExtPatternScriptletArglist(parent) {
const parentBeg = this.nodes[parent+NODE_BEG_INDEX];
const parentEnd = this.nodes[parent+NODE_END_INDEX];
if ( parentEnd === parentBeg ) { return 0; }
const s = this.getNodeString(parent);
let next = 0, prev = 0;
// json-based arg?
const match = this.rePatternScriptletJsonArgs.exec(s);
if ( match !== null ) {
next = this.allocTypedNode(
NODE_TYPE_EXT_PATTERN_SCRIPTLET_ARG,
parentBeg,
parentEnd
);
try {
void JSON.parse(s);
} catch(ex) {
this.addNodeFlags(next, NODE_FLAG_ERROR);
this.addFlags(AST_FLAG_HAS_ERROR);
}
return next;
}
// positional args
const argsEnd = s.length;
let argBodyBeg = 0, argBodyEnd = 0, argEnd = 0;
let t = '';
while ( argBodyBeg < argsEnd ) {
argEnd = this.indexOfNextScriptletArgSeparator(s, argBodyBeg);
t = s.slice(argBodyBeg, argEnd);
argBodyEnd = argEnd - this.rightWhitespaceCount(t);
next = this.allocTypedNode(
NODE_TYPE_EXT_PATTERN_SCRIPTLET_ARG,
parentBeg + argBodyBeg,
parentBeg + argBodyEnd
);
prev = this.linkRight(prev, next);
if ( argEnd === argsEnd ) { break; }
t = s.slice(argEnd + 1);
argBodyBeg = argEnd + 1 + this.leftWhitespaceCount(t);
if ( argBodyEnd !== argBodyBeg ) {
next = this.allocTypedNode(
NODE_TYPE_EXT_DECORATION,
parentBeg + argBodyEnd,
parentBeg + argBodyBeg
);
prev = this.linkRight(prev, next);
}
}
return next;
}
indexOfNextScriptletArgSeparator(pattern, beg = 0) {
const patternEnd = pattern.length;
if ( beg >= patternEnd ) { return patternEnd; }