@ultimat3/pwa 10.0.0 → 11.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -26,7 +26,7 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
26
26
  | Capabilities | gate the manifest member **and** the SW block, where the capability has one. Disabled → zero bytes. `push`, `backgroundSync` and `badging` emit worker code; `shareTarget`, `fileHandlers` and `protocolHandlers` are **manifest-only** — the OS delivers to a route the app already serves — and their `CAPABILITY_SW_MARKERS` list is empty, which `service-worker.test.ts` checks in both directions: every declared marker is in the worker when its capability is on, none is when they are all off. `shareTarget` named `/_x/share-target` there while no block emitted it. |
27
27
  | `AppUpdateAvailable` | declares exactly the fields the emitted `activate` block posts, and no more. `version-skew.test.ts` reads the literal back out of the generated source and compares it to a fixture typed `Required<AppUpdateAvailable>` — a field added to the interface stops compiling, a field added to the worker fails the assertion. It declared five and posted two until 9.0.0; the three extra described a forced reload nothing performed. |
28
28
  | Forced reload | **not a capability of this package.** `updateSignal`/`updatePolicy` computed `forced`/`deadlineAt` with no runtime caller and were deleted in 9.0.0. The two runtimes holding both build ids are BELOW this one — `http`'s `ctx.clientBuildId` (tier 2), `sync`'s `update-available` frame (tier 3) — so neither could ever have called into tier 4 to act on one. The app renders its own affordance; the framework never navigates a client. |
29
- | Outbox | queue lives in `@ultimat3/realtime`. This package only registers the sync trigger. |
29
+ | Outbox | queue lives in `@ultimat3/realtime`. This package only registers the sync trigger — and schedules **no retry**: the handler rejects and the PLATFORM decides when to wake it again. `RetryPolicy`, `DEFAULT_RETRY`, `retryDelayMs`, `shouldRetry` and `BackgroundSyncOptions.retry` were deleted 2026-08-23; of the policy only `maxAttempts` ever reached the worker, as a `SYNC_MAX_ATTEMPTS` constant nothing read, and the emitted `X_PWA_SYNC_INCOMPLETE` fix told the reader to raise `pwa.backgroundSync.retry.maxAttempts`, a key `PwaConfig` has never had. `background-sync.test.ts` asserts that every constant the worker declares is one the worker reads. |
30
30
  | Push strings | i18n keys only, rendered per subscriber locale. Never a literal. |
31
31
  | Push URLs | `PushPayload.url` is a PATH and `WindowClient.url` is absolute, so `notificationclick` resolves against `self.location.origin` before comparing. Unresolved, the focus-existing-tab loop matched nothing and every tap opened a second window. |
32
32
  | Colours | token values passed in via `PwaConfig.tokens`. Never a hex literal in this package — a test fixture asserting the parser is the one exception. |
package/README.md CHANGED
@@ -93,7 +93,7 @@ capability nothing implements.
93
93
  | `planIcons`, `requireSourceIcon`, `maskableSafeZone` | icons and splashes from one source |
94
94
  | `BuiltinImagePipeline` | renders that plan: one square PNG per entry, deterministic |
95
95
  | `requireOfflineFallback` | the mandatory offline route |
96
- | `backgroundSyncSource`, `retryDelayMs` | the Background Sync trigger |
96
+ | `backgroundSyncSource`, `registerBackgroundSyncSource` | the Background Sync trigger. No retry policy: the handler rejects and the PLATFORM reschedules it |
97
97
  | `renderPushPayload`, `pushSource`, `subscribeSource` | Web Push, per-locale bodies |
98
98
  | `createInstallController`, `iosInstallGuidance` | install prompt, never on first paint |
99
99
  | `PwaStrategyExhaustedError` and the other `errors.ts` classes | the codes this package throws, catchable by an app |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/pwa",
3
- "version": "10.0.0",
3
+ "version": "11.1.0",
4
4
  "description": "Generated service worker, web manifest, icons, push and version-skew handling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "10.0.0",
35
- "@ultimat3/seo": "10.0.0"
34
+ "@ultimat3/core": "11.1.0",
35
+ "@ultimat3/seo": "11.1.0"
36
36
  }
37
37
  }
@@ -20,36 +20,19 @@ import { BUILD_ID_HEADER } from './version-skew';
20
20
 
21
21
  export const SYNC_TAG = 'x-outbox';
22
22
 
23
- export interface RetryPolicy {
24
- readonly maxAttempts: number;
25
- readonly baseDelayMs: number;
26
- readonly maxDelayMs: number;
27
- }
28
-
29
- export const DEFAULT_RETRY: RetryPolicy = Object.freeze({
30
- maxAttempts: 6,
31
- baseDelayMs: 1_000,
32
- maxDelayMs: 5 * 60 * 1000,
33
- });
34
-
35
23
  /**
36
- * Deterministic exponential backoff — no jitter here on purpose: the Background Sync
37
- * scheduler already spreads wake-ups across clients, and a deterministic delay is
38
- * testable and reproducible in a bug report.
24
+ * `retry` was removed 2026-08-23, with `RetryPolicy`, `DEFAULT_RETRY`, `retryDelayMs` and
25
+ * `shouldRetry`: **this package schedules no retry and never did.** The one-shot `sync` handler
26
+ * rejects, and the PLATFORM decides when to wake it again — `flushOutbox` counts no attempts and
27
+ * has nowhere to apply a delay. Of the policy only `maxAttempts` ever reached the worker, as a
28
+ * `SYNC_MAX_ATTEMPTS` constant nothing read; the two exported functions were called by their own
29
+ * test and by nothing else. Same rule as `PwaConfig.installPrompt` and `JobsConfig.driver`
30
+ * (`packages/core/src/config.ts`): a knob that produces neither a build error nor a runtime effect
31
+ * is worse than no knob, because an author sets it, ships, and nothing changes.
39
32
  */
40
- export function retryDelayMs(attempt: number, policy: RetryPolicy = DEFAULT_RETRY): number {
41
- const clamped = Math.max(1, Math.min(attempt, policy.maxAttempts));
42
- return Math.min(policy.baseDelayMs * 2 ** (clamped - 1), policy.maxDelayMs);
43
- }
44
-
45
- export function shouldRetry(attempt: number, policy: RetryPolicy = DEFAULT_RETRY): boolean {
46
- return attempt < policy.maxAttempts;
47
- }
48
-
49
33
  export interface BackgroundSyncOptions {
50
34
  /** Endpoint `@ultimat3/realtime` exposes to flush the outbox. */
51
35
  readonly flushEndpoint?: string;
52
- readonly retry?: RetryPolicy;
53
36
  }
54
37
 
55
38
  export const DEFAULT_FLUSH_ENDPOINT = '/_x/outbox/flush';
@@ -86,11 +69,9 @@ class PwaSyncError extends Error{
86
69
  */
87
70
  export function backgroundSyncSource(options: BackgroundSyncOptions = {}): string {
88
71
  const endpoint = options.flushEndpoint ?? DEFAULT_FLUSH_ENDPOINT;
89
- const retry = options.retry ?? DEFAULT_RETRY;
90
72
  return `
91
73
  const SYNC_TAG=${JSON.stringify(SYNC_TAG)};
92
74
  const FLUSH_ENDPOINT=${JSON.stringify(endpoint)};
93
- const SYNC_MAX_ATTEMPTS=${retry.maxAttempts};
94
75
  ${SYNC_ERROR_CLASS}
95
76
  async function flushOutbox(){
96
77
  const res=await fetch(FLUSH_ENDPOINT,{method:'POST',headers:{${JSON.stringify(BUILD_ID_HEADER)}:BUILD_ID}});
@@ -98,7 +79,7 @@ async function flushOutbox(){
98
79
  // ||{} rather than a default inside the catch: json() on a 200 body of null RESOLVES with null,
99
80
  // so the catch never fires and body.remaining raised inside waitUntil instead of refusing coded.
100
81
  const body=(await res.json().catch(()=>null))||{};
101
- if(body.remaining>0)throw new PwaSyncError(${JSON.stringify(PwaSyncIncompleteError.code)},'outbox flush at '+FLUSH_ENDPOINT+' left '+body.remaining+' mutation(s) queued','x dev --role sync # drain the outbox, or raise pwa.backgroundSync.retry.maxAttempts in app.config.ts');
82
+ if(body.remaining>0)throw new PwaSyncError(${JSON.stringify(PwaSyncIncompleteError.code)},'outbox flush at '+FLUSH_ENDPOINT+' left '+body.remaining+' mutation(s) queued','x dev --role sync # run the role that drains the outbox; the browser reschedules this sync on its own');
102
83
  }
103
84
  self.addEventListener('sync',(event)=>{
104
85
  if(event.tag!==SYNC_TAG)return;
package/src/index.ts CHANGED
@@ -9,15 +9,12 @@ export type { OfflineStrategy, RenderMode } from '@ultimat3/core';
9
9
  // Moved to `@ultimat3/core` (one formatter, `b`/`kb`/`mb`/`gb`); still named here because a service
10
10
  // worker's size report is what a caller of this package prints.
11
11
  export { formatBytes } from '@ultimat3/core';
12
- export type { BackgroundSyncOptions, RetryPolicy } from './background-sync';
12
+ export type { BackgroundSyncOptions } from './background-sync';
13
13
  export {
14
14
  backgroundSyncSource,
15
15
  DEFAULT_FLUSH_ENDPOINT,
16
- DEFAULT_RETRY,
17
16
  registerBackgroundSyncSource,
18
- retryDelayMs,
19
17
  SYNC_TAG,
20
- shouldRetry,
21
18
  } from './background-sync';
22
19
  export type { Capability, CapabilityFlags, ResolvedCapabilities } from './capabilities';
23
20
  export {
package/src/precache.ts CHANGED
@@ -99,7 +99,12 @@ export function buildPrecacheManifest(input: PrecacheInput): PrecacheManifest {
99
99
  add({ url: asset.url, revision: asset.revision, bytes: asset.bytes, reason: 'asset' });
100
100
  }
101
101
 
102
- const sorted = [...entries.values()].sort((a, b) => a.url.localeCompare(b.url));
102
+ // CODE UNITS, never `localeCompare`: these entries are emitted into `sw.js`, whose header
103
+ // promises byte-identical output for identical input, and `localeCompare` with no locale
104
+ // argument answers from the runtime's ICU default and collation version — two machines, two
105
+ // orders, one no-op deploy that fires the SW update check. Same rule as `service-worker.ts`'s
106
+ // rule tie-break and `@ultimat3/jobs`' `job.ts`.
107
+ const sorted = [...entries.values()].sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
103
108
  const totalBytes = sorted.reduce((sum, entry) => sum + entry.bytes, 0);
104
109
  const warnBytes = input.warnBytes ?? DEFAULT_PRECACHE_WARN_BYTES;
105
110
 
@@ -123,8 +123,14 @@ const hasWildcard = (path: string): boolean =>
123
123
  * install and then never looked up, and a route the app declared cacheable served `network-only`,
124
124
  * which offline is the `/offline` document.
125
125
  *
126
- * The path stays as the tie-break, so the emitted file is still byte-identical for identical input.
126
+ * The path stays as the tie-break, so the emitted file is still byte-identical for identical input
127
+ * — compared by CODE UNIT, never `localeCompare`, which answers from the runtime's ICU default and
128
+ * collation version: `/Posts` sorted before `/posts` on one machine and after it on the next, for
129
+ * the same route table. The rule `@ultimat3/jobs`' `job.ts` states for `x.manifest.json`, applied
130
+ * to the artifact this file emits.
127
131
  */
132
+ const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
133
+
128
134
  export function routeRules(routes: readonly PwaRoute[]): readonly RouteRule[] {
129
135
  return [...routes]
130
136
  .filter((route) => route.surface !== 'api')
@@ -132,7 +138,7 @@ export function routeRules(routes: readonly PwaRoute[]): readonly RouteRule[] {
132
138
  (a, b) =>
133
139
  Number(hasWildcard(a.path)) - Number(hasWildcard(b.path)) ||
134
140
  specificityOf(b.path) - specificityOf(a.path) ||
135
- a.path.localeCompare(b.path),
141
+ byCodeUnit(a.path, b.path),
136
142
  )
137
143
  .map((route) => {
138
144
  const strategy = strategyFor(route);