Assistente indipendente per Pegaso, Mercatorum e San Raffaele
Una browser extension non parte da main().
Non esiste necessariamente un unico punto di ingresso. Il browser legge invece un file speciale, manifest.json, e da lì scopre quali componenti deve creare, dove devono vivere e quando devono essere eseguiti.
Nel nostro snapshot di riferimento, il manifest inizia così:
{
"manifest_version": 3,
"name": "PlumePilot – Assistente per Multiversity",
"version": "2.35.1"
}
manifest_version: 3 ci dice che stiamo usando il modello moderno delle extension Chromium e delle WebExtensions compatibili.
Ma le righe realmente interessanti arrivano poco dopo.
Nel manifest.json di PlumePilot troviamo quattro famiglie importanti:
manifest.json
├── action
│ └── popup.html
│
├── background
│ └── background.js (+ script di supporto)
│
├── content_scripts
│ ├── MAIN world
│ └── ISOLATED world
│
└── web_accessible_resources
└── asset utilizzabili dalle pagine supportate
Questa è già una prima lezione architetturale:
Il browser crea confini prima ancora che il nostro codice inizi a girare.
Non possiamo comprendere PlumePilot guardando soltanto le funzioni. Dobbiamo capire in quale runtime gira ogni funzione.
Il manifest dichiara:
"action": {
"default_title": "PlumePilot – Assistente per Multiversity",
"default_popup": "popup.html"
}
Quando premiamo l’icona di PlumePilot, il browser apre popup.html, che carica la logica del popup.
È importante capire cosa non è il popup.
Non è un elemento HTML inserito nella pagina Multiversity.
È una pagina appartenente all’estensione, con un proprio documento, un proprio ciclo di vita e accesso alle API permesse all’estensione.
Quando il popup viene aperto, popup.js ricostruisce buona parte dello stato visibile leggendo chrome.storage.local:
chrome.storage.local.get(
{
enabled: true,
stopAtTests: false,
autoplaySkipCompletedVideos: false,
themePreference: "system",
menuSize: "medium",
floatingMenuEnabled: true,
commissionCheckEnabled: false,
// ...
},
(result) => {
// aggiorna la UI
},
);
File reale: popup.js, circa righe 1308–1375 nello snapshot di riferimento.
Perché il popup è effimero.
L’utente può aprirlo, chiuderlo e riaprirlo. Non è una buona idea trattare le variabili JavaScript del popup come memoria persistente dell’estensione.
Da backend developer puoi pensare al popup più come a una view che viene ricostruita partendo dallo stato persistente che come a un processo centrale sempre vivo.
Questa scelta anticipa un concetto che approfondiremo nel capitolo sullo storage:
La lifetime di una UI non coincide necessariamente con la lifetime dello stato dell’applicazione.
Nel manifest troviamo:
"background": {
"scripts": [
"achievements.js",
"commission-state.js",
"sound-settings.js",
"whats-new.js",
"background.js"
],
"service_worker": "background.js"
}
Questa configurazione serve alla base multipiattaforma.
Nel modello Manifest V3 di Chrome, background.js viene usato come extension service worker. Firefox supporta la forma scripts per il background. La stessa base sorgente può quindi essere trasformata dal processo di build nel pacchetto più adatto al browser.
Il ruolo concettuale del background è più importante del dettaglio di packaging:
Il background coordina attività che appartengono all’estensione nel suo insieme, non a una singola pagina visualizzata.
Per esempio, alla fine di background.js troviamo un vero dispatcher di messaggi:
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
let action = null;
if (message?.type === "PEGASO_GET_OPERATION")
action = reconcileOperationOwner();
else if (message?.type === "PEGASO_START_EXPORT")
action = startExport({ ...message, sourceTabId });
else if (message?.type === "PEGASO_CANCEL_EXPORT")
action = cancelExport(message);
else if (message?.type === "PEGASO_COMMISSION_CHECK_CLAIM")
action = claimCommissionCheck(sourceTabId, message.platformId);
// ...
action.then(sendResponse).catch(/* ... */);
return true;
});
File reale: background.js, circa righe 918–945.
Questa forma assomiglia molto a un router o controller backend:
message.type
↓
dispatcher
↓
handler
↓
risposta asincrona
Non è HTTP, ma il modello mentale è sorprendentemente simile.
Perché non vive nel DOM della pagina.
Se deve sapere quali lezioni sono renderizzate, leggere un bottone della pagina o osservare uno stato JavaScript del sito, ha bisogno di codice che viva nella scheda.
Ed entrano in scena i content script.
Il manifest di PlumePilot registra diversi gruppi di content_scripts.
Uno di essi carica:
{
"js": ["commission-interceptor.js"],
"run_at": "document_start",
"all_frames": false,
"world": "MAIN"
}
Un altro carica:
{
"js": [
"commission-state.js",
"sound-settings.js",
"bridge.js"
],
"run_at": "document_start",
"all_frames": false
}
Qui world non è specificato, quindi il content script usa normalmente il mondo ISOLATED.
Questa differenza è fondamentale.
Immagina la pagina Multiversity come una stanza con lo stesso tavolo — il DOM — ma due serie separate di variabili JavaScript.
stesso DOM
│
┌───────────┴───────────┐
│ │
MAIN world ISOLATED world
│ │
codice pagina content script
+ content.js bridge.js
+ interceptor floating-menu.js
│ │
└──── window.postMessage ────┘
Il codice gira nello stesso ambiente JavaScript della pagina.
Questo è utile quando PlumePilot deve osservare o interagire con comportamenti che appartengono direttamente all’applicazione web.
Ma c’è un prezzo: quel codice non gode automaticamente dell’isolamento e delle API privilegiate tipiche del content script.
Il codice condivide il documento e quindi può lavorare sul DOM, ma il proprio scope JavaScript è separato da quello della pagina.
In compenso è il posto adatto per usare API dell’estensione come chrome.storage e chrome.runtime.
Questa riga di commento dentro content.js racconta l’architettura quasi meglio del manifest:
// Receives the enabled/paused state from bridge.js, which runs in the
// extension's isolated world and therefore has access to chrome.storage.
File reale: content.js, circa righe 248–249.
Subito dopo content.js ascolta messaggi provenienti dallo stesso window:
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (!event.data) return;
// interpreta i messaggi del bridge
});
Abbiamo quindi un pattern deliberato:
ISOLATED world
bridge.js
│
│ window.postMessage(...)
▼
MAIN world
content.js / interceptor
E viceversa.
bridge.js?Il nome non è metaforico. È davvero un ponte.
Prendiamo il controllo della commissione.
bridge.js può parlare con il background:
chrome.runtime.sendMessage({
type: "PEGASO_COMMISSION_CHECK_CLAIM",
platformId: PLATFORM_ID,
}, (response) => {
// ...
});
Ma quando deve chiedere al codice che vive nel MAIN world di effettuare un’operazione legata alla pagina, usa:
window.postMessage({
type: COMMISSION_REQUEST,
requestId: leaseId,
expiresAt,
}, "*");
Possiamo rappresentare il flusso così:
sequenceDiagram
participant B as Background
participant Br as bridge.js (ISOLATED)
participant P as Page scripts (MAIN)
Br->>B: PEGASO_COMMISSION_CHECK_CLAIM
B-->>Br: leaseId + expiresAt
Br->>P: window.postMessage(COMMISSION_REQUEST)
P-->>Br: window.postMessage(risultato)
Br->>B: memorizza/rilascia stato
Questo è message passing tra boundary.
Se tutti i componenti vivessero nello stesso scope, potremmo chiamare una funzione direttamente:
requestCommissionData();
Ma qui i runtime sono separati. Il messaggio diventa il contratto.
Il popup deve anche interrogare direttamente la scheda attiva.
In popup.js troviamo:
chrome.tabs.sendMessage(
tabId,
{ type: "PEGASO_COURSE_PROGRESS_STATUS_REQUEST" },
{ frameId: 0 },
(response) => {
if (chrome.runtime.lastError || !response?.status) return;
renderCourseProgress(response.status);
},
);
Qui non stiamo usando window.postMessage.
Perché?
Perché mittente e destinatario non sono due world JavaScript della stessa pagina: il mittente è una extension page (popup) e il destinatario è un content script nella tab.
Quindi abbiamo già almeno due bus di comunicazione differenti:
chrome.runtime / chrome.tabs messaging
→ tra componenti dell'estensione
window.postMessage
→ tra world JavaScript nella stessa pagina
Questa distinzione sarà il cuore del Capitolo 2.
A questo punto possiamo costruire una prima mappa utile dell’architettura.
flowchart TD
U[Utente] --> P[Popup<br/>popup.html + popup.js]
U --> F[Floating menu<br/>ISOLATED world]
P -->|chrome.runtime.sendMessage| B[Background coordinator<br/>background.js]
P -->|chrome.tabs.sendMessage| Br[bridge.js<br/>ISOLATED world]
F --> Br
Br -->|chrome.runtime messaging| B
Br <-->|window.postMessage| M[MAIN world<br/>content.js / commission-interceptor.js]
M --> D[DOM Multiversity]
M --> A[Stato / API osservabili dalla pagina]
B --> S[chrome.storage.local]
Br --> S
P --> S
Non è ancora la mappa completa di tutte le feature, ma è sufficiente per rispondere alla domanda iniziale:
Perché non possiamo mettere tutto in un singolo file?
Perché le responsabilità richiedono privilegi, lifetime e ambienti di esecuzione differenti.
run_at: anche quando contaI content script non sono caricati tutti nello stesso momento.
PlumePilot usa sia:
"run_at": "document_start"
sia:
"run_at": "document_idle"
document_start è utile quando dobbiamo essere presenti molto presto nel ciclo di vita della pagina. document_idle è adatto quando possiamo aspettare che il documento sia sostanzialmente pronto.
Per esempio, commission-interceptor.js viene caricato nel MAIN world a document_start.
Questo suggerisce una regola generale:
Se devi osservare un comportamento della pagina che potrebbe avvenire molto presto, arrivare dopo significa potenzialmente perdere l’evento.
Al contrario, una UI che dipende da elementi già presenti nella pagina può spesso aspettare document_idle.
Quindi il manifest non specifica soltanto dove eseguire il codice. Specifica anche quando inserirlo nella vita del documento.
all_frames: una pagina non è sempre un solo documentoAlcuni gruppi di script usano:
"all_frames": true
Questo ricorda che una tab può contenere iframe e quindi più documenti.
È facile dire “lo script gira nella pagina”, ma tecnicamente la domanda più corretta è:
In quale frame del tab gira?
Nel popup, quando PlumePilot invia alcune richieste alla tab, specifica esplicitamente:
{ frameId: 0 }
cioè il frame principale.
È un dettaglio apparentemente piccolo, ma previene ambiguità quando la stessa estensione può essere caricata in più frame.
web_accessible_resources: asset dell’estensione visibili alla paginaIl manifest espone anche alcune risorse, per esempio immagini della modalità Gaming, font e audio.
Non tutto ciò che è contenuto nel pacchetto di un’estensione deve essere automaticamente raggiungibile da una pagina web.
web_accessible_resources dichiara esplicitamente quali file possono essere richiesti dai contesti corrispondenti ai pattern indicati.
Questo è un altro esempio del principio del least privilege:
esponi ciò che serve, non l’intero pacchetto.
Lo stesso principio tornerà quando parleremo di permissions e host_permissions.
Nello snapshot corrente il manifest dichiara:
"permissions": ["storage"]
oltre a host_permissions per host necessari alla gestione di materiali e risorse.
Le extension API sono capability esplicite. Questo è diverso da una normale pagina web, dove il codice parte già all’interno del modello di sicurezza dell’origine del sito.
Quando progetteremo mentalmente una feature, una buona domanda sarà sempre:
In quale contesto dovrebbe vivere questa funzione e qual è il minimo privilegio necessario?
Questa domanda spesso determina l’architettura prima ancora del codice.
Seguiamo finalmente un piccolo percorso end-to-end.
Il browser crea popup.html e viene eseguito popup.js.
popup.js
↓
chrome.storage.local.get(...)
↓
render della UI
Nel codice troviamo:
chrome.tabs.query({ active: true, currentWindow: true }, ...)
popup
│
│ PEGASO_COURSE_PROGRESS_STATUS_REQUEST
▼
content script della tab
tramite chrome.tabs.sendMessage.
renderCourseProgress(response.status);
Il popup registra anche:
chrome.runtime.onMessage.addListener(...)
per aggiornarsi quando riceve nuovi stati mentre è aperto.
Quindi il popup non “possiede” il progresso. Lo visualizza.
Questo è un ottimo esempio di separazione tra stato e presentazione.
chrome.runtime.onMessage come router internoPuoi pensare ai message.type come a endpoint logici:
PEGASO_START_EXPORT
PEGASO_CANCEL_EXPORT
PEGASO_GET_OPERATION
...
Non sono URL, ma identificano comandi e richieste.
Coordina operazioni, storage e ownership senza conoscere il dettaglio visuale del DOM.
Converte comunicazione e capacità tra due ambienti incompatibili:
Extension APIs ⇄ bridge ⇄ Page runtime
In architettura software chiameremmo spesso un componente simile adapter o gateway.
Lo snapshot corrente contiene file molto grandi, tra cui content.js, floating-menu.js e popup.js.
È facile guardare soltanto la dimensione e concludere che il sistema sia monolitico.
Ma esistono due livelli diversi:
PlumePilot ha margine per una maggiore modularità fisica, tema che affronteremo nell’ultimo capitolo. Ma possiede già confini runtime reali imposti e sfruttati dall’architettura WebExtensions.
È importante non confondere le due cose.
manifest.jsonSenza ancora studiare autoplay, API o EPUB, sappiamo già che PlumePilot:
MAIN sia nell’ISOLATED world;document_start / document_idle);Il manifest è quindi molto più di “metadata per lo store”.
È una mappa dichiarativa dell’architettura runtime.
L’ambiente in cui gira un pezzo di codice: quali globali vede, quali API può usare e quale lifetime possiede.
Il contesto JavaScript condiviso con la pagina web.
Contesto separato usato dai content script, che condivide il DOM ma non lo stesso scope JavaScript della pagina.
Una pagina HTML appartenente all’estensione, come il popup.
Componente dedicato alla gestione di eventi e operazioni che non appartengono a una singola pagina.
Comunicazione tra componenti separati attraverso messaggi serializzabili.
Componente che traduce tra interfacce o ambienti differenti.
Concedere ed esporre soltanto le capacità necessarie.
Prima di leggere il prossimo capitolo, prova a classificare queste operazioni:
themePreference;Per ciascuna chiediti:
Dove dovrebbe vivere?
Popup?
Background?
MAIN world?
ISOLATED world?
Non tutte hanno una sola risposta possibile. Il punto è iniziare a ragionare in termini di confini e responsabilità.
Fonti primarie consigliate:
content_scripts manifest key: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/content_scriptsbackground manifest key: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/backgroundchrome.scripting e execution worlds: https://developer.chrome.com/docs/extensions/reference/api/scriptingQuando leggi documentazione sulle extension, fai attenzione a due assi distinti:
Manifest V2 vs Manifest V3
Chrome vs Firefox/WebExtensions
Un esempio trovato online può essere corretto in un browser e non rappresentare esattamente il runtime dell’altro.
Ora sappiamo chi sono i componenti.
La domanda successiva è:
Come fanno a parlarsi senza trasformare l’estensione in una rete di messaggi impossibile da seguire?
Seguiremo un messaggio reale dalla UI al background, dalla tab al bridge e dal bridge al MAIN world. Vedremo callback, Promise, sendResponse, lifetime asincrona dei listener, window.postMessage e i primi veri problemi di concorrenza.