General-purpose SDK for building browser extensions and Tampermonkey/Greasemonkey userscripts for fishtank.live.
npm install ftl-ext-sdkimport { site, chat, ui, socket } from 'ftl-ext-sdk';Implementation Bounty Open. Reward: ₣1,000 Site Tokens.
Support planned. The SDK currently uses ES module exports and needs a UMD/IIFE bundle with window.FTL for userscript environments.
import { site, chat, ui, socket, events } from 'ftl-ext-sdk';
import { io } from 'socket.io-client';
import * as msgpackParser from 'socket.io-msgpack-parser';
site.whenReady(async () => {
// Connect to the chat WebSocket (token: null = anonymous)
await socket.connect(io, msgpackParser, { token: null });
// Log all chat messages
chat.messages.onMessage((msg) => {
console.log(`[${msg.role || 'user'}] ${msg.username}: ${msg.message}`);
});
// React to modal events
events.onModalEvent((action, detail) => {
console.log(`Modal ${action}:`, detail?.modal);
});
ui.toasts.notify('Extension loaded!', { type: 'success' });
});Socket listeners start automatically when you register a callback — no manual setup step needed.
import { site } from 'ftl-ext-sdk';
site.getSiteVersion(); // 'current' | 'classic' | 'unknown'
site.isCurrent(); // true on fishtank.live
site.isClassic(); // true on classic.fishtank.live
site.isMobile(); // true on small screens
site.isSiteReady(); // true when key elements are present
// Wait for site to be ready before initialising
site.whenReady(() => {
console.log('Site is ready!');
});
// Detect the logged-in user
site.getCurrentUsername(); // string or null
site.onUserDetected((username) => {
console.log('Logged in as:', username);
});
// Detect the logged-in user's UUID (from auth cookie)
site.getCurrentUserId(); // string or null
site.onUserIdDetected((userId) => {
console.log('User ID:', userId);
});import { socket } from 'ftl-ext-sdk';
import { io } from 'socket.io-client';
import * as msgpackParser from 'socket.io-msgpack-parser';
// Connect (pass the socket.io-client and msgpack parser)
// token: null = unauthenticated, undefined = auto-detect from cookie
await socket.connect(io, msgpackParser, { token: null });
// Room constants
socket.ROOMS.GLOBAL; // 'Global'
socket.ROOMS.SEASON_PASS; // 'Season Pass'
socket.ROOMS.SEASON_PASS_XL; // 'Season Pass XL'
// Listen for any raw event
const unsub = socket.on('chat:message', (data) => {
console.log(data);
});
// Later: unsubscribe
unsub();
// Check connection status
socket.isConnected(); // true/false
socket.isAuthenticated(); // true/false
// Force a reconnect (e.g. after detecting stale connection)
socket.forceReconnect();
// Disconnect
socket.disconnect();
// Access the raw socket instance
socket.getSocket();| Constant | Event Name | Description |
|---|---|---|
EVENTS.CHAT_MESSAGE |
chat:message |
Chat messages (including happenings) |
EVENTS.CHAT_ROOM |
chat:room |
Room change events |
EVENTS.CHAT_PRESENCE |
chat:presence |
Chat presence updates |
EVENTS.TTS_INSERT |
tts:insert |
TTS submissions |
EVENTS.TTS_UPDATE |
tts:update |
TTS status changes |
EVENTS.SFX_INSERT |
sfx:insert |
SFX submissions |
EVENTS.SFX_UPDATE |
sfx:update |
SFX status changes |
EVENTS.CRAFTING_RECIPE_LEARNED |
items:crafting-recipe:learned |
New crafting recipe discovered |
EVENTS.NOTIFICATION_GLOBAL |
notification:global |
Global notifications / admin messages |
EVENTS.PRESENCE |
presence |
User presence updates |
Note: The server sends TTS and SFX events inconsistently — sometimes
:insert, sometimes:update, sometimes both. If you usechat.messages.onTTS()/chat.messages.onSFX()(recommended), the SDK listens on both and deduplicates automatically. If you use rawsocket.on(), you'll need to handle this yourself.
The recommended way to receive chat messages, TTS, and SFX events. The SDK normalises the raw socket data into clean objects, handles array unwrapping, resolves role priority from metadata flags, and deduplicates TTS/SFX events.
Socket listeners start automatically on the first callback registration — no setup step needed.
import { chat } from 'ftl-ext-sdk';
// Chat messages
chat.messages.onMessage((msg) => {
console.log(`${msg.username}: ${msg.message}`);
console.log('Role:', msg.role); // 'staff' | 'mod' | 'fish' | 'grandMarshal' | 'epic' | null
console.log('Colour:', msg.colour); // custom username colour or null
console.log('Avatar:', msg.avatar); // filename, e.g. "rchl.png"
console.log('Clan:', msg.clan);
console.log('Mentions:', msg.mentions); // [{displayName, userId}]
// Raw socket data is always available if you need it
console.log('Raw:', msg.raw);
});
// TTS (deduplicated across tts:insert and tts:update)
chat.messages.onTTS((tts) => {
console.log(`[TTS] ${tts.username} in ${tts.room}: ${tts.message} (${tts.voice})`);
console.log('Audio ID:', tts.audioId); // for CDN URL construction
console.log('Clan:', tts.clanTag);
});
// SFX (deduplicated across sfx:insert and sfx:update)
chat.messages.onSFX((sfx) => {
console.log(`[SFX] ${sfx.username} in ${sfx.room}: ${sfx.message}`);
console.log('Audio file:', sfx.audioFile); // filename from CDN URL
console.log('Clan:', sfx.clanTag);
});
// Convenience helpers (work on normalised objects)
chat.messages.isStaffMessage(msg); // boolean
chat.messages.isFishMessage(msg); // boolean
chat.messages.isModMessage(msg); // boolean
chat.messages.isHappening(msg); // boolean
chat.messages.mentionsUser(msg, 'username'); // boolean{
username: "BarryThePirate", // display name
message: "Hello world", // message text
role: "staff", // 'staff' | 'mod' | 'fish' | 'grandMarshal' | 'epic' | null
colour: "#966b9e", // custom username colour or null
avatar: "rchl.png", // avatar filename (extracted from CDN URL)
clan: null, // clan tag or null
endorsement: null, // endorsement badge text or null
chatRoom: "Global", // 'Global' | 'Season Pass' | 'Season Pass XL'
mentions: [ // normalised mention objects
{ displayName: "someuser", userId: "uuid-..." }
],
raw: { /* original socket data */ },
}{
username: "SomeUser",
message: "Hello from TTS",
voice: "Brainrot", // voice name
room: "brrr-5", // room code (use player.streams.roomName() to resolve)
audioId: "abc123", // TTS ID (CDN URL: https://cdn.fishtank.live/tts/{audioId}.mp3)
clanTag: null,
raw: { /* original socket data */ },
}{
username: "SomeUser",
message: "Airhorn", // sound name
room: "brrr-5",
audioFile: "Airhorn-123456.mp3", // filename (CDN URL: https://cdn.fishtank.live/sfx/{audioFile})
clanTag: null,
raw: { /* original socket data */ },
}The SDK resolves the highest-priority role from the socket metadata flags:
staff > mod > fish > grandMarshal > epic > null
A user with both isAdmin and isFish set to true will have role: 'staff'.
By default, the primary socket receives Global chat only. Use chat.rooms to subscribe to Season Pass and Season Pass XL rooms. Messages from all subscribed rooms flow through the same chat.messages.onMessage() callbacks — each message includes a chatRoom field indicating its source.
Room subscriptions require authentication (the server silently ignores room switches from anonymous sockets). The SDK auto-detects the auth token from the site's cookie.
import { chat } from 'ftl-ext-sdk';
// Subscribe to additional rooms
await chat.rooms.subscribe('Season Pass');
await chat.rooms.subscribe('Season Pass XL');
// Or subscribe to all extra rooms at once
await chat.rooms.subscribeAll();
// Messages now include chatRoom field
chat.messages.onMessage((msg) => {
console.log(`[${msg.chatRoom}] ${msg.username}: ${msg.message}`);
});
// Check subscriptions
chat.rooms.getSubscribed(); // ['Season Pass', 'Season Pass XL']
chat.rooms.isSubscribed('Season Pass'); // true
// Unsubscribe
chat.rooms.unsubscribe('Season Pass XL');
chat.rooms.unsubscribeAll();Note: Each room subscription opens a separate authenticated WebSocket connection. Global is always handled by the primary socket and cannot be unsubscribed.
The simplest way to watch chat. No auth, no extra connections. Observes the chat DOM for new messages and parses them.
import { chat } from 'ftl-ext-sdk';
// Watch for new messages in the DOM
chat.observer.onMessage((msg) => {
console.log(`${msg.username}: ${msg.message}`);
// The raw DOM element is available for visual modifications
if (msg.role === 'staff') {
msg.element.style.backgroundColor = 'rgba(255, 0, 0, 0.1)';
}
});
// Start observing (call after site is ready)
chat.observer.startObserving();
// Or wait for chat to appear, then start
await chat.observer.waitAndObserve();
// Convenience helpers
chat.observer.isStaffMessage(msg);
chat.observer.isFishMessage(msg);
chat.observer.isModMessage(msg);
chat.observer.isTTSMessage(msg);
chat.observer.isSFXMessage(msg);
chat.observer.isChatMessage(msg);
chat.observer.mentionsUser(msg, 'username');
// Stop observing
chat.observer.stopObserving();Reliability warning: The site uses react-window to virtualise chat, keeping only ~17 messages in the DOM at any time. React-window frequently replaces DOM nodes during re-renders, which can cause the observer to lose its connection. For reliable, long-running message capture, use
chat.messages(Socket.IO) instead. The DOM observer is best suited for UI modifications where you need access to the rendered elements.
chat.observer (DOM) |
chat.messages (Socket.IO) |
|
|---|---|---|
| Auth required | No | Optional |
| Extra connections | None | 1 WebSocket |
| Data | Text, username, avatar, level | Full normalised data + raw socket data |
| Reliability | May miss messages during re-renders | Captures every message |
| DOM access | Yes (element ref) | No (data only) |
| TTS/SFX | Only if rendered in chat | Dedicated events with deduplication |
| Best for | UI modifications, visual tweaks | Logging, analytics, bots |
import { chat } from 'ftl-ext-sdk';
chat.input.focus(); // Focus the input
chat.input.insertText('Hello world'); // Insert text
chat.input.mentionUser('username'); // Insert @mention
chat.input.getText(); // Get current input text
chat.input.clear(); // Clear the input
chat.input.getInputElement(); // Get the raw DOM elementimport { events } from 'ftl-ext-sdk';
// Open/close modals
events.openModal('craftItem', { someData: true });
events.closeModal();
events.isModalOpen();
// Watch for specific modals
const unsub = events.onModalOpen('craftItem', (modalElement, data) => {
// Inject your content into the modal
});
// Watch all modal events
events.onModalEvent((action, detail) => {
// action: 'open' | 'close' | 'confirm'
});import { ui } from 'ftl-ext-sdk';
// Inject content into the current modal
ui.modals.injectIntoModal(myElement, { position: 'append' });
ui.modals.injectIntoModal('<p>Hello</p>', { position: 'prepend', id: 'my-content' });
// Wait for modal to close
await ui.modals.waitForClose();import { ui } from 'ftl-ext-sdk';
// Register a shortcut (auto-skips when user is typing)
const unsub = ui.keyboard.register('my-shortcut', { key: 'e' }, () => {
console.log('E pressed!');
});
// With modifiers
ui.keyboard.register('save', { key: 's', ctrl: true }, () => {
console.log('Ctrl+S pressed!');
});
// Stop the event from reaching other handlers
ui.keyboard.register('intercept-t', { key: 't', stopPropagation: true }, () => {
console.log('T intercepted — site handler will not fire');
});
// The callback receives the keyboard event for conditional logic
ui.keyboard.register('conditional', { key: 'x' }, (e) => {
if (someCondition) {
e.stopImmediatePropagation();
}
});
// Unregister
unsub();
ui.keyboard.unregister('my-shortcut');
ui.keyboard.getRegistered(); // list all
ui.keyboard.unregisterAll(); // remove all| Option | Default | Description |
|---|---|---|
key |
(required) | Key to listen for (e.g. 'e', 'escape', 'f') |
ctrl |
false |
Require Ctrl key |
alt |
false |
Require Alt key |
shift |
false |
Require Shift key |
meta |
false |
Require Meta/Cmd key |
skipInputs |
true |
Don't fire when user is typing in an input/textarea |
preventDefault |
true |
Call e.preventDefault() on match |
stopPropagation |
false |
Call e.stopImmediatePropagation() on match |
import { ui } from 'ftl-ext-sdk';
const id = ui.toasts.notify('Hello!', {
description: 'This is a toast',
type: 'success', // 'default' | 'success' | 'error' | 'info'
duration: 5000, // ms (0 for persistent)
});
ui.toasts.dismiss(id);Watch for the site's own toast notifications (admin messages, item drops, crafting alerts).
import { ui } from 'ftl-ext-sdk';
// Wait for the toast container, then start observing
await ui.toastObserver.waitAndObserve();
ui.toastObserver.onToast((toast) => {
console.log('Title:', toast.title);
console.log('Description:', toast.description);
console.log('Image:', toast.imageUrl);
});
ui.toastObserver.isObserving();
ui.toastObserver.stopObserving();import { player } from 'ftl-ext-sdk';
// Room names
player.streams.fetchRoomNames(); // fetch from API (cached in localStorage)
player.streams.roomName('brrr-5'); // 'Bar' (human-readable)
player.streams.getRoomMap(); // full map
player.streams.isPlayerOpen();
player.streams.getPlayerElement();
// Video
player.video.getElement();
player.video.toggleFullscreen();
player.video.isFullscreen();import { dom } from 'ftl-ext-sdk';
// Stable element access
dom.byId('chat-input');
dom.getChatContainer();
dom.getChatScrollContainer();
dom.getVideoElement();
dom.getVisibleChatMessages();
// Wait for elements
const el = await dom.waitForElement('#modal');
// Observe an element (returns disconnect function)
const disconnect = dom.observe(someElement, (mutations) => {
// ...
}, { childList: true, subtree: true });
// Inject content (tagged with data-ftl-sdk for cleanup)
dom.inject(myElement, targetElement, 'append', 'my-injection');
dom.removeInjected('my-injection'); // remove specific
dom.removeInjected(); // remove all SDK injectionsimport { storage } from 'ftl-ext-sdk';
// All keys are prefixed with 'ftl-sdk:' to avoid collisions
storage.set('myKey', { some: 'data' });
storage.get('myKey'); // { some: 'data' }
storage.get('missing', []); // [] (default value)
storage.remove('myKey');
storage.keys(); // ['myKey', ...]
storage.clear(); // clears only SDK keysimport { react } from 'ftl-ext-sdk';
react.isAvailable();
react.getReactFiberKey();
react.getFiber(someElement);
react.getProps(someElement);
// Walk the fiber tree
react.walkFiberUp(element, (fiber) => fiber.memoizedProps?.someProp);
react.walkFiberDown(fiber, (fiber) => fiber.type === 'SomeComponent');
// Find hook state
react.findHookState(fiber, (state) => state?.someField === 'value');
// Search the entire tree from root
react.findInTree((fiber) => fiber.memoizedProps?.targetProp);Firefox content scripts run in a separate JavaScript realm from the page. This causes three issues:
WebSocket binary data arrives as an ArrayBuffer from the page's realm. Libraries like engine.io-parser and notepack.io use instanceof ArrayBuffer checks, which fail across realms.
Symptoms: Socket connects briefly then disconnects with parse error in a loop.
Fix: Add this Rollup plugin to patch instanceof checks at bundle time:
function firefoxArrayBufferFix() {
return {
name: 'firefox-arraybuffer-fix',
renderChunk(code) {
let patched = code;
let patchCount = 0;
patched = patched.replace(
/if \(data instanceof ArrayBuffer\) \{\s*\/\/ from HTTP long-polling \(base64\) or WebSocket \+ binaryType "arraybuffer"/g,
(match) => { patchCount++; return 'if (data instanceof ArrayBuffer || Object.prototype.toString.call(data) === "[object ArrayBuffer]") {\n // from HTTP long-polling (base64) or WebSocket + binaryType "arraybuffer"'; }
);
patched = patched.replace(
/if \(buffer instanceof ArrayBuffer\) \{/g,
(match) => { patchCount++; return 'if (buffer instanceof ArrayBuffer || Object.prototype.toString.call(buffer) === "[object ArrayBuffer]") {'; }
);
if (patchCount > 0) {
console.log(`[firefox-arraybuffer-fix] Applied ${patchCount} patches`);
return { code: patched, map: null };
}
console.warn('[firefox-arraybuffer-fix] WARNING: No patterns found!');
return null;
},
};
}No effect on Chrome where instanceof works across realms.
The page's JavaScript can't read detail on a CustomEvent created in the content script's realm.
Dispatching events:
function dispatchPageEvent(eventName, detail = {}) {
const safeDetail = typeof cloneInto === 'function'
? cloneInto(detail, document.defaultView)
: detail;
document.dispatchEvent(new CustomEvent(eventName, { detail: safeDetail }));
}Reading events:
document.addEventListener('modalOpen', (e) => {
let detail;
try {
detail = e.detail ? JSON.parse(JSON.stringify(e.detail)) : {};
} catch {
detail = {};
}
});Wrap the connect call with a timeout so a failed socket doesn't block the rest of your extension:
try {
await Promise.race([
socket.connect(io, msgpackParser, { token: null }),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 10000)),
]);
} catch (err) {
console.warn('Socket failed:', err.message);
}If you use socket.on() directly instead of chat.messages, be aware of these quirks:
chat:messageis array-wrapped: The data arrives as[{...}]not{...}. Unwrap withconst msg = Array.isArray(data) ? data[0] : data;mentionscontains objects, not strings:[{ displayName: "user", userId: "uuid" }]- TTS/SFX events fire on both
:insertand:update, inconsistently. Listen on both and deduplicate by ID. - Role is split across metadata flags (
isAdmin,isMod,isFish,isGrandMarshall,isEpic). Multiple can be true — resolve by priority.
The chat.messages module handles all of this automatically.
npm install
npm run build # Builds dist/ftl-ext-sdk.bundle.js
npm run watch # Rebuild on changessrc/
├── core/ — Low-level: React fiber, Socket.IO, DOM, events, storage
├── chat/ — Chat observation (DOM + Socket.IO), input helpers
├── player/ — Video player, stream/room name resolution
├── ui/ — Keyboard shortcuts, modals, toasts, toast observer
└── adapters/ — Site-version-specific configuration (current + classic stub)
- Non-destructive — Never modify the site's own connections, state, or event handlers
- Extension-store friendly — No monkey-patching, no remote code, no eval
- Fail silently — Missing elements return null, never throw in production paths
- Namespaced DOM — All injected elements use
data-ftl-sdkattributes - Performance-aware — No persistent body-level MutationObservers (the site generates thousands of chat mutations per second)
MIT