1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 12:55:55 +00:00
This is a very significant rewrite of a fragile system as a result of changes to Twitch's webpack settings, so there may be bugs or performance regressions. Thank you for your patience while we solve these issues.

* Fixed: Bug where the viewer count would sometimes be read incorrectly when more than 1,000.
* Fixed: Bug where some parts of the Twitch website would fail to load when FFZ is already loaded.
* Fixed: The channel carousel on Twitch's home page not being recolored correctly by themes.
This commit is contained in:
SirStendec 2025-03-04 17:35:08 -05:00
parent 1169d8fe64
commit 850c4d53fd
9 changed files with 851 additions and 694 deletions

View file

@ -1,7 +1,7 @@
{
"name": "frankerfacez",
"author": "Dan Salvato LLC",
"version": "4.76.5",
"version": "4.77.0",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"private": true,
"license": "Apache-2.0",

View file

@ -476,6 +476,9 @@ export default class Chat extends Module {
ui: {
path: 'Chat > Appearance >> Hidden Token Types @{"description":"This filter allows you to prevent specific content token types from appearing chat messages, such as hiding all cheers or emotes."}',
component: 'blocked-types',
getExtraTerms: () => Object
.keys(this.tokenizers)
.filter(key => ! UNBLOCKABLE_TOKENS.includes(key) && this.tokenizers[key]?.render),
data: () => Object
.keys(this.tokenizers)
.filter(key => ! UNBLOCKABLE_TOKENS.includes(key) && this.tokenizers[key]?.render)

View file

@ -524,7 +524,7 @@ export default class Channel extends Module {
// is actually a static string.
if ( typeof c === 'string' && /^[0-9,.]+$/.test(c) ) {
try {
const val = parseInt(c.replace(/\.,/, ''), 10);
const val = parseInt(c.replace(/[\.,]+/, ''), 10);
if ( ! isNaN(val) && isFinite(val) && val > 0 )
return val;
} catch(err) { /* no-op */ }

View file

@ -1136,12 +1136,12 @@ export default class ChatHook extends Module {
}
async grabTypes() {
grabTypes() {
if ( this.types_loaded )
return;
const ct = await this.web_munch.findModule('chat-types'),
callouts = await this.web_munch.findModule('callout-types');
const ct = this.web_munch.getModule('chat-types'),
callouts = this.web_munch.getModule('callout-types');
this.callout_types = callouts || CALLOUT_TYPES;
this.automod_types = ct?.automod || AUTOMOD_TYPES;
@ -1199,8 +1199,8 @@ export default class ChatHook extends Module {
onEnable() {
this.on('site.web_munch:loaded', this.grabTypes);
this.on('site.web_munch:loaded', this.defineClasses);
this.on('site.web_munch:new-ready', this.grabTypes);
this.on('site.web_munch:new-ready', this.defineClasses);
this.grabTypes();
this.defineClasses();

View file

@ -437,6 +437,7 @@ export default class Directory extends Module {
ui: {
path: 'Directory > Channels >> Block by Flag',
component: 'blocked-types',
getExtraTerms: () => [...CONTENT_FLAGS],
data: () => [...CONTENT_FLAGS]
.sort()
},
@ -460,6 +461,7 @@ export default class Directory extends Module {
ui: {
path: 'Directory > Channels >> Hide Thumbnails by Flag',
component: 'blocked-types',
getExtraTerms: () => [...CONTENT_FLAGS],
data: () => [...CONTENT_FLAGS]
.sort()
},

View file

@ -22,6 +22,7 @@
.qa-vod-chat,
.extensions-popover-view-layout,
.modview-dock-widget__preview__body > div,
.carousel-metadata,
.video-card {
background-color: var(--color-background-base) !important;
}
@ -96,6 +97,15 @@
border-color: var(--color-border-brand) !important;
}
.carousel-metadata--fadeout {
background: linear-gradient(
180deg,
transparent 0,
var(--color-background-base) 80%,
var(--color-background-base) 100%
);
}
.creator-chat-stats-carousel__right-arrow {
background: linear-gradient(270deg, var(--color-background-body) 60%, transparent) !important;
}

View file

@ -1,686 +0,0 @@
'use strict';
// ============================================================================
// WebMunch
// It consumes webpack.
// ============================================================================
import Module from 'utilities/module';
import {has, generateUUID} from 'utilities/object';
import { DEBUG } from 'utilities/constants';
const Requires = Symbol('FFZRequires');
const regex_cache = {};
function getRequireRegex(name) {
if ( ! regex_cache[name] )
regex_cache[name] = new RegExp(`\\b(?<!\\.)${name}\\(([0-9e_+]+)\\)`, 'g');
return regex_cache[name];
}
const NAMES = [
'webpackJsonp',
'webpackChunktwitch_twilight',
'webpackChunktwitch_sunlight',
'webpackJsonp_N_E'
];
const HARD_MODULES = [
[0, 'vendor'],
[1, 'core']
];
let last_muncher = 0;
export default class WebMunch extends Module {
constructor(...args) {
super(...args);
this._id = `_ffz$${last_muncher++}`;
this._rid = 0;
this._original_loader = null;
this._known_rules = {};
this._require = null;
this._chunk_names = {};
this._mod_cache = {};
this._checked_module = {};
this._required_ids = new Set;
this._known_ids = new Set;
this.Requires = Requires;
this.v4 = null;
this.hookLoader();
this.hookRequire();
}
// ========================================================================
// Loaded Modules
// ========================================================================
waitForLoader() {
if ( this._original_loader )
return Promise.resolve();
const waiters = this._load_waiters = this._load_waiters || [];
return new Promise((s,f) => waiters.push([s,f]));
}
_resolveLoadWait(errored) {
const waiters = this._load_waiters;
this._load_waiters = null;
if ( waiters )
for(const pair of waiters)
pair[errored ? 1 : 0]();
}
hookLoader(attempts = 0) {
if ( this._original_loader )
return this.log.warn('Attempted to call hookLoader twice.');
let name;
for(const n of NAMES)
if ( window[n] ) {
name = n;
break;
}
if ( ! name ) {
if ( attempts > 240 ) {
this.log.error("Unable to find webpack's loader after one minute.");
try {
const possibilities = [];
for(const key of Object.keys(window))
if ( has(window, key) && typeof key === 'string' && /webpack/i.test(key) && ! /ffz/i.test(key) )
possibilities.push(key);
if ( possibilities.length )
this.log.info('Possible Matches: ', possibilities.join(', '));
else
this.log.info('No possible matches found.');
} catch(err) { /* no-op */ }
this._resolveLoadWait(true);
return;
}
return setTimeout(this.hookLoader.bind(this, attempts + 1), 250);
}
const thing = window[name];
if ( typeof thing === 'function' ) {
// v3
this.v4 = false;
this._original_loader = thing;
try {
window[name] = this.webpackJsonpv3.bind(this);
} catch(err) {
this.log.warn('Unable to wrap webpackJsonp due to write protection.');
this._resolveLoadWait(true);
return;
}
} else if ( Array.isArray(thing) ) {
// v4
this.v4 = true;
this._original_store = thing;
this._original_loader = thing.push;
// Wrap all existing modules in case any of them haven't been required yet.
for(const chunk of thing)
if ( chunk && chunk[1] )
this.processModulesV4(chunk[1], true);
try {
thing.push = this.webpackJsonpv4.bind(this);
} catch(err) {
this.log.warn('Unable to wrap webpackJsonp (v4) due to write protection.');
this._resolveLoadWait(true);
return;
}
} else {
this.log.error('webpackJsonp is of an unknown value. Unable to wrap.');
this._resolveLoadWait(true);
return;
}
this._resolveLoadWait();
this.log.info(`Found and wrapped webpack's loader after ${(attempts||0)*250}ms.`);
}
webpackJsonpv3(chunk_ids, modules) {
const names = chunk_ids.map(x => this._chunk_names[x] || x).join(', ');
this.log.verbose(`Twitch Chunk Loaded: ${chunk_ids} (${names})`);
this.log.verbose(`Modules: ${Object.keys(modules)}`);
const res = this._original_loader.apply(window, arguments); // eslint-disable-line prefer-rest-params
this.emit(':loaded', chunk_ids, names, modules);
return res;
}
_resolveRequire(require) {
if ( this._require )
return;
this._require = require;
if ( this._resolve_require ) {
for(const fn of this._resolve_require)
fn(require);
this._resolve_require = null;
}
}
processModulesV4(modules) {
const t = this;
for(const [mod_id, original_module] of Object.entries(modules)) {
this._known_ids.add(mod_id);
/*modules[mod_id] = function(module, exports, require, ...args) {
if ( ! t._require && typeof require === 'function' ) {
t.log.debug(`require() grabbed from invocation of module ${mod_id}`);
try {
t._resolveRequire(require);
} catch(err) {
t.log.error('An error occurred running require callbacks.', err);
}
}
return original_module.call(this, module, exports, require, ...args);
}
modules[mod_id].original = original_module;*/
}
}
webpackJsonpv4(data) {
const chunk_ids = data[0],
modules = data[1],
names = Array.isArray(chunk_ids) && chunk_ids.map(x => this._chunk_names[x] || x);
this.log.verbose(`Twitch Chunk Loaded: ${chunk_ids} (${names.join(', ')})`);
this.log.verbose(`Modules: ${Object.keys(modules)}`);
if ( modules )
this.processModulesV4(modules, false);
for(const [key,val] of Object.entries(this._checked_module)) {
if (val == true)
this._checked_module[key] = null;
}
//this._checked_module = {};
const res = this._original_loader.apply(this._original_store, arguments); // eslint-disable-line prefer-rest-params
this.emit(':loaded', chunk_ids, names, modules);
return res;
}
// ========================================================================
// Finding Modules
// ========================================================================
known(key, predicate) {
if ( typeof key === 'object' ) {
for(const k in key)
if ( has(key, k) )
this.known(k, key[k]);
return;
}
this._known_rules[key] = predicate;
}
async findModule(key, predicate) {
if ( ! this._require )
await this.getRequire();
return this.getModule(key, predicate);
}
findDeep(chunks, predicate, multi = true) {
if ( chunks && ! Array.isArray(chunks) )
chunks = [chunks];
if ( ! this._require || ! this.v4 || ! this._original_store )
return new Error('We do not have webpack');
const out = [],
names = this._chunk_names;
for(const [cs, modules] of this._original_store) {
if ( chunks ) {
let matched = false;
for(const c of cs) {
if ( chunks.includes(c) || chunks.includes(`${c}`) || (names[c] && chunks.includes(names[c])) ) {
matched = true;
break;
}
}
if ( ! matched )
continue;
}
for(const id of Object.keys(modules)) {
try {
const mod = this._require(id);
for(const key in mod)
if ( mod[key] && predicate(mod[key], mod, key) ) {
this.log.info(`Found in key "${key}" of module "${id}" (${this.chunkNameForModule(id)})`);
if ( ! multi )
return mod;
out.push(mod);
break;
}
} catch(err) {
this.log.warn('Exception while deep scanning webpack.', err);
}
}
}
if ( out.length )
return out;
this.log.info('Unable to find deep scan target.');
return null;
}
getModule(key, predicate) {
if ( typeof key === 'function' ) {
predicate = key;
key = null;
}
if ( key && this._mod_cache[key] )
return this._mod_cache[key];
const require = this._require;
if ( ! require )
return null;
if ( ! predicate )
predicate = this._known_rules[key];
if ( ! predicate )
throw new Error(`no known predicate for locating ${key}`);
if ( require.c )
return this._oldGetModule(key, predicate, require);
if ( require.m )
return this._newGetModule(key, predicate, require);
}
_chunksForModule(id) {
if ( ! this.v4 )
return null;
if ( ! this._original_store )
return null;
const out = new Set;
for(const [chunks, modules] of this._original_store) {
if ( modules[id] ) {
for(const chunk of chunks)
out.add(chunk);
}
}
return [...out];
}
chunkNameForModule(id) {
const chunks = this._chunksForModule(id);
if ( ! chunks )
return null;
for(const chunk of chunks) {
const name = this._chunk_names[chunk];
if ( name )
return name;
}
return null;
}
chunkNamesForModule(id) {
const chunks = this._chunksForModule(id);
if ( ! chunks )
return null;
return chunks.map(id => this._chunk_names[id] || id);
}
_oldGetModule(key, predicate, require) {
if ( ! require || ! require.c )
return null;
let ids;
if ( this._original_store && predicate.chunks && this._chunk_names && Object.keys(this._chunk_names).length ) {
const chunk_pred = typeof predicate.chunks === 'function';
if ( ! chunk_pred && ! Array.isArray(predicate.chunks) )
predicate.chunks = [predicate.chunks];
const chunks = predicate.chunks,
names = this._chunk_names;
ids = [];
for(const [cs, modules] of this._original_store) {
let matched = false;
for(const c of cs) {
if ( chunk_pred ? chunks(names[c], c) : (chunks.includes(c) || chunks.includes(String(c)) || (names[c] && chunks.includes(names[c]))) ) {
matched = true;
break;
}
}
if ( matched )
ids = [...ids, ...Object.keys(modules)];
}
ids = new Set(ids);
} else
ids = Object.keys(require.c);
let checked = 0;
for(const k of ids)
if ( has(require.c, k) ) {
checked++;
const module = require.c[k],
mod = module && module.exports;
if ( mod ) {
const ret = predicate(mod);
if ( ret ) {
this.log.debug(`[Old] Located module "${key}" in module ${k}${DEBUG ? ` (${this.chunkNameForModule(k)})` : ''} after ${checked} tries`);
const out = predicate.use_result ? ret : mod;
if ( key )
this._mod_cache[key] = out;
return out;
}
}
}
this.log.debug(`[Old] Unable to locate module "${key}" despite checking ${checked} modules`);
return null;
}
_newGetModule(key, predicate, require) {
if ( ! require )
return null;
let ids = this._known_ids;
if ( this._original_store && predicate.chunks && this._chunk_names && Object.keys(this._chunk_names).length ) {
const chunk_pred = typeof predicate.chunks === 'function';
if ( ! chunk_pred && ! Array.isArray(predicate.chunks) )
predicate.chunks = [predicate.chunks];
const chunks = predicate.chunks,
names = this._chunk_names;
ids = [];
for(const [cs, modules] of this._original_store) {
let matched = false;
for(const c of cs) {
if ( chunk_pred ? chunks(names[c], c) : (chunks.includes(c) || chunks.includes(String(c)) || (names[c] && chunks.includes(names[c]))) ) {
matched = true;
break;
}
}
if ( matched )
ids = [...ids, ...Object.keys(modules)];
}
ids = new Set(ids);
}
let checked = 0;
for(const id of ids) {
try {
checked++;
// If we have not previously required this module, check to see
// if we CAN require this module. We want to avoid requiring a
// module that doesn't yet have a constructor because that will
// break webpack's internal state.
if ( ! this._required_ids.has(id) ) {
let check = this._checked_module[id];
if ( check == null )
check = this._checkModule(id);
if ( check )
continue;
}
this._required_ids.add(id);
if ( ! require.m[id] )
this.log.warn('Tried requiring module that isn\'t loaded', id);
const mod = require(id);
if ( mod ) {
const ret = predicate(mod);
if ( ret ) {
this.log.debug(`Located module "${key}" in module ${id}${DEBUG ? ` (${this.chunkNameForModule(id)})` : ''} after ${checked} tries`);
const out = predicate.use_result ? ret : mod;
if ( key )
this._mod_cache[key] = out;
return out;
}
}
} catch(err) {
this.log.warn('Unexpected error trying to find module', err);
}
}
this.log.debug(`Unable to locate module "${key}" despite checking ${checked} modules`);
return null;
}
_checkModule(id, checked = null) {
if (checked) {
if (checked.has(id))
return (this._checked_module[id] ?? false);
checked.add(id);
}
let fn = this._require?.m?.[id];
if ( fn ) {
if ( fn.original )
fn = fn.original;
let reqs = fn[Requires],
banned = false;
if ( reqs == null ) {
const str = fn.toString(),
name_match = /^function\([^,)]+,[^,)]+,([^,)]+)/.exec(str);
if ( name_match ) {
const regex = getRequireRegex(name_match[1]);
reqs = fn[Requires] = new Set;
regex.lastIndex = 0;
let match;
// Here, we check all the modules this module depends on
// so that we don't require a module with missing requirements
// because webpack sucks.
while((match = regex.exec(str))) {
let mod_id = match[1];
if ( mod_id === 'e' )
continue;
// Modules are all numbers, but some are written in e notation.
// We need to correct that for string comparison.
if ( /^\d+e\d+$/.test(mod_id) ) {
const bits = mod_id.split('e');
mod_id = `${parseInt(bits[0], 10) * (10 ** parseInt(bits[1], 10))}`;
}
reqs.add(mod_id);
if ( ! banned && ! this._require.m[mod_id] ) {
this.log.verbose(`Unable to load module ${id} due to missing dependency ${mod_id}`);
banned = true;
}
}
} else
fn[Requires] = false;
} else if ( reqs ) {
for(const mod_id of reqs)
if ( ! this._require.m[mod_id] ) {
banned = true;
break;
}
}
if ( ! banned && reqs ) {
if ( ! checked ) {
checked = new Set();
checked.add(id);
}
for(const mod_id of reqs) {
let val = this._checked_module[mod_id];
if (val == null && ! checked.has(mod_id))
try {
val = this._checkModule(mod_id, checked);
} catch (err) {
this.log.verbose(`Recursion error checking module ${id} (${mod_id})`);
val = true;
}
if ( val ) {
this.log.verbose(`Unable to load module ${id} due to unable to load dependency ${mod_id}`);
banned = true;
break;
}
}
}
return this._checked_module[id] = banned;
}
return this._checked_module[id] = true;
}
// ========================================================================
// Grabbing Require
// ========================================================================
getRequire(limit = 0) {
if ( this._require )
return Promise.resolve(this._require);
return new Promise((resolve, reject) => {
let fn = this._original_loader;
if ( ! fn ) {
if ( limit > 500 )
reject(new Error('unable to find webpackJsonp'));
return setTimeout(() => this.getRequire(limit++).then(resolve), 250);
}
if ( this.v4 )
fn = fn.bind(this._original_store);
// Inject a fake module and use that to grab require.
const id = `ffz-loader$${generateUUID()}`;
fn([
[id],
{
[id]: (module, exports, __webpack_require__) => {
resolve(this._require = __webpack_require__);
}
},
req => req(id)
]);
})
}
async hookRequire() {
const start_time = performance.now(),
require = await this.getRequire(),
time = performance.now() - start_time;
this.log.info(`require() grabbed in ${time.toFixed(5)}ms.`);
const loader = require.e && require.e.toString();
let modules;
if ( loader && loader.indexOf('Loading chunk') !== -1 ) {
const data = this.v4 ? /assets\/"\+\(?({1:.*?})/.exec(loader) : /({0:.*?})/.exec(loader);
if ( data )
try {
modules = JSON.parse(data[1].replace(/(\d+):/g, '"$1":'))
} catch(err) { } // eslint-disable-line no-empty
} else if ( require.u ) {
const builder = require.u.toString(),
match = /assets\/"\+\(?({\d+:.*?})/.exec(builder),
data = match ? match[1].replace(/([\de]+):/g, (_, m) => {
if ( /^\d+e\d+$/.test(m) ) {
const bits = m.split('e');
m = parseInt(bits[0], 10) * (10 ** parseInt(bits[1], 10));
}
return `"${m}":`;
}) : null;
if ( data )
try {
modules = JSON.parse(data);
} catch(err) { console.log(data); console.log(err) /* no-op */ }
}
if ( modules ) {
// Ensure that vendor and core have names.
if ( this._original_store ) {
for(const [pos, name] of HARD_MODULES) {
const mods = this._original_store[pos]?.[0];
if ( Array.isArray(mods) )
for(const id of mods)
if ( typeof id !== 'object' && ! modules[id] )
modules[id] = name;
}
}
this._chunk_names = modules;
this.log.debug(`Loaded names for ${Object.keys(modules).length} chunks from require().`)
} else
this.log.warn(`Unable to find chunk names in require().`);
}
}
WebMunch.Requires = Requires;

View file

@ -0,0 +1,789 @@
'use strict';
// ============================================================================
// WebMunch
// It consumes webpack.
// ============================================================================
import Module, { GenericModule } from 'utilities/module';
import {has, generateUUID, makeAddonIdChecker} from 'utilities/object';
import { DEBUG } from 'utilities/constants';
declare module 'utilities/types' {
interface ModuleEventMap {
'site.web_munch': WebMunchEvents;
}
interface ModuleMap {
'site.web_munch': WebMunch;
}
}
enum NodeState {
/** Nodes in the Unloaded state have not yet been loaded by webpack. */
Unloaded,
/** Nodes in the Loaded state have been loaded by webpack and we've
* processed them to read dependencies. */
Loaded,
/** Nodes in the Ready state are Loaded, and additionally all their
* dependencies are Ready. The module can be used. */
Ready,
/** Nodes in the Used state have been used. */
Used
}
type GraphNode = {
id: string;
state: NodeState;
/** A set of nodes that this node depends on. This may be `false` if the
* node's dependencies have been checked and it has none, or `null` if the
* node's dependencies have not yet been checked. */
requires: Set<string> | false | null;
/** A set of nodes that depend on this node. This may be `null` if no
* nodes have been found that depend on this node yet. */
dependants: Set<string> | null;
}
export type WebMunchEvents = {
/** This event is fired whenever new modules were marked Ready. */
':new-ready': [ids: Set<string>]
};
export type Predicate = ((n: unknown) => boolean) & {
chunks?: string | string[];
use_result?: boolean;
};
export type DeepPredicate = (n: unknown, mod: unknown, key: string | Symbol) => boolean;
type WebpackStoreV4 = WebpackLoaderDataV4[] & {
push: WebpackLoaderV4;
};
type WebpackModuleLoaderV4 = ((module: unknown, exports: unknown, __webpack_require__: WebpackRequireV4) => unknown);
type WebpackLoaderFuncV4 = (require: WebpackRequireV4) => void;
type WebpackLoaderDataV4 = [
chunk_ids: (number | string)[],
modules: Record<string, WebpackModuleLoaderV4>,
fn?: WebpackLoaderFuncV4
];
type WebpackLoaderV4 = (data: WebpackLoaderDataV4, ...args: unknown[]) => unknown;
type WebpackRequireV4 = {
<T = unknown>(id: string): T;
m?: Record<string, WebpackModuleLoaderV4>;
u?: (id: string) => string;
};
const regex_cache: Record<string, RegExp> = {};
function getRequireRegex(name: string) {
if ( ! regex_cache[name] )
return regex_cache[name] = new RegExp(`\\b(?<!\\.)${name}\\(([0-9e_+]+)\\)`, 'g');
return regex_cache[name];
}
const NAMES = [
'webpackJsonp',
'webpackChunktwitch_twilight',
'webpackChunktwitch_sunlight',
'webpackJsonp_N_E'
];
const HARD_MODULES = [
[0, 'vendor'],
[1, 'core']
];
/**
* Compute the strongly connected components (SCCs) among a set of candidate nodes
* using Tarjan's algorithm. The graph is defined over the _graph property and an edge
* exists from node A to node B if A.requires (a Set) contains B.
*
* @param node_ids Array of module IDs (candidates) to run the algorithm on.
* @returns An array of SCCs (each SCC is an array of module IDs).
*/
function computeSCC(graph: Record<string, GraphNode>, node_ids: Set<string>): string[][] {
let index = 0;
const indices: Record<string, number> = {};
const lowlinks: Record<string, number> = {};
const stack: string[] = [];
const onStack: Record<string, boolean> = {};
const result: string[][] = [];
function strongconnect(v: string) {
indices[v] = index;
lowlinks[v] = index;
index++;
stack.push(v);
onStack[v] = true;
// Consider each dependency of v.
const node = graph[v];
if (node.requires) {
for (const w of node.requires) {
// Only consider w if it is among the candidates.
if (!node_ids.has(w)) continue;
if (indices[w] === undefined) {
// w has not been visited; do so.
strongconnect(w);
lowlinks[v] = Math.min(lowlinks[v], lowlinks[w]);
} else if (onStack[w]) {
lowlinks[v] = Math.min(lowlinks[v], indices[w]);
}
}
}
// If v is a root node, pop the stack and generate an SCC.
if (lowlinks[v] === indices[v]) {
const scc: string[] = [];
let w: string;
do {
w = stack.pop()!;
onStack[w] = false;
scc.push(w);
} while (w !== v);
result.push(scc);
}
}
// Run strongconnect for every candidate node.
for (const v of node_ids) {
if (indices[v] === undefined) {
strongconnect(v);
}
}
return result;
}
export default class WebMunch extends Module<'site.web_munch', WebMunchEvents> {
_original_store?: WebpackStoreV4 | null;
_original_loader?: WebpackLoaderV4 | null;
_require: WebpackRequireV4 | null;
_chunk_names: Record<string, string>;
_known_rules: Record<string, Predicate>;
_mod_cache: Record<string, unknown>;
_loaded_ids: Set<string>;
_pending_ready_ids: Set<string>;
_graph: Record<string, GraphNode>;
_graph_update_raf?: ReturnType<typeof requestAnimationFrame> | null;
_processed_all?: boolean;
_require_waiter?: Promise<WebpackRequireV4> | null;
_load_waiter?: Promise<WebpackLoaderV4> | null;
_load_wait_fns?: null | [(value: WebpackLoaderV4) => void, (reason?: any) => void];
start_time: number;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this._processGraph = this._processGraph.bind(this);
this.start_time = performance.now();
this._known_rules = {};
this._mod_cache = {};
this._require = null;
this._chunk_names = {};
this._pending_ready_ids = new Set;
this._loaded_ids = new Set;
this._graph = {};
this.hookLoader();
this.getRequire();
}
// ========================================================================
// Grabbing Webpack
// ========================================================================
waitForLoader() {
if ( this._original_loader )
return Promise.resolve(this._original_loader);
if ( ! this._load_waiter )
this._load_waiter = new Promise<WebpackLoaderV4>((s,f) => {
this._load_wait_fns = [s,f];
});
return this._load_waiter;
}
_resolveLoadWait(result?: WebpackLoaderV4 | null) {
const fns = this._load_wait_fns;
this._load_waiter = null;
this._load_wait_fns = null;
if (fns)
result ? fns[0](result) : fns[1]();
}
hookLoader(attempts = 0) {
if ( this._original_loader ) {
this.log.warn('Attempted to call hookLoader twice.');
return;
}
let name: string | null = null;
for(const n of NAMES)
if ( (window as any)[n] ) {
name = n;
break;
}
if ( ! name ) {
if ( attempts > 240 ) {
this.log.error("Unable to find webpack's loader after one minute.");
try {
const possibilities = [];
for(const key of Object.keys(window))
if ( has(window, key) && typeof key === 'string' && /webpack/i.test(key) && ! /ffz/i.test(key) )
possibilities.push(key);
if ( possibilities.length )
this.log.info('Possible Matches: ', possibilities.join(', '));
else
this.log.info('No possible matches found.');
} catch(err) { /* no-op */ }
this._resolveLoadWait();
return;
}
setTimeout(this.hookLoader.bind(this, attempts + 1), 250);
return;
}
const thing = (window as any)[name] as unknown;
if ( Array.isArray(thing) ) {
this._original_store = thing as WebpackStoreV4;
this._original_loader = thing.push;
this._processAllModules();
try {
(thing as any).push = this.webpackJsonpv4.bind(this);
} catch(err) {
this.log.warn('Unable to wrap webpackJsonp due to write protection.');
this._resolveLoadWait();
return;
}
} else {
this.log.error('webpackJsonp is of an unknown value. Unable to wrap.');
this._resolveLoadWait();
return;
}
this._resolveLoadWait(this._original_loader);
const end = performance.now();
this.log.info(`Hooked webpack loader after ${Math.round(100*(end - this.start_time))/100}ms`);
}
webpackJsonpv4(data: WebpackLoaderDataV4, ...args: unknown[]) {
const chunk_ids = data[0].map(x => typeof x !== 'string' ? `${x}` : x),
modules = data[1],
names = Array.isArray(chunk_ids)
? chunk_ids.map(x => this._chunk_names[x] ?? x)
: null;
this.log.verbose(`Twitch Chunk Loaded: ${chunk_ids} (${names?.join(', ')})`);
this.log.verbose(`Modules: ${Object.keys(modules)}`);
const res = this._original_loader!.call(this._original_store, data, ...args); // eslint-disable-line prefer-rest-params
//this.emit(':chunk-loaded', chunk_ids, names, modules);
if ( modules )
this._processModulesV4(modules);
return res;
}
// ========================================================================
// Grabbing Require
// ========================================================================
getRequire(): Promise<WebpackRequireV4> {
if ( this._require )
return Promise.resolve(this._require);
if ( ! this._require_waiter )
this._require_waiter = new Promise<WebpackRequireV4>(async (resolve, reject) => {
let fn = await this.waitForLoader();
fn = fn.bind(this._original_store);
// Inject a fake module and use that to grab require.
const id = `ffz-loader$${generateUUID()}`;
fn([
[id],
{
[id]: (module, exports, __webpack_require__) => {
this._require = __webpack_require__;
this._loadChunkNames();
resolve(this._require);
const end = performance.now();
this.log.info(`Hooked webpack require after ${Math.round(100*(end - this.start_time))/100}ms`);
this._processAllModules();
this._require_waiter = null;
}
},
(req: WebpackRequireV4) => req(id)
]);
});
return this._require_waiter;
}
private _loadChunkNames() {
let modules: Record<string, string> | null = null;
if ( this._require?.u ) {
const builder = this._require.u.toString(),
match = /assets\/"\+\(?({\d+:.*?})/.exec(builder),
data = match ? match[1].replace(/([\de]+):/g, (_, m) => {
if ( /^\d+e\d+$/.test(m) ) {
const bits = m.split('e');
m = parseInt(bits[0], 10) * (10 ** parseInt(bits[1], 10));
}
return `"${m}":`;
}) : null;
if ( data )
try {
modules = JSON.parse(data);
} catch(err) { console.log(data); console.log(err) /* no-op */ }
}
if ( modules ) {
this._chunk_names = modules;
this.log.debug(`Loaded names for ${Object.keys(modules).length} chunks from require().`)
} else
this.log.warn(`Unable to find chunk names in require().`);
}
// ========================================================================
// Node Graph Processing
// ========================================================================
private _getNode(id: string) {
let out = this._graph[id];
if (! out )
out = this._graph[id] = {
id,
state: NodeState.Unloaded,
requires: null,
dependants: null
};
return out;
}
private _processAllModules() {
if ( ! this._require?.m || ! this._original_store || this._processed_all )
return;
this._processed_all = true;
for(const chunk of this._original_store)
if ( chunk && chunk[1] )
this._processModulesV4(chunk[1]);
}
/**
* Process newly loaded modules, updating their graph nodes
* and potentially triggering graph rebuilds via _processGraph.
* @param modules An object containing freshly loaded modules to process.
* @param newly_ready A list of ids of newly ready modules, for adding onto.
* @returns A list of ids of modules that are now ready
*/
private _processModulesV4(modules: Record<string, Function>) {
const require = this._require;
let need_graph = false;
for(const mod_id of Object.keys(modules)) {
const node = this._getNode(mod_id),
fn = require?.m?.[mod_id];
if (node.state !== NodeState.Unloaded || ! fn)
continue;
node.state = NodeState.Loaded;
this._loaded_ids.add(mod_id);
if (node.requires == null)
this._detectRequirements(node, fn);
if (node.requires === false) {
node.state = NodeState.Ready;
this._loaded_ids.delete(mod_id);
this._pending_ready_ids.add(mod_id);
}
// Mark any nodes that depend on this node as dirty
// so we can reprocess that section of the graph.
if (node.dependants && node.dependants.size > 0)
need_graph = true;
}
// Schedule a graph update, maybe.
if (need_graph || this._pending_ready_ids.size > 0)
this.scheduleGraphUpdate();
}
private _detectRequirements(node: GraphNode, fn: WebpackModuleLoaderV4) {
const str = fn.toString(),
name_match = /^function\([^,)]+,[^,)]+,([^,)]+)/.exec(str);
if ( name_match ) {
const regex = getRequireRegex(name_match[1]);
const reqs = new Set<string>;
regex.lastIndex = 0;
let match;
// Here, we check all the modules this module depends on
// so that we don't require a module with missing requirements
// because webpack sucks.
while((match = regex.exec(str))) {
let mod_id = match[1];
if ( mod_id === 'e' )
continue;
// Modules are all numbers, but some are written in e notation.
// We need to correct that for string comparison.
if ( /^\d+e\d+$/.test(mod_id) ) {
const bits = mod_id.split('e');
mod_id = `${parseInt(bits[0], 10) * (10 ** parseInt(bits[1], 10))}`;
}
reqs.add(mod_id);
// Two way relationship
const other = this._getNode(mod_id);
other.dependants ??= new Set;
other.dependants.add(node.id);
}
node.requires = reqs.size > 0 ? reqs : false;
} else
node.requires = false;
}
scheduleGraphUpdate() {
if ( ! this._graph_update_raf)
this._graph_update_raf = requestAnimationFrame(this._processGraph);
}
/**
* Process the dependency graph for any nodes that are in the Loaded state
* and may now be marked as Ready. To handle cycles, we group nodes into
* strongly connected components and mark an entire SCC as Ready if every
* external dependency is already Ready or Used.
* @param newly_ready An array to collect module ids that are marked Ready
* @returns The updated list of module ids that are now ready.
*/
private _processGraph() {
this._graph_update_raf = null;
const start = performance.now();
const count = this._pending_ready_ids.size;
// Iterate until no further nodes have become Ready.
let changed = true;
while(changed) {
changed = false;
// Determine our candidates.
if (this._loaded_ids.size === 0)
break;
// Compute strongly connected components among the candidates.
const sccs = computeSCC(this._graph, this._loaded_ids);
for(const scc of sccs) {
if (scc.length === 0)
continue;
// Check if each node in the SCC has all external dependencies Ready/Used.
let eligible = true;
for(const mod_id of scc) {
const node = this._graph[mod_id];
if (node.requires)
for(const req_id of node.requires) {
// Ignore other modules that are part of this SCC.
if (scc.includes(req_id))
continue;
const req_node = this._graph[req_id];
if (!req_node || (req_node.state !== NodeState.Ready && req_node.state !== NodeState.Used)) {
eligible = false;
break;
}
}
if (!eligible)
break;
}
// If the entire SCC is eligible, mark every node in it as Ready.
if (eligible) {
changed = true;
for(const mod_id of scc) {
const node = this._graph[mod_id];
if (node.state === NodeState.Loaded) {
node.state = NodeState.Ready;
this._loaded_ids.delete(mod_id);
this._pending_ready_ids.add(mod_id);
}
}
}
}
}
const end = performance.now();
this.log.debug(`Processed graph in ${Math.round(100*(end-start))/100}ms, found ${this._pending_ready_ids.size - count} newly ready modules. There are ${this._loaded_ids.size} remaining Loaded modules.`);
// TODO: Check for modules we're waiting for but only in the newly ready modules.
if ( this._pending_ready_ids.size ) {
const ready_ids = this._pending_ready_ids;
this._pending_ready_ids = new Set;
this.emit(':new-ready', ready_ids);
}
}
// ========================================================================
// Finding Modules
// ========================================================================
known(key: string, predicate: Predicate) {
if ( typeof key === 'object' ) {
for(const k of Object.keys(key))
this.known(k, key[k]);
return;
}
this._known_rules[key] = predicate;
}
async findModule<T = unknown>(key: string, predicate?: Predicate) {
if ( ! this._require )
await this.getRequire();
return this.getModule<T>(key, predicate);
}
findDeep(chunks: string | string[] | null, predicate: DeepPredicate, multi = true) {
if ( chunks && ! Array.isArray(chunks) )
chunks = [chunks];
if ( ! this._require || ! this._original_store )
throw new Error('We do not have webpack');
const out: unknown[] = [],
names = this._chunk_names;
for(const [cs, modules] of this._original_store) {
/*if ( chunks ) {
let matched = false;
for(const c of cs) {
if ( chunks.includes(c) || chunks.includes(`${c}`) || (names[c] && chunks.includes(names[c])) ) {
matched = true;
break;
}
}
if ( ! matched )
continue;
}*/
for(const id of Object.keys(modules)) {
const node = this._getNode(id);
if (node.state !== NodeState.Ready && node.state !== NodeState.Used)
continue;
try {
node.state = NodeState.Used;
const mod = this._require(id);
if (mod)
for(const [key, val] of Object.entries(mod))
if ( val && predicate(val, mod, key) ) {
this.log.info(`Found in key "${key}" of module "${id}" (${this.chunkNameForModule(id)})`);
if ( ! multi )
return mod;
out.push(mod);
break;
}
} catch(err) {
this.log.warn('Exception while deep scanning webpack.', err);
}
}
}
if ( out.length )
return out;
this.log.info('Unable to find deep scan target.');
return null;
}
getModule<T = unknown>(key: string | null, predicate?: Predicate) {
if ( typeof key === 'function' ) {
predicate = key;
key = null;
}
if ( key && this._mod_cache[key] )
return this._mod_cache[key] as T;
if ( ! predicate && key )
predicate = this._known_rules[key];
if ( ! predicate )
throw new Error(`no known predicate for locating ${key}`);
const require = this._require;
if ( require?.m )
return this._newGetModule(key, predicate, require) as T;
return null;
}
_chunksForModule(id: string) {
if ( ! this._original_store )
return null;
const out = new Set<number | string>;
for(const [chunks, modules] of this._original_store) {
if ( modules[id] ) {
for(const chunk of chunks)
out.add(chunk);
}
}
return [...out];
}
chunkNameForModule(id: string) {
const chunks = this._chunksForModule(id);
if ( ! chunks )
return null;
for(const chunk of chunks) {
const name = this._chunk_names[chunk];
if ( name )
return name;
}
return null;
}
chunkNamesForModule(id: string) {
const chunks = this._chunksForModule(id);
if ( ! chunks )
return null;
return chunks.map(id => this._chunk_names[id] || id);
}
_newGetModule(key: string | null, predicate: Predicate, require: WebpackRequireV4) {
if ( ! require )
return null;
let ids: Set<string>;
if ( this._original_store && predicate.chunks && this._chunk_names && Object.keys(this._chunk_names).length ) {
const chunk_pred = typeof predicate.chunks === 'function';
if ( ! chunk_pred && ! Array.isArray(predicate.chunks) )
predicate.chunks = [predicate.chunks];
const chunks = predicate.chunks,
names = this._chunk_names;
let id_list: string[] = [];
for(const [cs, modules] of this._original_store) {
let matched = false;
for(const c of cs) {
if ( chunk_pred ? chunks(names[c], c) : (chunks.includes(c) || chunks.includes(String(c)) || (names[c] && chunks.includes(names[c]))) ) {
matched = true;
break;
}
}
if ( matched )
id_list = [...id_list, ...Object.keys(modules)];
}
ids = new Set(id_list);
} else
ids = new Set(Object.keys(this._graph));
let checked = 0;
for(const id of ids) {
//let check;
try {
checked++;
// Ensure the node is in a valid state for requiring it.
const node = this._getNode(id);
if ( node.state !== NodeState.Ready && node.state !== NodeState.Used )
continue;
node.state = NodeState.Used;
const mod = require(id);
if ( mod ) {
const ret = predicate(mod);
if ( ret ) {
this.log.debug(`Located module "${key}" in module ${id}${DEBUG ? ` (${this.chunkNameForModule(id)})` : ''} after ${checked} tries`);
const out = predicate.use_result ? ret : mod;
if ( key )
this._mod_cache[key] = out;
return out;
}
}
} catch(err) {
this.log.warn(`Unexpected error trying to find module '${id}':`, err);
}
}
this.log.debug(`Unable to locate module "${key}" despite checking ${checked} modules`);
return null;
}
}

View file

@ -23,6 +23,45 @@ const ATTRS = [
'title', 'type', 'usemap', 'value', 'width', 'wrap'
];
const SVG_TAGS = [
'svg', 'animate', 'animateMotion', 'animateTransform', 'circle', 'clipPath',
'cursor', 'defs', 'desc', 'ellipse', 'feBlend', 'feColorMatrix', 'feComponentTransfer',
'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight',
'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur',
'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight',
'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence', 'filter', 'font-face-format',
'font-face-name', 'font-face-src', 'font-face-uri', 'font-face', 'font', 'foreignObject',
'g', 'glyph', 'glyphRef', 'hkern', 'image', 'line', 'linearGradient', 'marker', 'mask',
'metadata', 'missing-glyph', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialGradient',
'rect', 'set', 'stop', 'svg', 'switch', 'symbol', 'text', 'textPath', 'tref',
'tspan', 'use', 'view', 'vkern'
];
/*const SVG_ATTRS = [
'accent-height', 'accumulate', 'additive', 'alignment-baseline', 'alphabetic',
'amplitude', 'arabic-form', 'ascent', 'attributeName', 'attributeType', 'azimuth',
'baseFrequency', 'baseline-shift', 'baseProfile', 'bbox', 'begin', 'bias', 'by',
'calcMode', 'cap-height', 'class', 'clip', 'clipPathUnits', 'clip-path', 'clip-rule',
'color', 'color-interpolation', 'color-interpolation-filters', 'crossorigin',
'cursor', 'cx', 'cy', 'd', 'decoding', 'descent', 'diffuseConstant', 'direction', 'display',
'divisor', 'dominant-baseline', 'dur', 'dx', 'dy', 'edgeMode', 'elevation', 'end', 'exponent',
'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterUnits', 'flood-color', 'flood-opacity',
'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant',
'font-weight', 'fr', 'from', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyph-orientation-horizontal',
'glyph-orientation-vertical', 'gradientTransform', 'gradientUnits', 'hanging',
'horiz-adv-x', 'horiz-origin-x', 'ideographic', 'image-rendering', 'in', 'in2', 'intercept',
'k', 'k1', 'k2', 'k3', 'k4', 'kernelMatrix', 'kernelUnitLength', 'keyPoints', 'keySplines',
'keyTimes', 'lang', 'lengthAdjust', 'letter-spacing', 'lighting-color', 'limitingConeAngle',
'local', 'marker-end', 'marker-mid', 'marker-start', 'markerHeight', 'markerUnits', 'markerWidth',
'mask', 'maskContentUnits', 'maskUnits', 'mathematical', 'max', 'media', 'method', 'min', 'mode',
'name', 'numOctaves', 'offset', 'opacity', 'operator', 'order', 'orient', 'orientation', 'origin',
'overflow', 'overline-position', 'overline-thickness', 'paint-order', 'panose-1', 'path',
'pathLength', 'patternContentUnits', 'patternTransform', 'patternUnits', 'ping', 'pointer-events',
'points', 'pointsAtX', 'pointsAtY', 'pointsAtZ', 'preserveAlpha', 'preserveAspectRatio',
'primitiveUnits', 'r', 'radius', 'refX', 'refY', 'result', 'rotate', 'rx', 'ry', 'scale', 'seed',
'side', 'spacing', 'stop-color', 'stop-opacity', 'st'
];*/
const BOOLEAN_ATTRS = [
'controls', 'autoplay', 'loop'
];
@ -211,7 +250,7 @@ export function createElement(tag: string, props?: any, ...children: DomFragment
if ( prop && prop !== 'false' )
el.setAttribute(key, prop);
} else if ( lk.startsWith('aria-') || ATTRS.includes(lk) )
} else if ( lk.startsWith('aria-') || ATTRS.includes(lk) || SVG_TAGS.includes(tag) )
el.setAttribute(key, prop);
else