@braidlabs/angular-router 0.2.0 → 0.4.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.
@@ -1,490 +1,301 @@
1
- import * as i0 from '@angular/core';
2
- import { InjectionToken, signal, Injectable, inject, DestroyRef, DOCUMENT, PLATFORM_ID, computed, makeEnvironmentProviders, provideEnvironmentInitializer } from '@angular/core';
3
- import { isSkewDisabled, createVersionProbe, moduleWasRemoved, setSkewDisabled } from '@braidlabs/skew';
4
- import { isPlatformBrowser } from '@angular/common';
5
- import { Router, NavigationError } from '@angular/router';
6
-
7
- const SKEW_RECOVERY_OPTIONS = new InjectionToken('SKEW_RECOVERY_OPTIONS');
1
+ import * as i0 from "@angular/core";
2
+ import { DOCUMENT, DestroyRef, Injectable, InjectionToken, PLATFORM_ID, computed, inject, makeEnvironmentProviders, provideEnvironmentInitializer, signal } from "@angular/core";
3
+ import { createVersionProbe, isSkewDisabled, moduleWasRemoved, setSkewDisabled } from "@braidlabs/skew";
4
+ import { isPlatformBrowser } from "@angular/common";
5
+ import { NavigationError, Router } from "@angular/router";
6
+ const SKEW_RECOVERY_OPTIONS = new InjectionToken("SKEW_RECOVERY_OPTIONS");
8
7
  function resolveOptions(options) {
9
- return {
10
- ...options,
11
- onStaleChunk: options.onStaleChunk ?? 'reload-at-target',
12
- retryAttempts: options.retryAttempts ?? 1,
13
- retryDelayMs: options.retryDelayMs ?? 250,
14
- maxRecoveries: options.maxRecoveries ?? 1,
15
- fallbackRoute: options.fallbackRoute ?? '/',
16
- respectUnsavedWork: options.respectUnsavedWork ?? true,
17
- };
8
+ return {
9
+ ...options,
10
+ onStaleChunk: options.onStaleChunk ?? "reload-at-target",
11
+ retryAttempts: options.retryAttempts ?? 1,
12
+ retryDelayMs: options.retryDelayMs ?? 250,
13
+ maxRecoveries: options.maxRecoveries ?? 1,
14
+ fallbackRoute: options.fallbackRoute ?? "/",
15
+ respectUnsavedWork: options.respectUnsavedWork ?? true
16
+ };
18
17
  }
19
-
20
- /**
21
- * Wrapping the dynamic import.
22
- *
23
- * Two problems are solved here, both before any Angular machinery is involved:
24
- *
25
- * 1. **Attribution.** By the time an import rejects, the runtime holds
26
- * `chunk-ABC123.js` — not `./admin/routes`. Without a logical id there is no
27
- * way to ask the new build's manifest whether that route still exists.
28
- * 2. **Transience.** A flaky network, a CDN edge miss, and a genuinely purged
29
- * asset are indistinguishable from the error alone. A bounded retry resolves
30
- * the first two without inflicting a page reload on anyone.
31
- *
32
- * Retry is therefore a *precondition* to recovery, not one of the strategies.
33
- */
34
- /** Defaults for `lazy()`, overridable globally by `provideSkewRecovery()`. */
35
18
  const lazyDefaults = {
36
- retryAttempts: 1,
37
- retryDelayMs: 250,
19
+ retryAttempts: 1,
20
+ retryDelayMs: 250
38
21
  };
39
- /** Restores defaults. Exposed for tests, which must not leak configuration. */
40
22
  function resetLazyDefaults() {
41
- lazyDefaults.retryAttempts = 1;
42
- lazyDefaults.retryDelayMs = 250;
43
- }
44
- /**
45
- * Thrown when a lazy load fails after its retries are exhausted.
46
- *
47
- * Angular surfaces this through `NavigationError`, where the recovery service
48
- * recognises it. Carrying the module id across that boundary is the whole
49
- * reason this error type exists.
50
- */
51
- class ChunkLoadFailure extends Error {
52
- moduleId;
53
- cause;
54
- attempts;
55
- name = 'ChunkLoadFailure';
56
- constructor(moduleId, cause, attempts) {
57
- super(`[skew] failed to load ${moduleId ? `"${moduleId}"` : 'a lazy chunk'} ` +
58
- `after ${attempts} attempt${attempts === 1 ? '' : 's'}`);
59
- this.moduleId = moduleId;
60
- this.cause = cause;
61
- this.attempts = attempts;
62
- }
23
+ lazyDefaults.retryAttempts = 1;
24
+ lazyDefaults.retryDelayMs = 250;
63
25
  }
26
+ var ChunkLoadFailure = class extends Error {
27
+ moduleId;
28
+ cause;
29
+ attempts;
30
+ name = "ChunkLoadFailure";
31
+ constructor(moduleId, cause, attempts) {
32
+ super(`[skew] failed to load ${moduleId ? `"${moduleId}"` : "a lazy chunk"} after ${attempts} attempt${attempts === 1 ? "" : "s"}`);
33
+ this.moduleId = moduleId;
34
+ this.cause = cause;
35
+ this.attempts = attempts;
36
+ }
37
+ };
64
38
  function isChunkLoadFailure(value) {
65
- return value instanceof ChunkLoadFailure;
39
+ return value instanceof ChunkLoadFailure;
66
40
  }
67
- /**
68
- * Heuristic for "this looks like a module that failed to load" rather than an
69
- * error thrown *by* the module once it ran.
70
- *
71
- * Deliberately broad: bundlers and browsers disagree on the shape here
72
- * (`ChunkLoadError`, a bare `TypeError`, a CSP violation), and a false positive
73
- * costs one wasted retry while a false negative costs the user their session.
74
- */
75
41
  function looksLikeChunkError(error) {
76
- if (!error)
77
- return false;
78
- const name = error.name ?? '';
79
- const message = error.message ?? '';
80
- return (name === 'ChunkLoadError' ||
81
- /loading (chunk|css chunk)/i.test(message) ||
82
- /failed to fetch dynamically imported module/i.test(message) ||
83
- /error loading dynamically imported module/i.test(message) ||
84
- /importing a module script failed/i.test(message));
42
+ if (!error) return false;
43
+ const name = error.name ?? "";
44
+ const message = error.message ?? "";
45
+ return name === "ChunkLoadError" || /loading (chunk|css chunk)/i.test(message) || /failed to fetch dynamically imported module/i.test(message) || /error loading dynamically imported module/i.test(message) || /importing a module script failed/i.test(message);
85
46
  }
86
47
  function lazy(moduleIdOrLoader, loaderOrOptions, maybeOptions) {
87
- const hasId = typeof moduleIdOrLoader === 'string';
88
- const moduleId = hasId ? moduleIdOrLoader : undefined;
89
- const loader = (hasId ? loaderOrOptions : moduleIdOrLoader);
90
- const options = (hasId ? maybeOptions : loaderOrOptions) ?? {};
91
- if (typeof loader !== 'function') {
92
- throw new TypeError('[skew] lazy() requires a loader function');
93
- }
94
- return async () => {
95
- // Not public API — see `disabled.ts` in @braidlabs/skew. A bare dynamic import
96
- // with no retry and no attribution: the rejection propagates exactly as the
97
- // bundler produced it, and nothing downstream knows which module it was.
98
- if (isSkewDisabled())
99
- return loader();
100
- const attempts = Math.max(0, options.retryAttempts ?? lazyDefaults.retryAttempts) + 1;
101
- const delay = options.retryDelayMs ?? lazyDefaults.retryDelayMs;
102
- let lastError;
103
- for (let attempt = 1; attempt <= attempts; attempt++) {
104
- try {
105
- return await loader();
106
- }
107
- catch (error) {
108
- lastError = error;
109
- // An error thrown by module *evaluation* is a bug in the module, not a
110
- // transport failure. Retrying it just runs the bug again.
111
- if (!looksLikeChunkError(error))
112
- throw error;
113
- if (attempt < attempts && delay > 0) {
114
- await new Promise((resolve) => setTimeout(resolve, delay * attempt));
115
- }
116
- }
117
- }
118
- throw new ChunkLoadFailure(moduleId, lastError, attempts);
119
- };
48
+ const hasId = typeof moduleIdOrLoader === "string";
49
+ const moduleId = hasId ? moduleIdOrLoader : void 0;
50
+ const loader = hasId ? loaderOrOptions : moduleIdOrLoader;
51
+ const options = (hasId ? maybeOptions : loaderOrOptions) ?? {};
52
+ if (typeof loader !== "function") throw new TypeError("[skew] lazy() requires a loader function");
53
+ return async () => {
54
+ if (isSkewDisabled()) return loader();
55
+ const attempts = Math.max(0, options.retryAttempts ?? lazyDefaults.retryAttempts) + 1;
56
+ const delay = options.retryDelayMs ?? lazyDefaults.retryDelayMs;
57
+ let lastError;
58
+ for (let attempt = 1; attempt <= attempts; attempt++) try {
59
+ return await loader();
60
+ } catch (error) {
61
+ lastError = error;
62
+ if (!looksLikeChunkError(error)) throw error;
63
+ if (attempt < attempts && delay > 0) await new Promise((resolve) => setTimeout(resolve, delay * attempt));
64
+ }
65
+ throw new ChunkLoadFailure(moduleId, lastError, attempts);
66
+ };
120
67
  }
121
-
122
- /**
123
- * Unsaved-work tracking.
124
- *
125
- * A hard reload destroys in-memory state, so recovery must know whether that
126
- * matters. Angular already has a declaration for exactly this — `CanDeactivate`
127
- * — but the router exposes no way to ask "would a guard block here?", so a
128
- * library cannot reuse it.
129
- *
130
- * This registry is the workaround: components declare dirtiness explicitly and
131
- * the recovery service consults it. If Angular ever exposes guard
132
- * introspection, this whole file becomes deletable.
133
- */
134
- class UnsavedWorkRegistry {
135
- sources = signal([], /* @ts-ignore */
136
- ...(ngDevMode ? [{ debugName: "sources" }] : /* istanbul ignore next */ []));
137
- /** Registers a predicate. Returns a disposer. */
138
- register(isDirty) {
139
- this.sources.update((current) => [...current, isDirty]);
140
- return () => {
141
- this.sources.update((current) => current.filter((entry) => entry !== isDirty));
142
- };
143
- }
144
- /**
145
- * True when any registered source reports unsaved work.
146
- *
147
- * A throwing predicate is treated as *clean* rather than dirty: a broken
148
- * dirty-check should not permanently block recovery and strand the user.
149
- */
150
- isDirty() {
151
- return this.sources().some((isDirty) => {
152
- try {
153
- return isDirty();
154
- }
155
- catch {
156
- return false;
157
- }
158
- });
159
- }
160
- /** Count of registered sources. Diagnostics only. */
161
- get size() {
162
- return this.sources().length;
163
- }
164
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: UnsavedWorkRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
165
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: UnsavedWorkRegistry, providedIn: 'root' });
166
- }
167
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: UnsavedWorkRegistry, decorators: [{
168
- type: Injectable,
169
- args: [{ providedIn: 'root' }]
170
- }] });
171
- /**
172
- * Declares that this component holds unsaved work, so automatic recovery will
173
- * not discard it. Unregisters automatically on destroy.
174
- *
175
- * ```ts
176
- * export class BulletinEditor {
177
- * protected readonly form = …;
178
- * constructor() {
179
- * trackUnsavedWork(() => this.form.dirty);
180
- * }
181
- * }
182
- * ```
183
- *
184
- * Must be called in an injection context.
185
- */
68
+ var UnsavedWorkRegistry = class UnsavedWorkRegistry {
69
+ sources = signal([], ...ngDevMode ? [{ debugName: "sources" }] : /* istanbul ignore next */ []);
70
+ register(isDirty) {
71
+ this.sources.update((current) => [...current, isDirty]);
72
+ return () => {
73
+ this.sources.update((current) => current.filter((entry) => entry !== isDirty));
74
+ };
75
+ }
76
+ isDirty() {
77
+ return this.sources().some((isDirty) => {
78
+ try {
79
+ return isDirty();
80
+ } catch {
81
+ return false;
82
+ }
83
+ });
84
+ }
85
+ get size() {
86
+ return this.sources().length;
87
+ }
88
+ static ɵfac = i0.ɵɵngDeclareFactory({
89
+ minVersion: "12.0.0",
90
+ version: "22.2.3",
91
+ ngImport: i0,
92
+ type: UnsavedWorkRegistry,
93
+ deps: [],
94
+ target: i0.ɵɵFactoryTarget.Injectable
95
+ });
96
+ static ɵprov = i0.ɵɵngDeclareInjectable({
97
+ minVersion: "12.0.0",
98
+ version: "22.2.3",
99
+ ngImport: i0,
100
+ type: UnsavedWorkRegistry,
101
+ providedIn: "root"
102
+ });
103
+ };
104
+ i0.ɵɵngDeclareClassMetadata({
105
+ minVersion: "12.0.0",
106
+ version: "22.2.3",
107
+ ngImport: i0,
108
+ type: UnsavedWorkRegistry,
109
+ decorators: [{
110
+ type: Injectable,
111
+ args: [{ providedIn: "root" }]
112
+ }]
113
+ });
186
114
  function trackUnsavedWork(isDirty) {
187
- const registry = inject(UnsavedWorkRegistry);
188
- const destroyRef = inject(DestroyRef);
189
- const dispose = registry.register(isDirty);
190
- destroyRef.onDestroy(dispose);
115
+ const registry = inject(UnsavedWorkRegistry);
116
+ const destroyRef = inject(DestroyRef);
117
+ const dispose = registry.register(isDirty);
118
+ destroyRef.onDestroy(dispose);
191
119
  }
192
-
193
- const LOOP_GUARD_KEY = 'skew:recoveries';
194
- /**
195
- * Orchestrates recovery from a failed lazy load.
196
- *
197
- * Wiring is via router events rather than a global registry: `lazy()` throws a
198
- * {@link ChunkLoadFailure}, Angular surfaces it as `NavigationError`, and this
199
- * service recognises it there. That keeps `lazy()` dependency-free and usable
200
- * in route definitions, which are evaluated long before DI exists.
201
- *
202
- * Zoneless-safe and SSR-safe: no `NgZone`, and every `location` /
203
- * `sessionStorage` touch is guarded.
204
- */
205
- class SkewRecoveryService {
206
- options = inject(SKEW_RECOVERY_OPTIONS);
207
- router = inject(Router);
208
- document = inject(DOCUMENT);
209
- unsavedWork = inject(UnsavedWorkRegistry);
210
- isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
211
- probe;
212
- pendingSignal = signal(null, /* @ts-ignore */
213
- ...(ngDevMode ? [{ debugName: "pendingSignal" }] : /* istanbul ignore next */ []));
214
- statusSignal = signal(null, /* @ts-ignore */
215
- ...(ngDevMode ? [{ debugName: "statusSignal" }] : /* istanbul ignore next */ []));
216
- /** A recovery the application has been asked to complete itself. */
217
- pending = this.pendingSignal.asReadonly();
218
- /** Last known comparison against the origin. */
219
- status = this.statusSignal.asReadonly();
220
- /** True when a newer deployment is known to exist. */
221
- updateAvailable = computed(() => this.statusSignal()?.kind === 'staleClient', /* @ts-ignore */
222
- ...(ngDevMode ? [{ debugName: "updateAvailable" }] : /* istanbul ignore next */ []));
223
- constructor() {
224
- this.probe = this.options.manifestUrl
225
- ? createVersionProbe({
226
- identity: this.options.identity,
227
- manifestUrl: this.options.manifestUrl,
228
- })
229
- : null;
230
- if (this.isBrowser)
231
- this.listen();
232
- }
233
- listen() {
234
- this.router.events.subscribe((event) => {
235
- if (event instanceof NavigationError && isChunkLoadFailure(event.error)) {
236
- // Not public API — see `disabled.ts` in @braidlabs/skew. The NavigationError
237
- // is left to propagate untouched: no retry, no manifest probe, no
238
- // classification, no decision. Whatever the application does next is
239
- // whatever it would have done without this package installed.
240
- if (isSkewDisabled())
241
- return;
242
- void this.handle(event.error, event.url);
243
- }
244
- });
245
- }
246
- /** Re-checks the origin. Safe to call from a "check for updates" control. */
247
- async check() {
248
- if (!this.probe)
249
- return null;
250
- const status = await this.probe.check();
251
- this.statusSignal.set(status);
252
- return status;
253
- }
254
- /** Completes a deferred recovery — what a "Reload" button calls. */
255
- recover() {
256
- const pending = this.pendingSignal();
257
- if (!pending)
258
- return;
259
- this.pendingSignal.set(null);
260
- this.assign(pending.targetUrl);
261
- }
262
- /** Dismisses a pending recovery without acting on it. */
263
- dismiss() {
264
- this.pendingSignal.set(null);
265
- }
266
- async handle(failure, targetUrl) {
267
- const context = await this.classify(failure, targetUrl);
268
- const action = await this.chooseAction(context);
269
- this.dispatch(action, context);
270
- }
271
- async classify(failure, targetUrl) {
272
- const status = await this.check();
273
- const serverBuildId = status?.kind === 'current'
274
- ? status.buildId
275
- : status && 'remote' in status
276
- ? status.remote
277
- : undefined;
278
- // Absent a module map we must assume the route still exists. Claiming it
279
- // was deleted would redirect users away from a route that merely moved —
280
- // a worse failure than the one being recovered from.
281
- const manifest = this.probe?.lastManifest() ?? null;
282
- const moduleStillExists = failure.moduleId === undefined ||
283
- manifest === null ||
284
- !moduleWasRemoved(manifest, failure.moduleId);
285
- return {
286
- targetUrl,
287
- currentUrl: this.router.url,
288
- moduleId: failure.moduleId,
289
- error: failure,
290
- attempt: this.recoveryCount(),
291
- isOnline: this.isOnline(),
292
- clientBuildId: this.options.identity.buildId,
293
- serverBuildId,
294
- moduleStillExists,
295
- entryDocumentStale: status?.kind === 'staleOrigin',
296
- newAssetsReachable: status?.kind === 'staleClient',
297
- hasUnsavedWork: this.options.respectUnsavedWork && this.unsavedWork.isDirty(),
298
- };
299
- }
300
- async chooseAction(context) {
301
- // Guard rails run before any application policy, because each of these is a
302
- // case where reloading is actively harmful rather than merely unhelpful.
303
- if (!context.isOnline)
304
- return 'notify';
305
- if (context.entryDocumentStale)
306
- return 'notify'; // reloading would loop
307
- if (context.attempt >= this.options.maxRecoveries)
308
- return 'notify';
309
- if (context.hasUnsavedWork)
310
- return 'notify';
311
- if (!context.moduleStillExists)
312
- return 'redirect-to-fallback';
313
- const strategy = this.options.onStaleChunk;
314
- if (typeof strategy === 'function') {
315
- try {
316
- return await strategy(context);
317
- }
318
- catch {
319
- // A throwing policy must not strand the user with nothing.
320
- return 'notify';
321
- }
322
- }
323
- return strategy;
324
- }
325
- dispatch(action, context) {
326
- switch (action) {
327
- case 'reload-at-target':
328
- this.countRecovery();
329
- this.assign(context.targetUrl);
330
- return;
331
- case 'reload-in-place':
332
- this.countRecovery();
333
- this.reload();
334
- return;
335
- case 'redirect-to-fallback':
336
- void this.router.navigateByUrl(this.options.fallbackRoute);
337
- return;
338
- case 'ignore':
339
- return;
340
- case 'notify':
341
- this.pendingSignal.set({
342
- targetUrl: context.targetUrl,
343
- moduleId: context.moduleId,
344
- serverBuildId: context.serverBuildId,
345
- reason: !context.isOnline
346
- ? 'offline'
347
- : context.entryDocumentStale
348
- ? 'loop-detected'
349
- : context.attempt >= this.options.maxRecoveries
350
- ? 'exhausted'
351
- : 'notify',
352
- context,
353
- });
354
- return;
355
- }
356
- }
357
- // --- environment access, all browser-guarded ---------------------------
358
- isOnline() {
359
- if (!this.isBrowser)
360
- return true;
361
- const nav = this.document.defaultView?.navigator;
362
- return nav?.onLine ?? true;
363
- }
364
- assign(url) {
365
- if (!this.isBrowser)
366
- return;
367
- this.document.defaultView?.location.assign(url);
368
- }
369
- reload() {
370
- if (!this.isBrowser)
371
- return;
372
- this.document.defaultView?.location.reload();
373
- }
374
- /**
375
- * Recoveries already attempted, kept in `sessionStorage` so it survives the
376
- * very reload it is counting. Without this, an origin serving a stale entry
377
- * document turns one failure into an infinite reload loop.
378
- */
379
- recoveryCount() {
380
- if (!this.isBrowser)
381
- return 0;
382
- try {
383
- const raw = this.document.defaultView?.sessionStorage.getItem(LOOP_GUARD_KEY);
384
- const parsed = raw ? JSON.parse(raw) : null;
385
- // Scoped to a build: a genuinely new deployment gets a fresh budget.
386
- return parsed?.buildId === this.options.identity.buildId ? (parsed.count ?? 0) : 0;
387
- }
388
- catch {
389
- return 0;
390
- }
391
- }
392
- countRecovery() {
393
- if (!this.isBrowser)
394
- return;
395
- try {
396
- this.document.defaultView?.sessionStorage.setItem(LOOP_GUARD_KEY, JSON.stringify({ buildId: this.options.identity.buildId, count: this.recoveryCount() + 1 }));
397
- }
398
- catch {
399
- // Private mode. The manifest probe remains as the precise loop guard.
400
- }
401
- }
402
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: SkewRecoveryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
403
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: SkewRecoveryService, providedIn: 'root' });
404
- }
405
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: SkewRecoveryService, decorators: [{
406
- type: Injectable,
407
- args: [{ providedIn: 'root' }]
408
- }], ctorParameters: () => [] });
409
-
410
- /**
411
- * Enables skew recovery.
412
- *
413
- * ```ts
414
- * bootstrapApplication(App, {
415
- * providers: [
416
- * provideRouter(routes),
417
- * provideSkewRecovery({
418
- * identity: BUILD_IDENTITY, // from @braidlabs/build
419
- * manifestUrl: '/skew-manifest.json',
420
- * }),
421
- * ],
422
- * });
423
- * ```
424
- *
425
- * Deliberately *not* a `RouterFeature`: Angular's feature factory is internal,
426
- * so third parties cannot construct one. This composes alongside
427
- * `provideRouter` instead, and works with any router configuration.
428
- *
429
- * The service is instantiated eagerly — it subscribes to router events, and a
430
- * lazily-created recovery service would miss the very first failure.
431
- */
120
+ const LOOP_GUARD_KEY = "skew:recoveries";
121
+ var SkewRecoveryService = class SkewRecoveryService {
122
+ options = inject(SKEW_RECOVERY_OPTIONS);
123
+ router = inject(Router);
124
+ document = inject(DOCUMENT);
125
+ unsavedWork = inject(UnsavedWorkRegistry);
126
+ isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
127
+ probe;
128
+ pendingSignal = signal(null, ...ngDevMode ? [{ debugName: "pendingSignal" }] : /* istanbul ignore next */ []);
129
+ statusSignal = signal(null, ...ngDevMode ? [{ debugName: "statusSignal" }] : /* istanbul ignore next */ []);
130
+ pending = this.pendingSignal.asReadonly();
131
+ status = this.statusSignal.asReadonly();
132
+ updateAvailable = computed(() => this.statusSignal()?.kind === "staleClient", ...ngDevMode ? [{ debugName: "updateAvailable" }] : /* istanbul ignore next */ []);
133
+ constructor() {
134
+ this.probe = this.options.manifestUrl ? createVersionProbe({
135
+ identity: this.options.identity,
136
+ manifestUrl: this.options.manifestUrl
137
+ }) : null;
138
+ if (this.isBrowser) this.listen();
139
+ }
140
+ listen() {
141
+ this.router.events.subscribe((event) => {
142
+ if (event instanceof NavigationError && isChunkLoadFailure(event.error)) {
143
+ if (isSkewDisabled()) return;
144
+ this.handle(event.error, event.url);
145
+ }
146
+ });
147
+ }
148
+ async check() {
149
+ if (!this.probe) return null;
150
+ const status = await this.probe.check();
151
+ this.statusSignal.set(status);
152
+ return status;
153
+ }
154
+ recover() {
155
+ const pending = this.pendingSignal();
156
+ if (!pending) return;
157
+ this.pendingSignal.set(null);
158
+ this.assign(pending.targetUrl);
159
+ }
160
+ dismiss() {
161
+ this.pendingSignal.set(null);
162
+ }
163
+ async handle(failure, targetUrl) {
164
+ const context = await this.classify(failure, targetUrl);
165
+ const action = await this.chooseAction(context);
166
+ this.dispatch(action, context);
167
+ }
168
+ async classify(failure, targetUrl) {
169
+ const status = await this.check();
170
+ const serverBuildId = status?.kind === "current" ? status.buildId : status && "remote" in status ? status.remote : void 0;
171
+ const manifest = this.probe?.lastManifest() ?? null;
172
+ const moduleStillExists = failure.moduleId === void 0 || manifest === null || !moduleWasRemoved(manifest, failure.moduleId);
173
+ return {
174
+ targetUrl,
175
+ currentUrl: this.router.url,
176
+ moduleId: failure.moduleId,
177
+ error: failure,
178
+ attempt: this.recoveryCount(),
179
+ isOnline: this.isOnline(),
180
+ clientBuildId: this.options.identity.buildId,
181
+ serverBuildId,
182
+ moduleStillExists,
183
+ entryDocumentStale: status?.kind === "staleOrigin",
184
+ newAssetsReachable: status?.kind === "staleClient",
185
+ hasUnsavedWork: this.options.respectUnsavedWork && this.unsavedWork.isDirty()
186
+ };
187
+ }
188
+ async chooseAction(context) {
189
+ if (!context.isOnline) return "notify";
190
+ if (context.entryDocumentStale) return "notify";
191
+ if (context.attempt >= this.options.maxRecoveries) return "notify";
192
+ if (context.hasUnsavedWork) return "notify";
193
+ if (!context.moduleStillExists) return "redirect-to-fallback";
194
+ const strategy = this.options.onStaleChunk;
195
+ if (typeof strategy === "function") try {
196
+ return await strategy(context);
197
+ } catch {
198
+ return "notify";
199
+ }
200
+ return strategy;
201
+ }
202
+ dispatch(action, context) {
203
+ switch (action) {
204
+ case "reload-at-target":
205
+ this.countRecovery();
206
+ this.assign(context.targetUrl);
207
+ return;
208
+ case "reload-in-place":
209
+ this.countRecovery();
210
+ this.reload();
211
+ return;
212
+ case "redirect-to-fallback":
213
+ this.router.navigateByUrl(this.options.fallbackRoute);
214
+ return;
215
+ case "ignore": return;
216
+ case "notify":
217
+ this.pendingSignal.set({
218
+ targetUrl: context.targetUrl,
219
+ moduleId: context.moduleId,
220
+ serverBuildId: context.serverBuildId,
221
+ reason: !context.isOnline ? "offline" : context.entryDocumentStale ? "loop-detected" : context.attempt >= this.options.maxRecoveries ? "exhausted" : "notify",
222
+ context
223
+ });
224
+ return;
225
+ }
226
+ }
227
+ isOnline() {
228
+ if (!this.isBrowser) return true;
229
+ return (this.document.defaultView?.navigator)?.onLine ?? true;
230
+ }
231
+ assign(url) {
232
+ if (!this.isBrowser) return;
233
+ this.document.defaultView?.location.assign(url);
234
+ }
235
+ reload() {
236
+ if (!this.isBrowser) return;
237
+ this.document.defaultView?.location.reload();
238
+ }
239
+ recoveryCount() {
240
+ if (!this.isBrowser) return 0;
241
+ try {
242
+ const raw = this.document.defaultView?.sessionStorage.getItem(LOOP_GUARD_KEY);
243
+ const parsed = raw ? JSON.parse(raw) : null;
244
+ return parsed?.buildId === this.options.identity.buildId ? parsed.count ?? 0 : 0;
245
+ } catch {
246
+ return 0;
247
+ }
248
+ }
249
+ countRecovery() {
250
+ if (!this.isBrowser) return;
251
+ try {
252
+ this.document.defaultView?.sessionStorage.setItem(LOOP_GUARD_KEY, JSON.stringify({
253
+ buildId: this.options.identity.buildId,
254
+ count: this.recoveryCount() + 1
255
+ }));
256
+ } catch {}
257
+ }
258
+ static ɵfac = i0.ɵɵngDeclareFactory({
259
+ minVersion: "12.0.0",
260
+ version: "22.2.3",
261
+ ngImport: i0,
262
+ type: SkewRecoveryService,
263
+ deps: [],
264
+ target: i0.ɵɵFactoryTarget.Injectable
265
+ });
266
+ static ɵprov = i0.ɵɵngDeclareInjectable({
267
+ minVersion: "12.0.0",
268
+ version: "22.2.3",
269
+ ngImport: i0,
270
+ type: SkewRecoveryService,
271
+ providedIn: "root"
272
+ });
273
+ };
274
+ i0.ɵɵngDeclareClassMetadata({
275
+ minVersion: "12.0.0",
276
+ version: "22.2.3",
277
+ ngImport: i0,
278
+ type: SkewRecoveryService,
279
+ decorators: [{
280
+ type: Injectable,
281
+ args: [{ providedIn: "root" }]
282
+ }],
283
+ ctorParameters: () => []
284
+ });
432
285
  function provideSkewRecovery(options) {
433
- const resolved = resolveOptions(options);
434
- return makeEnvironmentProviders([
435
- { provide: SKEW_RECOVERY_OPTIONS, useValue: resolved },
436
- provideEnvironmentInitializer(() => {
437
- // Route definitions are evaluated before DI exists, so `lazy()` cannot
438
- // inject its retry policy. Publishing the resolved values here keeps a
439
- // single source of configuration without making `lazy()` DI-aware.
440
- lazyDefaults.retryAttempts = resolved.retryAttempts;
441
- lazyDefaults.retryDelayMs = resolved.retryDelayMs;
442
- inject(SkewRecoveryService);
443
- }),
444
- ]);
286
+ const resolved = resolveOptions(options);
287
+ return makeEnvironmentProviders([{
288
+ provide: SKEW_RECOVERY_OPTIONS,
289
+ useValue: resolved
290
+ }, provideEnvironmentInitializer(() => {
291
+ lazyDefaults.retryAttempts = resolved.retryAttempts;
292
+ lazyDefaults.retryDelayMs = resolved.retryDelayMs;
293
+ inject(SkewRecoveryService);
294
+ })]);
445
295
  }
446
-
447
- /**
448
- * ⚠️ NOT PUBLIC API. Undocumented on purpose. Do not use in an application.
449
- *
450
- * Angular-shaped wrapper around `setSkewDisabled()` from `@braidlabs/skew`. Read the
451
- * module comment in `@braidlabs/skew`'s `disabled.ts` first — it explains what this
452
- * turns off, why it exists, and why it is deliberately absent from the README,
453
- * the package docs, and every example.
454
- *
455
- * ```ts
456
- * // demo apps only
457
- * providers: [provideSkewRecovery({ … }), provideSkewDisabled()]
458
- * ```
459
- *
460
- * Two things worth knowing before using it anyway:
461
- *
462
- * **It is not scoped to this injector.** The flag lives in a module-level
463
- * variable in `@braidlabs/skew`, because core is framework-agnostic and has no
464
- * injector to read from. Calling this affects every `@braidlabs` package sharing
465
- * that instance of core — including any other Angular application on the page.
466
- * It is `provide*`-shaped for familiarity, not because it is scoped.
467
- *
468
- * **It applies at bootstrap.** For a control that flips at runtime — which is
469
- * what a before/after demo actually wants — call `setSkewDisabled()` directly.
470
- * Every check reads the flag at the moment it runs, so a later toggle takes
471
- * effect on the next read, navigation or write with no reload.
472
- *
473
- * This lives in `@braidlabs/angular-router` for want of a shared Angular package;
474
- * it belongs in `@braidlabs/angular-core` once that lands, since it governs all of
475
- * them and nothing here is router-specific.
476
- *
477
- * @internal
478
- */
479
296
  function provideSkewDisabled(disabled = true) {
480
- return makeEnvironmentProviders([
481
- provideEnvironmentInitializer(() => setSkewDisabled(disabled)),
482
- ]);
297
+ return makeEnvironmentProviders([provideEnvironmentInitializer(() => setSkewDisabled(disabled))]);
483
298
  }
484
-
485
- /**
486
- * Generated bundle index. Do not edit.
487
- */
488
-
489
299
  export { ChunkLoadFailure, SKEW_RECOVERY_OPTIONS, SkewRecoveryService, UnsavedWorkRegistry, isChunkLoadFailure, lazy, lazyDefaults, looksLikeChunkError, provideSkewDisabled, provideSkewRecovery, resetLazyDefaults, resolveOptions, trackUnsavedWork };
490
- //# sourceMappingURL=braidlabs-angular-router.mjs.map
300
+
301
+ //# sourceMappingURL=braidlabs-angular-router.mjs.map