Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 18 additions & 52 deletions build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,51 +10,10 @@ const { builtinScriptlets: scriptlets } = await import(
`https://raw.githubusercontent.com/gorhill/uBlock/${tagName}/src/js/resources/scriptlets.js`
);

const index = new Map();
for (const scriptlet of scriptlets) {
index.set(scriptlet.name, scriptlet);
for (const name of scriptlet.aliases || []) {
index.set(name, scriptlet);
}
}

// Each function once, by name, so compose() and run() can declare them in one shared scope.
const functions = new Map();
const entryFunctions = [];

const entries = scriptlets
.filter(scriptlet => scriptlet.name.endsWith('.js'))
.map((scriptlet) => {
const allDependencies = new Set();

const addDeps = (aScriptlet) => {
for (const dep of aScriptlet.dependencies || []) {
allDependencies.add(dep);
const bScriptlet = index.get(dep);
addDeps(bScriptlet);
}
};

addDeps(scriptlet);

const deps = [...allDependencies].reverse().map((dep) => index.get(dep).fn);

for (const fn of [...deps, scriptlet.fn]) {
functions.set(fn.name, fn.toString());
}
entryFunctions.push(scriptlet.fn.name);

return `
scriptlets['${scriptlet.name}'] = {
aliases: ${JSON.stringify(scriptlet.aliases || [])},
${scriptlet.world ? `world: '${scriptlet.world}',` : '' }
requiresTrust: ${scriptlet.requiresTrust || false},
fn: '${scriptlet.fn.name}',
dependencies: ${JSON.stringify(deps.map((dep) => dep.name))},
};
`;
})
.join('\n');
// Every function once, by name; run() declares them all in one scope, so the scriptlets of an
// injection share one safeSelf() cache without touching globalThis, as in uBO.
const functions = new Map(scriptlets.map((scriptlet) => [scriptlet.fn.name, scriptlet.fn.toString()]));
const entries = scriptlets.filter((scriptlet) => scriptlet.name.endsWith('.js'));

console.log(`
/*******************************************************************************
Expand All @@ -78,21 +37,28 @@ console.log(`
Home: https://github.com/gorhill/uBlock

*/
const functions = {};
${[...functions].map(([name, source]) => `functions['${name}'] = ${source};`).join('\n')}

const scriptlets = {};
${entries}
${entries
.map(
(scriptlet) => `
scriptlets['${scriptlet.name}'] = {
aliases: ${JSON.stringify(scriptlet.aliases || [])},
${scriptlet.world ? `world: '${scriptlet.world}',` : ''}
requiresTrust: ${scriptlet.requiresTrust || false},
fn: '${scriptlet.fn.name}',
};
`,
)
.join('\n')}

// For chrome.scripting.executeScript(), which takes a function and JSON arguments but no code.
// The calls come last, so the class dependencies, which are not hoisted, are declared by then.
export function run(scriptletGlobals, calls) {
${[...functions.values()].join('\n')}
const table = { ${entryFunctions.join(', ')} };
const table = { ${entries.map((scriptlet) => scriptlet.fn.name).join(', ')} };
for (const [name, ...args] of calls) {
try { table[name](...args); } catch {}
}
}

export { functions };
export default scriptlets;
`);
23 changes: 1 addition & 22 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import SCRIPTLETS, { functions, run } from './ubo.js';
import SCRIPTLETS, { run } from './ubo.js';

const scriptlets = {};

Expand All @@ -9,26 +9,5 @@ for (const [name, scriptlet] of Object.entries(SCRIPTLETS)) {
}
}

// One scope for all calls of an injection, as in uBO: each dependency is declared once, so the
// scriptlets share one safeSelf() cache without touching globalThis. The calls come last, so the
// class dependencies, which are not hoisted, are declared by the time they run.
export function compose(calls, scriptletGlobals = {}) {
const declarations = new Map();
const body = [];
for (const { scriptlet, args } of calls) {
for (const name of [...scriptlet.dependencies, scriptlet.fn]) {
declarations.set(name, functions[name].toString());
}
body.push(`try { ${scriptlet.fn}(...${JSON.stringify(args)}); } catch {}`);
}
return [
'(function () {',
`const scriptletGlobals = ${JSON.stringify(scriptletGlobals)};`,
...declarations.values(),
...body,
'})();',
].join('\n');
}

export { run };
export default scriptlets;
61 changes: 26 additions & 35 deletions test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { test, suite } from "node:test";
import assert from "node:assert";
import vm from "node:vm";
import scriptlets, { compose, run } from "./index.js";
import { functions } from "./ubo.js";
import scriptlets, { run } from "./index.js";

const runSource = run.toString();

test("default export is an object", () => {
assert(typeof scriptlets === "object");
Expand All @@ -12,12 +13,8 @@ test("each scriptlet has basic properties", () => {
for (const [name, scriptlet] of Object.entries(scriptlets)) {
assert(name.length > 0, `${name} - name is too short`);
assert(
functions[scriptlet.fn] instanceof Function,
`${name} - fn does not name a function`
);
assert(
scriptlet.dependencies.every((dep) => functions[dep] instanceof Function),
`${name} - a dependency does not name a function`
runSource.includes(`function ${scriptlet.fn}(`),
`${name} - fn does not name a function of run()`
);
assert(
scriptlet.aliases instanceof Array,
Expand All @@ -33,11 +30,16 @@ suite("uBO", () => {
});
});

suite("safeSelf() cache", () => {
suite("run()", () => {
// Each hook clones the argument with safe.JSON_parse(safe.JSON_stringify(obj))
const scriptlet = scriptlets["trusted-edit-inbound-object.js"];
const HOOKS = 6;
const hookArgs = (i) => ["JSON.stringify", "0", `[?.hook${i}]+={"edited${i}":true}`];
const calls = Array.from({ length: HOOKS }, (_, i) => [
scriptlet.fn,
"JSON.stringify",
"0",
`[?.hook${i}]+={"edited${i}":true}`,
]);

// A fresh realm is the page; counters wrap the native JSON methods before the scriptlets run
function createPage() {
Expand All @@ -54,42 +56,31 @@ suite("safeSelf() cache", () => {
return page;
}

function assertLinear(page) {
test("declares safeSelf() once", () => {
assert.strictEqual(runSource.match(/^function safeSelf\(/gm).length, 1);
});

test("shares one safeSelf() cache, so stacked hooks stay linear", () => {
const page = createPage();
// Same call as chrome.scripting.executeScript() makes: the function source with JSON arguments
vm.runInContext(`(${runSource})(...${JSON.stringify([{}, calls])});`, page);

const result = vm.runInContext(
"calls.parse = 0; calls.stringify = 0; JSON.stringify({ hook0: true, hook5: true });",
page
);
assert.deepStrictEqual(JSON.parse(result), { hook0: true, hook5: true, edited0: true, edited5: true });
// One call from the page plus one clone per hook, not 2^n
assert.deepStrictEqual({ ...page.calls }, { parse: HOOKS, stringify: HOOKS + 1 });
}
});

test("leaves no global behind", () => {
const page = createPage();
vm.runInContext(`(${runSource})(...${JSON.stringify([{}, calls])});`, page);

function assertNoGlobal(page) {
const keys = vm
.runInContext("Object.getOwnPropertySymbols(globalThis)", page)
.filter((key) => key.description.startsWith("safeSelf."));
assert.strictEqual(keys.length, 0);
}

test("compose() shares it in one scope, without a global", () => {
const page = createPage();
const code = compose(
Array.from({ length: HOOKS }, (_, i) => ({ scriptlet, args: hookArgs(i) }))
);
assert.strictEqual(code.match(/^function safeSelf\(/gm).length, 1);
vm.runInContext(code, page);

assertLinear(page);
assertNoGlobal(page);
});

test("run() shares it in one scope, without a global", () => {
const page = createPage();
// Same call as chrome.scripting.executeScript() makes: the function source with JSON arguments
const calls = Array.from({ length: HOOKS }, (_, i) => [scriptlet.fn, ...hookArgs(i)]);
vm.runInContext(`(${run})(...${JSON.stringify([{}, calls])});`, page);

assertLinear(page);
assertNoGlobal(page);
});
});
Loading
Loading