Assistente indipendente per Pegaso, Mercatorum e San Raffaele
Nel capitolo precedente abbiamo visto che PlumePilot non vive in un unico ambiente JavaScript.
Il popup, il background, i content script nel mondo isolato, gli script nel MAIN world e le pagine builder sono componenti distinti. Possono appartenere alla stessa estensione, ma non condividono automaticamente memoria, scope globale o ciclo di vita.
Questo cambia radicalmente il modo in cui dobbiamo ragionare sul programma.
In una normale funzione possiamo fare questo:
const result = calculateSomething(input);
Il chiamante conosce direttamente la funzione, le passa un valore e riceve il risultato.
Tra due contesti dell’estensione, invece, spesso la situazione assomiglia di più a questa:
componente A
│
│ { type: "DO_SOMETHING", ... }
▼
confine
│
▼
componente B
│
│ { accepted: true, ... }
▼
componente A
Il componente A non chiama direttamente una funzione privata di B. Invia invece un messaggio che descrive ciò che vuole ottenere.
Questa è la base del message passing.
Ed è uno dei concetti più importanti per comprendere PlumePilot.
Quando due pezzi di codice vivono nello stesso scope, il contratto può essere semplicemente la firma di una funzione:
startExport(format, tabId);
Quando attraversiamo un boundary, il contratto diventa invece un oggetto serializzabile:
{
type: "PEGASO_START_EXPORT",
format: "materials",
sourceTabId: tabId,
requestSource: "toolbar-popup"
}
Questo oggetto non contiene comportamento. Contiene dati che descrivono una richiesta.
Il campo più importante è spesso type.
Puoi pensarlo come a una route:
POST /exports/start
nel backend, oppure come a un nome di evento:
PEGASO_START_EXPORT
nel sistema di messaging.
Il destinatario guarda quel valore e decide quale handler eseguire.
In background.js accade esattamente questo:
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);
// ...
});
Riferimento reale: background.js, dispatcher dei messaggi.
Il background si comporta quindi come un piccolo dispatcher:
message.type
│
├── PEGASO_START_EXPORT ─────► startExport()
├── PEGASO_CANCEL_EXPORT ────► cancelExport()
├── PEGASO_GET_OPERATION ────► reconcileOperationOwner()
└── ...
Quando attraversiamo un boundary non stiamo soltanto “chiamando una funzione in un altro posto”.
Stiamo definendo un protocollo.
Anche se il protocollo è interno all’applicazione.
PlumePilot usa soprattutto tre meccanismi di comunicazione:
1. chrome.runtime.sendMessage()
2. chrome.tabs.sendMessage()
3. window.postMessage()
Sembrano simili, ma risolvono problemi differenti.
chrome.runtime.sendMessage()Invia un messaggio ad altri contesti dell’estensione, tipicamente al background.
Esempio dal popup:
chrome.runtime.sendMessage(message, (response) => {
// usa la risposta
});
È appropriato quando la destinazione appartiene al runtime dell’estensione, non quando vogliamo indirizzare direttamente un content script in una specifica scheda.
chrome.tabs.sendMessage()Invia un messaggio ai content script presenti in una scheda precisa.
PlumePilot lo usa, per esempio, per comunicare con il corso aperto dall’utente:
chrome.tabs.sendMessage(
tabId,
{ type: "PEGASO_COURSE_PROGRESS_STATUS_REQUEST" },
{ frameId: 0 },
callback,
);
Qui compare già una seconda informazione importante: la destinazione non è soltanto l’estensione, ma una specifica tab e persino uno specifico frame.
window.postMessage()È invece una API Web standard, non specifica delle estensioni.
In PlumePilot viene usata soprattutto per attraversare il confine tra:
ISOLATED world ◄────► MAIN world
perché entrambi i mondi osservano lo stesso window, pur non condividendo lo stesso scope JavaScript.
Per esempio bridge.js può fare:
window.postMessage(
{ type: "PEGASO_COURSE_PROGRESS_STATUS_REQUEST" },
"*",
);
mentre content.js ascolta:
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (!event.data) return;
if (event.data.type === "PEGASO_COURSE_PROGRESS_STATUS_REQUEST") {
// ...
}
});
La stessa pagina diventa quindi una specie di bus locale di messaggi.
Prima di seguire un caso reale, fissiamo una regola pratica.
ESTENSIONE
popup / builder background
│ ▲
│ chrome.runtime.sendMessage │
└────────────────────────────────────┘
background / popup
│
│ chrome.tabs.sendMessage(tabId)
▼
bridge.js / content script isolato
│
│ window.postMessage
▼
content.js / MAIN world
│
▼
pagina Multiversity
Naturalmente la comunicazione può anche risalire nella direzione opposta.
Il punto fondamentale è questo:
Il tipo di messaggio dipende dal boundary che dobbiamo attraversare.
Questa è una domanda utile ogni volta che leggi PlumePilot:
“Da quale contesto sto partendo, e quale contesto devo raggiungere?”
Spesso è sufficiente per capire perché sia stata usata una API invece di un’altra.
Cominciamo da una richiesta relativamente semplice.
Quando il popup viene aperto, vuole mostrare il progresso del corso corrente.
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);
},
);
Riferimento: popup.js L1392–L1399.
Il popup conosce l’ID della tab attiva e manda quindi un messaggio al content script della pagina.
popup.js
│
│ chrome.tabs.sendMessage(tabId, ...)
▼
bridge.js
Perché arriva a bridge.js?
Perché bridge.js è registrato come content script nel mondo isolato e ascolta chrome.runtime.onMessage.
Il suo handler contiene:
if (message?.type === "PEGASO_COURSE_PROGRESS_STATUS_REQUEST") {
window.postMessage(
{ type: "PEGASO_COURSE_PROGRESS_STATUS_REQUEST" },
"*",
);
sendResponse({
accepted: true,
status: courseProgressStatus,
});
return;
}
Riferimento: bridge.js L368–L371.
Qui accadono due cose diverse.
Il bridge:
MAIN world di aggiornare lo stato.Questa distinzione è molto interessante.
Potremmo immaginare una soluzione ingenua:
popup
↓
calcola da zero il progresso
↓
attendi
↓
rispondi
PlumePilot adotta invece un modello più reattivo:
popup
│
├──► riceve subito l'ultimo snapshot noto
│
└──► provoca un refresh nel MAIN world
│
▼
nuovo stato calcolato
│
▼
aggiornamento push
Il courseProgressStatus custodito dal bridge è quindi una snapshot cache.
Può essere abbastanza recente da mostrare subito qualcosa, mentre il sistema aggiorna lo stato reale in parallelo.
Il popup non deve rimanere bloccato aspettando che ogni calcolo sia terminato.
Per un breve intervallo due componenti possono non possedere esattamente la stessa informazione.
Poi gli eventi li riallineano.
Non significa necessariamente che il sistema sia “sbagliato”.
Significa che la consistenza non è ottenuta tramite memoria condivisa e sincrona, ma tramite propagazione dello stato.
Questo modello è comunissimo nei sistemi distribuiti, nelle UI reattive e nelle applicazioni event-driven.
PlumePilot ce ne offre una versione molto piccola e osservabile.
Il bridge ha fatto:
window.postMessage(
{ type: "PEGASO_COURSE_PROGRESS_STATUS_REQUEST" },
"*",
);
content.js, che gira nel MAIN world, riceve il messaggio:
if (event.data.type === "PEGASO_COURSE_PROGRESS_STATUS_REQUEST") {
scheduleCourseProgressInitialization(0);
publishCourseProgressStatus();
return;
}
Riferimento: content.js L423–L426.
A questo punto siamo passati da:
WebExtension API world
al codice che vive insieme alla pagina.
Il MAIN world calcola il proprio stato e lo pubblica nuovamente:
window.postMessage({
type: "PEGASO_COURSE_PROGRESS_STATUS",
status: {
courseCode: state.courseCode,
available: percent !== null && state.chapterCount > 0,
percent,
chapterCount: state.chapterCount,
knownChapters: courseProgressChapters.size,
exact: exactAvailable,
// ...
},
}, "*");
Riferimento: content.js L4478–L4500.
Questa volta la direzione è opposta:
MAIN world
│
│ window.postMessage
▼
bridge.js
Il bridge riceve il nuovo stato e non lo inoltra ciecamente.
Prima lo normalizza:
courseProgressStatus = courseCode ? {
platformId: PLATFORM_ID,
courseCode,
available: source.available === true && Number.isFinite(percent),
percent: Number.isFinite(percent)
? Math.max(0, Math.min(100, Math.floor(percent)))
: null,
chapterCount: Math.max(0, Math.floor(Number(source.chapterCount) || 0)),
knownChapters: Math.max(0, Math.floor(Number(source.knownChapters) || 0)),
exact: source.exact === true,
updatedByStudyWing: source.updatedByStudyWing === true,
message: safeText(source.message, 160) || "",
} : null;
Poi lo rimanda nel runtime dell’estensione:
chrome.runtime.sendMessage({
type: "PEGASO_COURSE_PROGRESS_STATUS",
status: courseProgressStatus,
});
Riferimento: bridge.js L513–L534.
Questo ci mostra perché il nome bridge è appropriato.
Non è soltanto un cavo.
È anche un adapter.
MAIN world payload
│
▼
validazione / normalizzazione
│
▼
extension runtime payload
Notiamo alcune difese:
Number.isFinite(percent)
Math.max(...)
Math.min(...)
safeText(...)
Il bridge decide quale forma dei dati è accettabile prima di portarli nell’altro contesto.
Ogni boundary è un buon posto in cui:
È lo stesso motivo per cui in un backend potresti avere DTO, serializer o adapter tra layer.
popup.js non si limita alla risposta iniziale.
Ascolta anche aggiornamenti successivi:
chrome.runtime.onMessage.addListener((message, sender) => {
if (
message?.type === "PEGASO_COURSE_PROGRESS_STATUS" &&
sender?.tab?.id === activeCourseTabId
) {
renderCourseProgress(message.status || null);
}
});
Riferimento: popup.js L1402–L1409.
Qui compare un’altra difesa importante:
sender?.tab?.id === activeCourseTabId
Il popup potrebbe ricevere eventi provenienti da più tab.
Non vuole mostrare nel corso A il progresso ricevuto dalla tab B.
Quindi non basta chiedersi:
“Che tipo di messaggio è?”
Bisogna anche chiedersi:
“Chi lo ha inviato?”
Possiamo finalmente rappresentarlo per intero:
sequenceDiagram
participant P as popup.js
participant B as bridge.js (ISOLATED)
participant C as content.js (MAIN)
P->>B: tabs.sendMessage(PROGRESS_STATUS_REQUEST)
B-->>P: sendResponse(snapshot nota)
B->>C: window.postMessage(PROGRESS_STATUS_REQUEST)
C->>C: aggiorna/calcola stato
C->>B: window.postMessage(PROGRESS_STATUS)
B->>B: valida e normalizza
B->>P: runtime.sendMessage(PROGRESS_STATUS)
P->>P: renderCourseProgress()
Questo è un pattern molto più ricco di una semplice richiesta sincrona.
Contiene:
Ed è ancora uno dei flussi relativamente semplici di PlumePilot.
sendResponse() e il problema del tempoConsideriamo ora il listener nel background:
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
let action = null;
// ... selezione dell'azione ...
action
.then(sendResponse)
.catch((error) => {
sendResponse({ accepted: false, reason: error.message });
});
return true;
});
Perché quel return true?
Per capirlo dobbiamo distinguere due casi.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
sendResponse({ accepted: true });
});
La risposta viene inviata prima che il listener termini.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
doSomethingAsync().then((result) => {
sendResponse(result);
});
return true;
});
Il listener termina prima che la Promise completi.
return true segnala al runtime che il canale di risposta deve restare disponibile perché sendResponse() verrà chiamato successivamente.
Questo dettaglio è facile da ignorare e può generare bug apparentemente inspiegabili:
messaggio inviato
↓
handler avviato
↓
operazione async partita
↓
handler termina
↓
canale chiuso ← se non viene mantenuto
↓
Promise completa
↓
sendResponse(...) ← troppo tardi
Con return true:
handler termina
↓
canale mantenuto
↓
Promise completa
↓
sendResponse(...)
La documentazione WebExtensions descrive esplicitamente questo pattern.
È vagamente simile alla differenza tra:
return response;
e un handler che deve aspettare una query o un servizio asincrono prima di poter completare la risposta.
La differenza è che qui il meccanismo di risposta è gestito dal runtime dell’estensione.
Nel popup troviamo:
function runtimeMessage(message) {
return new Promise((resolve) =>
chrome.runtime.sendMessage(message, (response) =>
resolve(
chrome.runtime.lastError
? { accepted: false, reason: chrome.runtime.lastError.message }
: response,
),
),
);
}
Riferimento: popup.js L1296–L1306.
Il chiamante può quindi scrivere:
const response = await runtimeMessage({
type: "PEGASO_START_EXPORT",
// ...
});
invece di annidare callback.
Questa funzione svolge due lavori:
chrome.runtime.lastError in un normale valore applicativo.Il secondo punto è importante.
Invece di obbligare ogni chiamante a ricordare:
if (chrome.runtime.lastError) {
// ...
}
l’errore viene trasformato in una risposta coerente:
{
accepted: false,
reason: "..."
}
Possiamo pensare a runtimeMessage() come a:
API del browser
(callback + runtime.lastError)
│
▼
runtimeMessage()
│
▼
API interna PlumePilot
(Promise + accepted/reason)
Ancora una volta compare lo stesso principio:
I boundary sono posti naturali in cui tradurre un’interfaccia in un’altra.
Il flusso del progresso era corto.
L’export delle dispense è molto più interessante perché l’operazione:
Questa ultima proprietà è essenziale.
Se l’operazione appartenesse al popup, chiudere il popup potrebbe distruggere lo stato del processo.
Invece il popup è soltanto uno dei client del workflow.
Quando l’utente avvia la raccolta delle dispense:
const response = await runtimeMessage({
type: "PEGASO_START_EXPORT",
format: "materials",
sourceTabId: tabId,
requestSource: "toolbar-popup",
});
Riferimento: popup.js L2112–L2133.
Questo messaggio è semanticamente un command.
Sta dicendo:
“Per favore, prova ad avviare questa operazione.”
Non sta semplicemente annunciando che qualcosa è successo.
Questa distinzione sarà utile per leggere meglio i nomi dei messaggi.
Esprime un’intenzione:
START_EXPORT
CANCEL_EXPORT
FIND_FIRST_INCOMPLETE
Descrive qualcosa che è già avvenuto:
COURSE_PROGRESS_STATUS
COURSE_MATERIALS_COLLECTED
EXPORT_COLLECTION_FAILED
Il codice non applica formalmente una convenzione CQRS o event sourcing, ma la distinzione concettuale è già presente.
Il dispatcher del background instrada il comando verso:
startExport(...)
All’interno troviamo:
const result = await acquire(format, message.sourceTabId);
if (!result.accepted) return result;
Riferimento: background.js L700–L719.
Il background non ordina immediatamente alla pagina di raccogliere materiale.
Prima acquisisce il diritto di eseguire l’operazione.
Questo impedisce che workflow incompatibili partano contemporaneamente.
Ne parleremo molto meglio nei capitoli su stato, concorrenza e race condition; per ora basta notare che il background è il punto centrale adatto a decidere:
"C'è già qualcosa in esecuzione?"
Una singola tab non avrebbe necessariamente una visione globale sufficiente.
operationIdDopo l’acquisizione, l’operazione possiede un identificatore:
result.operation.id
Il background lo invia alla tab:
const response = await sendTabMessage(message.sourceTabId, {
type: "PEGASO_COLLECT_COURSE_MATERIALS",
format,
operationId: result.operation.id,
});
L’operationId è uno dei concetti architetturali più utili dell’intero flusso.
Senza di esso avremmo messaggi del tipo:
"raccolta iniziata"
"25%"
"raccolta completata"
Ma a quale raccolta appartengono?
Con l’identificatore possiamo invece avere:
operationId = 9f2...
START 9f2...
STATUS 9f2...
STATUS 9f2...
COMPLETE 9f2...
Questo è un correlation ID.
Serve a collegare messaggi diversi allo stesso workflow logico.
È un pattern molto comune nei sistemi a messaggi, nelle code, nelle pipeline asincrone e nel tracing distribuito.
La funzione helper è:
function sendTabMessage(tabId, message) {
return new Promise((resolve, reject) =>
chrome.tabs.sendMessage(
tabId,
message,
{ frameId: 0 },
(response) =>
chrome.runtime.lastError
? reject(new Error(chrome.runtime.lastError.message))
: resolve(response),
),
);
}
Riferimento: background.js L677–L683.
Qui compaiono due coordinate:
tabId
frameId: 0
Il messaggio non è un broadcast generico al browser.
È indirizzato al top frame di una scheda precisa.
Questa è un’altra somiglianza con i sistemi distribuiti: oltre al contenuto del messaggio serve spesso una forma di addressing.
Il messaggio arriva a bridge.js:
if (message?.type === "PEGASO_COLLECT_COURSE_MATERIALS") {
const operationId = exportOperationId(message.operationId);
if (!operationId) {
sendResponse({
accepted: false,
reason: "Identificativo operazione mancante.",
});
return;
}
if (requestedExportOperationIds.has(operationId)) {
sendResponse({ accepted: true, duplicate: true });
return;
}
rememberExportOperation(requestedExportOperationIds, operationId);
window.postMessage({
type: "PEGASO_COLLECT_COURSE_MATERIALS_REQUEST",
format: message.format,
operationId,
}, "*");
sendResponse({ accepted: true, duplicate: false });
}
Riferimento: bridge.js L373–L390.
Qui vediamo una misura di robustezza molto concreta:
requestedExportOperationIds.has(operationId)
Se per qualche motivo lo stesso comando viene recapitato nuovamente, il bridge evita di avviare una seconda raccolta identica.
Questa tecnica si chiama deduplicazione.
È strettamente collegata all’idempotenza.
Un’operazione idempotente produce naturalmente lo stesso effetto anche se ripetuta.
Per esempio:
SET theme = dark
SET theme = dark
SET theme = dark```
ha lo stesso risultato di una sola chiamata.
Una raccolta di dispense, invece, è un'operazione costosa e non vogliamo necessariamente eseguirla tre volte.
Quindi PlumePilot usa un'altra strategia:
```text
"Ho già visto questo operationId?"
│
sì ─┴─► ignora duplicato
Non rende magicamente ogni effetto idempotente, ma avvicina il workflow a un comportamento at-most-once per quell’identificatore, entro la memoria e la lifetime del componente che conserva il set.
Questa precisazione è importante: non dobbiamo attribuire al codice garanzie più forti di quelle che possiede realmente.
Il bridge inoltra la richiesta tramite window.postMessage().
content.js la riceve:
if (event.data.type === "PEGASO_COLLECT_COURSE_MATERIALS_REQUEST") {
const operationId = /* normalizzazione */;
if (!operationId) return;
if (handledExportOperationIds.has(operationId)) {
return;
}
rememberHandledExportOperation(operationId);
void collectCourseMaterials(format, operationId).finally(() => {
// log conclusivo
});
return;
}
Riferimento: content.js L287–L311.
Notiamo che la deduplicazione compare anche qui.
Perché duplicarla in due layer?
Perché i due boundary hanno responsabilità differenti.
Il bridge protegge il passaggio:
extension runtime → MAIN world
content.js protegge invece il vero side effect:
messaggio MAIN → collectCourseMaterials()
È un esempio di defence in depth applicata all’affidabilità, non soltanto alla sicurezza.
void collectCourseMaterials(...): perché non viene fatto await?Questa riga è interessante:
void collectCourseMaterials(format, operationId).finally(...);
L’handler del messaggio non aspetta che l’intera raccolta termini.
La raccolta può durare molto.
Il messaggio iniziale ha già svolto il suo compito:
far partire il workflow.
Da quel momento gli aggiornamenti viaggeranno come eventi separati.
Questa è una distinzione fondamentale:
request/response breve
≠
workflow asincrono lungo
Tentare di tenere aperta una singola risposta per tutta la durata della raccolta renderebbe il sistema più fragile e accoppiato.
PlumePilot usa invece:
COMMAND: avvia
EVENT: stato aggiornato
EVENT: stato aggiornato
EVENT: completato
Il modello assomiglia a quello che in un backend potresti usare per un job asincrono:
POST /exports
→ 202 Accepted + jobId
GET /jobs/:id
oppure eventi push
Durante la raccolta content.js pubblica aggiornamenti:
window.postMessage(
{
type: "PEGASO_EXPORT_COLLECTION_STATUS",
operationId,
message,
},
"*",
);
Riferimento: content.js L2388–L2401.
Il bridge li riceve e li converte in messaggi per il background:
chrome.runtime.sendMessage({
type: "PEGASO_UPDATE_OPERATION",
operationId,
patch: { message: event.data.message },
});
Riferimento: bridge.js L584–L588.
Il background aggiorna quindi lo stato centrale dell’operazione.
Possiamo leggere il flusso come:
content.js
│
│ EVENT status
▼
bridge.js
│
│ command/update
▼
background
│
▼
stato operazione
Il popup può essere chiuso.
La raccolta continua comunque.
Quando il popup verrà riaperto, potrà ricostruire la UI a partire dallo stato dell’operazione.
Questo è un risultato architetturale importante:
La UI non possiede il job. Osserva il job.
Quando i dati sono pronti, il MAIN world pubblica un evento del tipo:
PEGASO_COURSE_MATERIALS_COLLECTED
Il bridge controlla nuovamente l’operationId e utilizza un secondo set di deduplicazione:
if (terminalExportOperationIds.has(operationId)) {
return;
}
rememberExportOperation(
terminalExportOperationIds,
operationId,
);
Poi invia al background:
chrome.runtime.sendMessage({
type: "PEGASO_OPEN_EXPORT_BUILDER",
format: event.data.format,
operationId,
payload: event.data.payload,
});
Riferimento: bridge.js L550–L570.
Perché un secondo concetto di “già visto”?
Perché un evento terminale è diverso dal comando iniziale.
Vogliamo evitare, ad esempio, che due notifiche di completamento aprano due builder.
Quindi il workflow possiede almeno due momenti da proteggere:
START → non partire due volte
COMPLETE → non finalizzare due volte
Anche il background effettua una propria verifica:
if (
operation.phase === "building" ||
operation.jobKey ||
Number.isInteger(operation.builderTabId)
) {
return {
accepted: true,
duplicate: true,
operation,
};
}
Riferimento: background.js L784–L850.
Poi genera un nuovo identificatore:
const jobId = crypto.randomUUID();
salva il payload nello storage:
const storageKey = `pegasoExportJob:${jobId}`;
await storageSet({
[storageKey]: {
...message.payload,
format,
operationId: operation.id,
courseTabId: operation.sourceTabId,
},
});
ed apre una nuova pagina dell’estensione:
chrome.tabs.create({
url: chrome.runtime.getURL(`${builderPage}?job=${jobId}`),
});
Questa parte è molto elegante dal punto di vista dei boundary.
Il payload potenzialmente grande non viene infilato nell’URL.
L’URL contiene soltanto un riferimento:
?job=<jobId>
La pagina builder recupererà il contenuto dallo storage.
Questo assomiglia al pattern:
identificatore piccolo → lookup di stato grande
che incontriamo continuamente lato backend.
Mettiamo tutto insieme.
sequenceDiagram
participant P as popup.js
participant BG as background.js
participant BR as bridge.js
participant C as content.js (MAIN)
participant S as storage
participant BU as materials-builder
P->>BG: runtime.sendMessage(START_EXPORT)
BG->>BG: acquire() + operationId
BG->>BR: tabs.sendMessage(COLLECT_MATERIALS, operationId)
BR->>BR: deduplica command
BR->>C: window.postMessage(COLLECT_REQUEST)
BR-->>BG: accepted
BG-->>P: operation accepted
C->>C: collectCourseMaterials()
C->>BR: EXPORT_COLLECTION_STATUS
BR->>BG: UPDATE_OPERATION
C->>BR: COURSE_MATERIALS_COLLECTED
BR->>BR: deduplica terminal event
BR->>BG: OPEN_EXPORT_BUILDER
BG->>BG: verifica duplicate finalization
BG->>S: salva payload con jobId
BG->>BU: apre builder?job=...
BU->>S: legge job
Guardandolo così, è evidente che non stiamo più parlando di “uno script che clicca dei bottoni”.
Stiamo osservando un piccolo sistema event-driven.
accepted non significa completedMolte risposte di PlumePilot hanno questa forma:
{
accepted: true
}
È importante interpretarla correttamente.
Per esempio, quando il bridge risponde:
sendResponse({ accepted: true, duplicate: false });
non sta dicendo:
“Le dispense sono pronte.”
Sta dicendo:
“Ho accettato la richiesta e il workflow può proseguire.”
Questa distinzione è cruciale nei sistemi asincroni.
accepted ≠ completed
Un backend potrebbe esprimerla tramite HTTP 202 Accepted.
PlumePilot la esprime tramite il proprio protocollo applicativo.
Per studiare il progetto può essere utile classificare mentalmente i messaggi in tre gruppi.
Chiedono informazioni.
PEGASO_GET_OPERATION
PEGASO_COURSE_PROGRESS_STATUS_REQUEST
PEGASO_CHAPTER_LIMIT_STATUS_REQUEST
Chiedono di fare qualcosa.
PEGASO_START_EXPORT
PEGASO_CANCEL_EXPORT
PEGASO_FIND_FIRST_INCOMPLETE_COMMAND
Comunicano qualcosa che è successo.
PEGASO_COURSE_PROGRESS_STATUS
PEGASO_COURSE_MATERIALS_COLLECTED
PEGASO_EXPORT_COLLECTION_FAILED
Il codice non impone formalmente questa tassonomia, ma usarla durante la lettura aiuta moltissimo.
Quando incontri un nuovo message.type, chiediti:
È una query?
È un command?
È un event?
Quasi sempre capirai subito chi dovrebbe produrlo e chi dovrebbe consumarlo.
Non tutti i messaggi richiedono una risposta.
chrome.runtime.sendMessage(message, (response) => {
// la risposta è importante
});
chrome.runtime.sendMessage({
type: "PEGASO_UPDATE_OPERATION",
operationId,
patch: { message },
});
Qui il mittente comunica un aggiornamento ma non ha necessariamente bisogno di bloccare il proprio flusso in attesa di una risposta.
Questo riduce l’accoppiamento, ma cambia il modo in cui dobbiamo gestire gli errori.
Se nessuno attende una conferma, il sistema deve essere progettato in modo che un fallimento non lasci uno stato ambiguo o invisibile.
Non esiste una scelta universalmente migliore.
La domanda corretta è:
Il mittente ha bisogno di sapere che il destinatario ha completato il lavoro, oppure gli basta aver pubblicato l’informazione?
PlumePilot distingue implicitamente due famiglie di errore.
Per esempio:
chrome.runtime.lastError
Può significare che il destinatario non esiste più, la tab è stata chiusa, il content script non è disponibile, ecc.
Il messaggio è arrivato correttamente, ma l’azione viene rifiutata:
{
accepted: false,
reason: "Scheda del corso non disponibile."
}
Questa distinzione è molto simile a:
network / transport error
≠
HTTP 4xx / domain error
in un sistema web.
Entrambi possono impedire l’operazione, ma hanno cause e strategie di recupero differenti.
runtime.lastError viene controllato spessoConsidera questo pattern:
chrome.tabs.sendMessage(tabId, message, (response) => {
if (chrome.runtime.lastError) {
// gestione errore
return;
}
// usa response
});
Le API callback-style di Chrome comunicano diversi errori asincroni tramite chrome.runtime.lastError.
Se ignorassimo questo valore potremmo interpretare:
response === undefined
come una risposta applicativa valida, quando in realtà il messaggio non è mai arrivato al destinatario.
La wrapper runtimeMessage() che abbiamo visto prima serve proprio a trasformare questa peculiarità dell’API browser in un modello più facile da usare nel resto dell’applicazione.
Quando passiamo un oggetto attraverso il sistema di messaging non stiamo condividendo lo stesso riferimento JavaScript.
Concettualmente dobbiamo trattare il messaggio come dati serializzabili.
Questo significa che è una cattiva idea immaginare di poter inviare direttamente:
{
callback: () => doSomething(),
domNode: document.querySelector("video")
}
come se il destinatario condividesse lo stesso heap.
Un messaggio dovrebbe contenere dati come:
{
type: "PEGASO_UPDATE_OPERATION",
operationId: "...",
patch: {
phase: "building",
message: "Preparazione in corso…"
}
}
Questa limitazione, apparentemente scomoda, ha anche un vantaggio:
Ci costringe a rendere espliciti i contratti tra componenti.
window.postMessage() e il trust boundarywindow non significa condividere fiduciacontent.js e bridge.js comunicano con:
window.postMessage(...)
Gli handler spesso iniziano con:
if (event.source !== window || !event.data) return;
Questo controllo assicura che il messaggio osservato provenga dallo stesso oggetto window, non per esempio da un’altra finestra che possiede un riferimento.
Ma è importante non interpretarlo come autenticazione crittografica.
Il codice JavaScript della stessa pagina può usare anch’esso window.postMessage().
Per questo il boundary MAIN/ISOLATED va trattato come un posto in cui:
PlumePilot lo fa in diversi punti attraverso funzioni come:
safeText(...)
exportOperationId(...)
Number.isFinite(...)
Array.isArray(...)
Un bridge dovrebbe essere stretto.
Meglio:
5 messaggi espliciti e validati
che:
"esegui arbitrariamente quello che mi manda l'altro lato"
Questo principio vale per browser extension, iframe, worker, plugin system e API backend.
"*" come targetOrigin qui non equivale a “mandare il dato su Internet”Una riga come:
window.postMessage(message, "*");
può sembrare allarmante se la leggiamo senza contesto.
"*" significa che il mittente non limita il target origin del destinatario del messaggio.
Non significa però che il browser trasmetta automaticamente quel payload a qualunque sito su Internet.
Nel nostro caso il target è lo stesso window della pagina.
Resta comunque buona pratica progettare i payload come se il canale fosse osservabile dal codice che vive nella pagina e quindi non usarlo come contenitore di segreti.
Quando una applicazione usa postMessage tra finestre di origini differenti, la verifica di origin diventa ancora più importante.
Supponiamo di avere:
A: START_EXPORT
B: CANCEL_EXPORT
Entrambi sono validi.
Ma cosa succede se arrivano quasi contemporaneamente?
Oppure:
A: STATUS 40%
B: COMPLETE
C: STATUS 50% ← arrivato in ritardo
Il problema non è il contenuto di un singolo messaggio.
È l’ordine temporale.
Qui nasce la concorrenza.
Ed è uno dei motivi per cui PlumePilot possiede:
operationId;Nel capitolo dedicato all’asincronia studieremo meglio le race condition.
Per ora basta una regola:
In un sistema a messaggi, il tempo e l’ordine diventano parte del problema.
Guarda questo controllo nel bridge:
if (terminalExportOperationIds.has(operationId)) {
return;
}
Perché separare gli eventi terminali?
Perché una volta che un workflow è terminato, eventi duplicati o tardivi non dovrebbero normalmente riaprirlo.
Possiamo pensare al ciclo di vita così:
idle
↓
collecting
↓
building
↓
completed / released
Un messaggio ricevuto fuori fase potrebbe essere valido dal punto di vista sintattico ma non valido dal punto di vista temporale.
Questa è già una piccola anticipazione delle state machine.
C’è una riga in content.js particolarmente istruttiva:
// bridge.js may have sent its startup state before this document-idle
// listener existed. Request an explicit synchronization after subscribing.
window.postMessage({ type: "PEGASO_AUTONEXT_STATE_REQUEST" }, "*");
Riferimento: content.js L557–L559.
Questa piccola soluzione racconta una classe intera di bug asincroni.
Ricordiamo dal manifest:
bridge.js → document_start
content.js → document_idle
Quindi può verificarsi:
bridge caricato
↓
invia stato
↓
content.js non ha ancora registrato il listener
↓
messaggio perso
Un postMessage non è automaticamente una coda persistente.
Se nessun listener utile è presente in quel momento, il messaggio non verrà recuperato magicamente più tardi.
La soluzione è semplice e robusta:
1. content.js registra il listener
2. content.js chiede esplicitamente lo stato corrente
3. bridge.js lo ripubblica
Questo pattern si chiama spesso handshake o explicit synchronization.
Gli eventi sono ottimi per dire:
“qualcosa è cambiato”
ma un nuovo subscriber ha spesso bisogno anche di poter chiedere:
“qual è lo stato adesso?”
Per questo molti sistemi reali combinano:
snapshot + events
invece di affidarsi esclusivamente a uno dei due.
È esattamente ciò che abbiamo visto anche nel progresso del corso.
Il modello compare più volte:
QUERY
↓
stato corrente
EVENT
↓
aggiornamenti futuri
È una combinazione potente perché risolve due esigenze diverse.
Serve quando un componente nasce o viene riaperto.
"Dimmi dove siamo adesso."
Servono dopo la sincronizzazione iniziale.
"Avvisami quando cambia qualcosa."
Da backend developer puoi confrontarlo con:
GET /resource
+
WebSocket / SSE per gli update
oppure con:
query iniziale
+
subscription
Il meccanismo tecnico è diverso, ma il problema architetturale è lo stesso.
Quando incontri un message.type nel repository, puoi ricostruirne il percorso in quattro passi.
Cerca:
{ type: "NOME_MESSAGGIO" }
Domanda:
chi lo invia?
Cerca:
message?.type === "NOME_MESSAGGIO"
o:
event.data.type === "NOME_MESSAGGIO"
Domanda:
chi lo riceve?
Guarda se il produttore usa:
runtime.sendMessage
tabs.sendMessage
window.postMessage
Questo ti dice quale boundary viene attraversato.
Per esempio:
REQUEST → STATUS
START → UPDATE → COMPLETE
A questo punto hai ricostruito il protocollo.
Questo metodo è utile non soltanto per PlumePilot.
È un modo generale per comprendere sistemi event-driven che non conosci.
| Da | A | Meccanismo tipico | Esempio |
|---|---|---|---|
| Popup | Background | runtime.sendMessage |
avvio export |
| Builder | Background | runtime.sendMessage |
update/release operazione |
| Background | Content script | tabs.sendMessage |
raccogli dispense |
| Popup | Content script | tabs.sendMessage |
richiedi progresso |
| ISOLATED | MAIN | window.postMessage |
comando di raccolta |
| MAIN | ISOLATED | window.postMessage |
stato/completamento |
| Bridge | Popup/extension runtime | runtime.sendMessage |
progresso aggiornato |
Questa tabella è quasi una mappa delle dipendenze runtime di PlumePilot.
Potremmo immaginare di costruire una astrazione unica:
bus.send("START_EXPORT", payload);
che nasconda completamente se sotto usa:
runtime.sendMessage
tabs.sendMessage
postMessage
Il codice chiamante sarebbe più uniforme.
Rischieremmo di nascondere informazioni architetturali importanti:
Dove sta andando il messaggio?
Quale boundary attraversa?
Quale runtime lo riceve?
Nel caso di PlumePilot, mantenere una certa visibilità del meccanismo sottostante è spesso utile proprio perché i contesti hanno capacità diverse.
Una astrazione migliore potrebbe quindi non essere:
"nascondi ogni differenza"
ma:
"centralizza error handling e contratti mantenendo esplicita la destinazione"
È una distinzione architetturale importante.
Attualmente molti nomi dei messaggi sono stringhe distribuite nel codice:
"PEGASO_START_EXPORT"
"PEGASO_UPDATE_OPERATION"
"PEGASO_COURSE_PROGRESS_STATUS"
Una possibile evoluzione architetturale sarebbe centralizzarli:
const Messages = Object.freeze({
START_EXPORT: "PEGASO_START_EXPORT",
UPDATE_OPERATION: "PEGASO_UPDATE_OPERATION",
COURSE_PROGRESS_STATUS: "PEGASO_COURSE_PROGRESS_STATUS",});
oppure, in TypeScript, definire anche i payload associati.
Non significa che l’implementazione attuale sia sbagliata.
È il tipo di trade-off che discuteremo nell’ultimo capitolo: cosa rifaremmo diversamente dopo aver visto crescere il progetto.
In un singolo pacchetto di estensione tutti i componenti vengono normalmente aggiornati insieme.
Per questo PlumePilot non ha bisogno di qualcosa come:
protocolVersion: 3
in ogni messaggio.
Ma immagina un sistema in cui mittente e destinatario possano essere aggiornati indipendentemente.
A quel punto il contratto di messaging diventerebbe una vera API da versionare.
Questa osservazione ci aiuta a capire una cosa:
La complessità necessaria dipende anche dal modo in cui i componenti vengono distribuiti.
PlumePilot può permettersi un protocollo interno relativamente leggero perché i componenti appartengono alla stessa release.
Molti concetti di questo capitolo hanno equivalenti familiari lato server.
| Browser extension | Analogia backend/distribuita |
|---|---|
message.type |
route / event name / command name |
runtime.onMessage |
dispatcher / controller |
sendResponse() |
response handler |
operationId |
job ID / correlation ID |
accepted: true |
richiesta accettata / 202 Accepted |
runtime.lastError |
transport/runtime failure |
accepted: false |
domain/application failure |
window.postMessage |
message bus locale / IPC-like boundary |
tabs.sendMessage(tabId) |
invio verso una istanza indirizzata |
| snapshot + events | query iniziale + subscription |
| deduplicazione | duplicate suppression / idempotency key |
Le analogie non sono equivalenze perfette.
Un browser non è un cluster distribuito e postMessage non è Kafka.
Ma i problemi logici sono della stessa famiglia:
Per questo studiare bene l’architettura di un’estensione è sorprendentemente utile anche fuori dal browser.
Immagina di dover implementare queste funzioni.
Quale useresti?
runtime.sendMessage
Perché il destinatario è il coordinatore dell’estensione.
tabs.sendMessage(37, ...)
Perché dobbiamo indirizzare un content script specifico.
content.js nel MAIN world vuole chiedere al bridge di leggere una preferenza dallo storagewindow.postMessage
per attraversare MAIN → ISOLATED; sarà poi il bridge a usare le WebExtension API appropriate.
Considera:
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
fetchSomething().then((result) => {
sendResponse(result);
});
});
Cosa manca nel modello callback-style usato da PlumePilot?
La risposta:
return true;
per mantenere disponibile il canale di risposta asincrono.
Classifica:
PEGASO_GET_OPERATION
→ query.
PEGASO_CANCEL_EXPORT
→ command.
PEGASO_EXPORT_COLLECTION_FAILED
→ event.
Questa classificazione non è richiesta dal browser.
Serve a noi per ragionare meglio sul sistema.
operationId?Immagina due export quasi contemporanei:
Export A
Export B
poi ricevi:
"raccolta completata"
Senza un correlation ID non sai a quale dei due appartiene.
Con:
operationId: A
il problema diventa deterministico.
Supponiamo:
bridge.js: document_start
content.js: document_idle
Il bridge pubblica lo stato una sola volta appena parte.
Perché il content.js potrebbe non riceverlo?
Perché il listener potrebbe non essere ancora stato registrato.
Qual è una soluzione?
subscribe → request current snapshot
che è proprio ciò che fa PEGASO_AUTONEXT_STATE_REQUEST.
Comunicare attraverso messaggi invece di condividere direttamente funzioni e memoria.
L’insieme delle forme, dei nomi e delle aspettative che permettono a mittente e destinatario di capirsi.
In PlumePilot il protocollo è composto in gran parte da oggetti con type e payload associati.
Componente che riceve messaggi e li instrada verso l’handler corretto.
Il blocco runtime.onMessage del background ne è un esempio.
Messaggio che esprime l’intenzione di eseguire un’azione.
Messaggio che descrive un fatto già avvenuto.
Messaggio che richiede informazioni senza voler rappresentare un cambiamento di stato come scopo principale.
Identificatore che permette di collegare più messaggi allo stesso workflow.
operationId è il nostro esempio principale.
Riconoscere e ignorare la ripetizione di una richiesta o di un evento già processato.
Situazione in cui componenti differenti possono avere temporaneamente snapshot non identici, ma convergono tramite aggiornamenti successivi.
Invio di un messaggio senza aspettare una risposta applicativa prima di continuare.
Interazione in cui il mittente ha bisogno di una risposta correlata alla richiesta.
Procedura con cui un componente appena inizializzato chiede esplicitamente lo stato corrente per evitare di dipendere da eventi pubblicati prima che fosse pronto ad ascoltarli.
Validazione e normalizzazione dei dati nel punto in cui attraversano un confine tra componenti o trust domain.
https://developer.chrome.com/docs/extensions/develop/concepts/messaging
Da leggere per:
runtime.sendMessage();tabs.sendMessage();https://developer.chrome.com/docs/extensions/reference/api/tabs
Utile soprattutto per capire perché tabs.sendMessage() possiede un tabId e può essere indirizzato a uno specifico frame/document.
runtime.sendMessage()https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/sendMessage
runtime.onMessagehttps://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onMessage
Particolarmente importante per comprendere:
sendResponse();return true;Window.postMessage()https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage
Da leggere insieme alla sezione sulla sicurezza e sul controllo dell’origine quando si comunica tra finestre o origini differenti.
https://developer.chrome.com/docs/extensions/reference/api/scripting#type-ExecutionWorld
Serve a ricollegare questo capitolo alla distinzione MAIN / ISOLATED studiata nel Capitolo 1.
A prima vista questo capitolo parlava di API come:
sendMessage()
postMessage()
onMessage
Ma il punto più importante è più generale.
PlumePilot ci mostra che quando un’applicazione viene suddivisa in contesti con lifetime e privilegi differenti, la comunicazione diventa parte dell’architettura.
Non possiamo più ragionare soltanto in termini di:
funzione A chiama funzione B
Dobbiamo ragionare in termini di:
chi produce il messaggio?
chi lo riceve?
attraverso quale boundary?
la consegna richiede risposta?
la risposta può arrivare più tardi?
come correlare gli aggiornamenti?
cosa accade se il messaggio arriva due volte?
cosa accade se arriva fuori ordine?
cosa accade se il subscriber non esiste ancora?
Queste domande ci porteranno naturalmente ai prossimi argomenti.
Nel Capitolo 3 usciremo dall’infrastruttura dell’estensione e guarderemo il territorio più instabile in cui PlumePilot deve lavorare:
il DOM di una applicazione web che non controlliamo.
Vedremo perché leggere un elemento HTML è facile, mentre costruire automazioni affidabili sopra una UI dinamica è tutta un’altra storia.
← 01 — Anatomia di PlumePilot · Indice · 03 — DOM e pagine dinamiche →