From 9a3d2753bd183fae303a38ba4ad788eba532ebb0 Mon Sep 17 00:00:00 2001 From: Timotej Zatko Date: Mon, 29 Jun 2026 11:55:45 +0200 Subject: [PATCH] fix: guard against the script bootstrapping twice on a page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the userscript was injected more than once into the same page (re-injection or a same-origin iframe), bootstrap ran twice and TheWestApi.register threw "given parameter ('tw-calc') is already registered!" on the second run — the visible symptom of everything initializing twice. Extract the injection logic into src/inject.ts and skip bootstrapping when window.TW_Calc is already set, making injection idempotent. Add tests for the ready/already-injected guards. Co-Authored-By: Claude Opus 4.8 --- src/index.ts | 33 +--------------------------- src/inject.spec.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++++ src/inject.ts | 42 +++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 32 deletions(-) create mode 100644 src/inject.spec.ts create mode 100644 src/inject.ts diff --git a/src/index.ts b/src/index.ts index 73b76e5..3abb7e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,9 @@ import './patch'; import 'reflect-metadata'; import 'regenerator-runtime/runtime'; -import DependencyContainer from 'tsyringe/dist/typings/types/dependency-container'; import { bootstrap } from './bootstrap'; import { container } from 'tsyringe'; +import { inject } from './inject'; import { tw2patch } from './tw2-patch'; import { TWCalcPublicApi } from './tw-calc.types'; @@ -14,34 +14,3 @@ container.register('localStorage', { useValue: localStorage }); container.register('tw2patch', { useValue: tw2patch }); inject(bootstrap, window, location); - -// inject script when the game is ready -function inject( - bootstrapper: (dependencyContainer: DependencyContainer) => T, - window: Window, - location: Location, -): NodeJS.Timeout { - const injectInterval = setInterval(() => { - if (!isGameReady(window)) { - return; - } - // clear the injector interval - clearInterval(injectInterval); - // inject the script - window['TW_Calc'] = injectScript(bootstrapper, location); - }, 250); - return injectInterval; -} - -function isGameReady(window: Window): boolean { - // jquery must be loaded - return typeof window['$'] !== 'undefined'; -} - -function injectScript(bootstrapper: (dependencyContainer: DependencyContainer) => T, location: Location): T { - const { href } = location; - if ((href.indexOf('.the-west.') != -1 || href.indexOf('.tw.innogames.') != -1) && href.indexOf('game.php') != -1) { - return bootstrapper(container); - } - throw new Error('TW-Calc must be loaded in the game!'); -} diff --git a/src/inject.spec.ts b/src/inject.spec.ts new file mode 100644 index 0000000..a23a6c9 --- /dev/null +++ b/src/inject.spec.ts @@ -0,0 +1,55 @@ +import { inject, isAlreadyInjected, isGameReady } from './inject'; + +describe('inject', () => { + let win: Partial & { $?: unknown; TW_Calc?: unknown }; + let location: Location; + let bootstrapper: jasmine.Spy; + + beforeEach(() => { + jasmine.clock().install(); + bootstrapper = jasmine.createSpy('bootstrap').and.returnValue({}); + location = { href: 'https://en.the-west.net/game.php' } as Location; + win = {}; + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it('isGameReady reflects jQuery presence', () => { + expect(isGameReady({} as Window)).toBe(false); + expect(isGameReady({ $: () => undefined } as unknown as Window)).toBe(true); + }); + + it('isAlreadyInjected reflects TW_Calc presence', () => { + expect(isAlreadyInjected({} as Window)).toBe(false); + expect(isAlreadyInjected({ TW_Calc: {} } as unknown as Window)).toBe(true); + }); + + it('does not bootstrap until the game is ready', () => { + inject(bootstrapper, win as Window, location); + jasmine.clock().tick(1000); + + expect(bootstrapper).not.toHaveBeenCalled(); + }); + + it('bootstraps once when the game is ready', () => { + win.$ = () => undefined; + + inject(bootstrapper, win as Window, location); + jasmine.clock().tick(250); + + expect(bootstrapper).toHaveBeenCalledTimes(1); + expect(win.TW_Calc).toBeDefined(); + }); + + it('does not bootstrap again when the script is already injected', () => { + win.$ = () => undefined; + win.TW_Calc = {}; // a previous injection already set this + + inject(bootstrapper, win as Window, location); + jasmine.clock().tick(1000); + + expect(bootstrapper).not.toHaveBeenCalled(); + }); +}); diff --git a/src/inject.ts b/src/inject.ts new file mode 100644 index 0000000..71aea05 --- /dev/null +++ b/src/inject.ts @@ -0,0 +1,42 @@ +import DependencyContainer from 'tsyringe/dist/typings/types/dependency-container'; +import { container } from 'tsyringe'; + +export function isGameReady(window: Window): boolean { + // jquery must be loaded + return typeof window['$'] !== 'undefined'; +} + +export function isAlreadyInjected(window: Window): boolean { + return typeof window['TW_Calc'] !== 'undefined'; +} + +// inject script when the game is ready +export function inject( + bootstrapper: (dependencyContainer: DependencyContainer) => T, + window: Window, + location: Location, +): ReturnType { + const injectInterval = setInterval(() => { + if (!isGameReady(window)) { + return; + } + // clear the injector interval + clearInterval(injectInterval); + // the script may be injected more than once on the page (e.g. re-injection or an + // iframe); only bootstrap once, otherwise TheWestApi.register throws "already registered" + if (isAlreadyInjected(window)) { + return; + } + // inject the script + window['TW_Calc'] = injectScript(bootstrapper, location); + }, 250); + return injectInterval; +} + +export function injectScript(bootstrapper: (dependencyContainer: DependencyContainer) => T, location: Location): T { + const { href } = location; + if ((href.indexOf('.the-west.') != -1 || href.indexOf('.tw.innogames.') != -1) && href.indexOf('game.php') != -1) { + return bootstrapper(container); + } + throw new Error('TW-Calc must be loaded in the game!'); +}