diff --git a/packages/core/src/service-worker/ServiceWorkerClient.ts b/packages/core/src/service-worker/ServiceWorkerClient.ts index ef52327..44fa290 100644 --- a/packages/core/src/service-worker/ServiceWorkerClient.ts +++ b/packages/core/src/service-worker/ServiceWorkerClient.ts @@ -60,6 +60,7 @@ export class ServiceWorkerClient { config: { channelName: this.channelName, dbName: this.dbName, + deliveryUrl: this.config.deliveryUrl, }, }); diff --git a/packages/core/src/service-worker/tabmesh-sw.ts b/packages/core/src/service-worker/tabmesh-sw.ts index c454c1f..cc086fb 100644 --- a/packages/core/src/service-worker/tabmesh-sw.ts +++ b/packages/core/src/service-worker/tabmesh-sw.ts @@ -136,6 +136,11 @@ async function drainPendingEvents(): Promise { // Sort by priority (desc), then createdAt (asc) pending.sort((a, b) => b.priority - a.priority || a.createdAt - b.createdAt); + // Without a delivery endpoint the SW has nothing to send to. Leave + // pending entries in the outbox so the next Hub session picks them up; + // we still GC TTL-expired entries below. + const canDeliver = Boolean(config.deliveryUrl); + for (const entry of pending) { // Filter expired events if (entry.expiresAt !== undefined && entry.expiresAt <= now) { @@ -143,31 +148,27 @@ async function drainPendingEvents(): Promise { continue; } - // Attempt delivery via fetch if deliveryUrl is configured - if (config.deliveryUrl) { - try { - const response = await fetch(config.deliveryUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: entry.type, - payload: entry.payload, - id: entry.id, - sourceTabId: entry.sourceTabId, - }), - }); - - if (response.ok) { - deliveredIds.push(entry.id); - } - // Non-OK response: leave as pending for next sync - } catch { - // Network failure: leave as pending for next sync + if (!canDeliver) continue; + + // Attempt delivery via fetch. + try { + const response = await fetch(config.deliveryUrl as string, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: entry.type, + payload: entry.payload, + id: entry.id, + sourceTabId: entry.sourceTabId, + }), + }); + + if (response.ok) { + deliveredIds.push(entry.id); } - } else { - // No delivery URL: mark as delivered (events will be picked up - // by the next Hub that starts) - deliveredIds.push(entry.id); + // Non-OK response: leave as pending for next sync + } catch { + // Network failure: leave as pending for next sync } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b7702d8..5f7019b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -251,6 +251,14 @@ export interface ServiceWorkerConfig { enabled: boolean; /** Service Worker script URL. @default '/tabmesh-sw.js' */ scriptUrl: string; + /** + * HTTP endpoint the Service Worker POSTs pending events to during + * Background Sync. Required for the SW handoff to actually deliver + * events. When omitted, the SW leaves pending events in the outbox + * for the next Hub session to drain — it does NOT silently mark them + * delivered. + */ + deliveryUrl?: string; } /** diff --git a/packages/core/tests/ServiceWorkerClient.test.ts b/packages/core/tests/ServiceWorkerClient.test.ts index 0736587..77cc3bc 100644 --- a/packages/core/tests/ServiceWorkerClient.test.ts +++ b/packages/core/tests/ServiceWorkerClient.test.ts @@ -63,6 +63,25 @@ describe('ServiceWorkerClient', () => { config: { channelName: 'test-app', dbName: 'tabmesh:test-app', + deliveryUrl: undefined, + }, + }); + }); + + it('forwards deliveryUrl to the service worker config', async () => { + const client = new ServiceWorkerClient('test-app', { + enabled: true, + deliveryUrl: 'https://api.example.com/events', + }); + + await client.register(); + + expect(mockRegistration.active?.postMessage).toHaveBeenCalledWith({ + kind: 'tabmesh-sw-config', + config: { + channelName: 'test-app', + dbName: 'tabmesh:test-app', + deliveryUrl: 'https://api.example.com/events', }, }); });