From 850c4d53fd70b5e074ebd00ec3e6d0a3d475506e Mon Sep 17 00:00:00 2001 From: SirStendec Date: Tue, 4 Mar 2025 17:35:08 -0500 Subject: [PATCH] 4.77.0 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. --- package.json | 2 +- src/modules/chat/index.js | 3 + src/sites/twitch-twilight/modules/channel.jsx | 2 +- .../twitch-twilight/modules/chat/index.js | 10 +- .../modules/directory/index.jsx | 2 + .../styles/color_normalizer.scss | 10 + src/utilities/compat/webmunch.js | 686 --------------- src/utilities/compat/webmunch.ts | 789 ++++++++++++++++++ src/utilities/dom.ts | 41 +- 9 files changed, 851 insertions(+), 694 deletions(-) delete mode 100644 src/utilities/compat/webmunch.js create mode 100644 src/utilities/compat/webmunch.ts diff --git a/package.json b/package.json index 05c9a4aa..43449302 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/modules/chat/index.js b/src/modules/chat/index.js index d12bccb8..acf0e900 100644 --- a/src/modules/chat/index.js +++ b/src/modules/chat/index.js @@ -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) diff --git a/src/sites/twitch-twilight/modules/channel.jsx b/src/sites/twitch-twilight/modules/channel.jsx index 0572dbf5..2568a062 100644 --- a/src/sites/twitch-twilight/modules/channel.jsx +++ b/src/sites/twitch-twilight/modules/channel.jsx @@ -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 */ } diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js index c6623058..73a46eb9 100644 --- a/src/sites/twitch-twilight/modules/chat/index.js +++ b/src/sites/twitch-twilight/modules/chat/index.js @@ -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(); diff --git a/src/sites/twitch-twilight/modules/directory/index.jsx b/src/sites/twitch-twilight/modules/directory/index.jsx index 7e7b74d8..ba7271d8 100644 --- a/src/sites/twitch-twilight/modules/directory/index.jsx +++ b/src/sites/twitch-twilight/modules/directory/index.jsx @@ -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() }, diff --git a/src/sites/twitch-twilight/styles/color_normalizer.scss b/src/sites/twitch-twilight/styles/color_normalizer.scss index 25b28930..daafbedc 100644 --- a/src/sites/twitch-twilight/styles/color_normalizer.scss +++ b/src/sites/twitch-twilight/styles/color_normalizer.scss @@ -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; } diff --git a/src/utilities/compat/webmunch.js b/src/utilities/compat/webmunch.js deleted file mode 100644 index 6a5977f7..00000000 --- a/src/utilities/compat/webmunch.js +++ /dev/null @@ -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(? 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; diff --git a/src/utilities/compat/webmunch.ts b/src/utilities/compat/webmunch.ts new file mode 100644 index 00000000..f9c773ef --- /dev/null +++ b/src/utilities/compat/webmunch.ts @@ -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 | 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 | null; +} + + +export type WebMunchEvents = { + /** This event is fired whenever new modules were marked Ready. */ + ':new-ready': [ids: Set] +}; + +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, + fn?: WebpackLoaderFuncV4 +]; +type WebpackLoaderV4 = (data: WebpackLoaderDataV4, ...args: unknown[]) => unknown; + +type WebpackRequireV4 = { + (id: string): T; + m?: Record; + u?: (id: string) => string; +}; + + +const regex_cache: Record = {}; + +function getRequireRegex(name: string) { + if ( ! regex_cache[name] ) + return regex_cache[name] = new RegExp(`\\b(?, node_ids: Set): string[][] { + let index = 0; + const indices: Record = {}; + const lowlinks: Record = {}; + const stack: string[] = []; + const onStack: Record = {}; + 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; + + _known_rules: Record; + _mod_cache: Record; + + _loaded_ids: Set; + _pending_ready_ids: Set; + + _graph: Record; + _graph_update_raf?: ReturnType | null; + + _processed_all?: boolean; + + _require_waiter?: Promise | null; + _load_waiter?: Promise | 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((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 { + if ( this._require ) + return Promise.resolve(this._require); + + if ( ! this._require_waiter ) + this._require_waiter = new Promise(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 | 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) { + 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; + + 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(key: string, predicate?: Predicate) { + if ( ! this._require ) + await this.getRequire(); + + return this.getModule(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(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; + + 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; + 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; + } + +} diff --git a/src/utilities/dom.ts b/src/utilities/dom.ts index a9c619f0..3a4e3df5 100644 --- a/src/utilities/dom.ts +++ b/src/utilities/dom.ts @@ -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