@braidlabs/angular-router 0.1.1
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/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# `@braidlabs/angular-router`
|
|
2
|
+
|
|
3
|
+
Chunk recovery for Angular applications.
|
|
4
|
+
|
|
5
|
+
When a lazy chunk fails to load, it can mean a flaky network, a CDN edge miss, an offline device, a deleted route, or an origin serving a stale entry document. This library provides a recovery primitive that correctly classifies the failure and takes the right action without bricking the user's tab.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
Enable skew recovery in your application config:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { provideSkewRecovery } from '@braidlabs/angular-router';
|
|
13
|
+
// Generate BUILD_IDENTITY with @braidlabs/build
|
|
14
|
+
import { BUILD_IDENTITY } from './generated/build-id';
|
|
15
|
+
|
|
16
|
+
bootstrapApplication(App, {
|
|
17
|
+
providers: [
|
|
18
|
+
provideRouter(routes),
|
|
19
|
+
provideSkewRecovery({
|
|
20
|
+
identity: BUILD_IDENTITY,
|
|
21
|
+
manifestUrl: '/skew-manifest.json', // Allows detection of stale origins vs deleted routes
|
|
22
|
+
}),
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then, wrap your lazy imports with `lazy()`:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { lazy } from '@braidlabs/angular-router';
|
|
31
|
+
|
|
32
|
+
export const routes: Routes = [
|
|
33
|
+
{
|
|
34
|
+
path: 'admin',
|
|
35
|
+
// The id 'admin.routes' is used to cross-reference the manifest
|
|
36
|
+
loadChildren: lazy('admin.routes', () => import('./admin/routes'))
|
|
37
|
+
}
|
|
38
|
+
];
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## How It Works
|
|
42
|
+
|
|
43
|
+
### 1. Pre-Classification Retry
|
|
44
|
+
|
|
45
|
+
A transient network failure shouldn't cause a page reload. `lazy()` will automatically retry the dynamic import (default: 1 retry, 250ms backoff) before surfacing a `ChunkLoadFailure` to the router. This resolves CDN misses without the user ever noticing.
|
|
46
|
+
|
|
47
|
+
### 2. Recovery Strategies
|
|
48
|
+
|
|
49
|
+
If retries are exhausted, the failure is caught by the recovery service, which classifies it and executes the configured strategy (`onStaleChunk`):
|
|
50
|
+
|
|
51
|
+
| Strategy | Behaviour |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `'reload-at-target'` *(default)* | `location.assign(targetUrl)` — Hard navigates to the intended destination. This is crucial because Angular's default `deferred` URL update strategy leaves the address bar on the *previous* route upon failure. A plain `location.reload()` would discard the navigation intent. |
|
|
54
|
+
| `'reload-in-place'` | `location.reload()` — Refreshes the current route, abandoning the navigation. |
|
|
55
|
+
| `'redirect-to-fallback'` | Client-side redirect (default `/`); correct when a route was deleted in a new deployment. |
|
|
56
|
+
| `'notify'` | Exposes the failure via the `SkewRecoveryService` signal to let the app handle it manually. |
|
|
57
|
+
| `'ignore'` | Leaves the `NavigationError` to propagate untouched. |
|
|
58
|
+
| `(ctx) => Action` | Custom application policy based on the `StaleChunkContext`. |
|
|
59
|
+
|
|
60
|
+
### 3. Loop Prevention
|
|
61
|
+
|
|
62
|
+
If the origin is serving a stale entry document (older than your current running client), reloading will fetch the exact same stale bundles and fail again, creating an infinite loop that bricks the tab.
|
|
63
|
+
|
|
64
|
+
`@braidlabs/angular-router` prevents this by:
|
|
65
|
+
1. Probing the `manifestUrl` to determine the origin's build timestamp.
|
|
66
|
+
2. Refusing to automatically reload if the origin is older than the client.
|
|
67
|
+
3. Keeping a hard limit on automatic recoveries per session (`maxRecoveries`, default 1).
|
|
68
|
+
|
|
69
|
+
## Unsaved Work Protection
|
|
70
|
+
|
|
71
|
+
Angular does not provide a way for libraries to introspect `CanDeactivate` guards. If an automatic reload happens while a user is midway through a form, data is lost.
|
|
72
|
+
|
|
73
|
+
To prevent this, components can register unsaved work:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { trackUnsavedWork } from '@braidlabs/angular-router';
|
|
77
|
+
|
|
78
|
+
@Component({ ... })
|
|
79
|
+
export class BulletinEditor {
|
|
80
|
+
form = new FormGroup({ ... });
|
|
81
|
+
|
|
82
|
+
constructor() {
|
|
83
|
+
// Automatically cleans up when the component is destroyed
|
|
84
|
+
trackUnsavedWork(() => this.form.dirty);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
If `respectUnsavedWork` is true (default), any pending skew recovery will downgrade its strategy to `'notify'` instead of automatically reloading the page.
|
|
90
|
+
|
|
91
|
+
## Manual Recovery UI
|
|
92
|
+
|
|
93
|
+
If a recovery degrades to `'notify'` (e.g. because of unsaved work or a loop detection), you can prompt the user to manually recover:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { Component, inject } from '@angular/core';
|
|
97
|
+
import { SkewRecoveryService } from '@braidlabs/angular-router';
|
|
98
|
+
|
|
99
|
+
@Component({
|
|
100
|
+
template: `
|
|
101
|
+
@if (recovery.pending()) {
|
|
102
|
+
<div class="banner">
|
|
103
|
+
A new version is available.
|
|
104
|
+
<button (click)="recovery.recover()">Reload App</button>
|
|
105
|
+
</div>
|
|
106
|
+
}
|
|
107
|
+
`
|
|
108
|
+
})
|
|
109
|
+
export class AppLayout {
|
|
110
|
+
recovery = inject(SkewRecoveryService);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
@@ -0,0 +1,490 @@
|
|
|
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');
|
|
8
|
+
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
|
+
};
|
|
18
|
+
}
|
|
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
|
+
const lazyDefaults = {
|
|
36
|
+
retryAttempts: 1,
|
|
37
|
+
retryDelayMs: 250,
|
|
38
|
+
};
|
|
39
|
+
/** Restores defaults. Exposed for tests, which must not leak configuration. */
|
|
40
|
+
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
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function isChunkLoadFailure(value) {
|
|
65
|
+
return value instanceof ChunkLoadFailure;
|
|
66
|
+
}
|
|
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
|
+
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));
|
|
85
|
+
}
|
|
86
|
+
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
|
+
};
|
|
120
|
+
}
|
|
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
|
+
*/
|
|
186
|
+
function trackUnsavedWork(isDirty) {
|
|
187
|
+
const registry = inject(UnsavedWorkRegistry);
|
|
188
|
+
const destroyRef = inject(DestroyRef);
|
|
189
|
+
const dispose = registry.register(isDirty);
|
|
190
|
+
destroyRef.onDestroy(dispose);
|
|
191
|
+
}
|
|
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
|
+
*/
|
|
432
|
+
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
|
+
]);
|
|
445
|
+
}
|
|
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
|
+
function provideSkewDisabled(disabled = true) {
|
|
480
|
+
return makeEnvironmentProviders([
|
|
481
|
+
provideEnvironmentInitializer(() => setSkewDisabled(disabled)),
|
|
482
|
+
]);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Generated bundle index. Do not edit.
|
|
487
|
+
*/
|
|
488
|
+
|
|
489
|
+
export { ChunkLoadFailure, SKEW_RECOVERY_OPTIONS, SkewRecoveryService, UnsavedWorkRegistry, isChunkLoadFailure, lazy, lazyDefaults, looksLikeChunkError, provideSkewDisabled, provideSkewRecovery, resetLazyDefaults, resolveOptions, trackUnsavedWork };
|
|
490
|
+
//# sourceMappingURL=braidlabs-angular-router.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"braidlabs-angular-router.mjs","sources":["../../../../../libs/angular/router/src/lib/config.ts","../../../../../libs/angular/router/src/lib/lazy.ts","../../../../../libs/angular/router/src/lib/unsaved-work.ts","../../../../../libs/angular/router/src/lib/recovery.service.ts","../../../../../libs/angular/router/src/lib/provide.ts","../../../../../libs/angular/router/src/lib/disabled.provider.ts","../../../../../libs/angular/router/src/braidlabs-angular-router.ts"],"sourcesContent":["import { InjectionToken } from '@angular/core';\nimport type { BuildIdentity } from '@braidlabs/skew';\n\n/**\n * What to do when a lazy chunk cannot be loaded.\n *\n * These are not interchangeable. A missing chunk, an offline device, a deleted\n * route, and an origin serving a stale entry document all arrive as the same\n * rejected dynamic import, and each wants a different response — which is why\n * classification happens before dispatch.\n */\nexport type StaleChunkAction =\n /**\n * Hard-navigate to the URL the user was trying to reach.\n *\n * Distinct from a plain reload for a subtle reason: Angular's default\n * `urlUpdateStrategy` is `'deferred'`, so after a failed navigation the\n * address bar still shows the *previous* route. `location.reload()` would\n * therefore return the user where they started and silently discard the\n * navigation they attempted.\n */\n | 'reload-at-target'\n /** Refresh the current route, abandoning the attempted navigation. */\n | 'reload-in-place'\n /** Client-side redirect to `fallbackRoute`. Correct when the route is gone. */\n | 'redirect-to-fallback'\n /** Take no automatic action; expose it and let the application decide. */\n | 'notify'\n /** Leave `NavigationError` to propagate untouched. */\n | 'ignore';\n\n/** Everything known about the failure at the moment a strategy is chosen. */\nexport interface StaleChunkContext {\n /** The URL the user was navigating to. */\n readonly targetUrl: string;\n /** Where they still are, since the navigation failed. */\n readonly currentUrl: string;\n /** Logical id passed to `lazy()`, when one was supplied. */\n readonly moduleId?: string;\n readonly error: unknown;\n\n /** Recoveries already spent this session, for loop protection. */\n readonly attempt: number;\n readonly isOnline: boolean;\n\n readonly clientBuildId: string;\n /** Present only when the manifest probe succeeded. */\n readonly serverBuildId?: string;\n\n /**\n * False when the module is absent from the new build's manifest — the route\n * was deleted, so reloading would land on a router 404.\n */\n readonly moduleStillExists: boolean;\n /**\n * True when the origin reports an *older* build than the one running.\n * Reloading fetches the same stale bundle and fails again — forever.\n */\n readonly entryDocumentStale: boolean;\n /** True when the probe reached the origin and it is ahead of us. */\n readonly newAssetsReachable: boolean;\n\n /** True when something on the page has registered unsaved work. */\n readonly hasUnsavedWork: boolean;\n}\n\nexport type StaleChunkStrategy =\n | StaleChunkAction\n | ((context: StaleChunkContext) => StaleChunkAction | Promise<StaleChunkAction>);\n\nexport interface SkewRecoveryOptions {\n /** Identity of this build. Generate with `@braidlabs/build`. */\n readonly identity: BuildIdentity;\n /**\n * Manifest the origin serves, `Cache-Control: no-store`. Without it the\n * library still recovers, but cannot distinguish a deleted route or a stale\n * origin — so it degrades to conservative behaviour.\n */\n readonly manifestUrl?: string;\n /** Default `'reload-at-target'`. */\n readonly onStaleChunk?: StaleChunkStrategy;\n /** Transient-failure retries inside `lazy()`. Default 1. */\n readonly retryAttempts?: number;\n /** Backoff between retries. Default 250ms. */\n readonly retryDelayMs?: number;\n /**\n * Hard cap on automatic recoveries per session. Default 1.\n *\n * The blunt guard against reload loops; the manifest probe is the precise\n * one. Both exist because the failure mode — a bricked tab — is severe.\n */\n readonly maxRecoveries?: number;\n /** Target for `'redirect-to-fallback'`. Default `'/'`. */\n readonly fallbackRoute?: string;\n /**\n * Degrade to `'notify'` when unsaved work is registered. Default true.\n *\n * Angular does not let a library ask the router whether a `CanDeactivate`\n * guard would block, so components opt in via `trackUnsavedWork()`.\n */\n readonly respectUnsavedWork?: boolean;\n}\n\n/** Fully-resolved options, with defaults applied. */\nexport interface ResolvedSkewRecoveryOptions extends SkewRecoveryOptions {\n readonly onStaleChunk: StaleChunkStrategy;\n readonly retryAttempts: number;\n readonly retryDelayMs: number;\n readonly maxRecoveries: number;\n readonly fallbackRoute: string;\n readonly respectUnsavedWork: boolean;\n}\n\nexport const SKEW_RECOVERY_OPTIONS = new InjectionToken<ResolvedSkewRecoveryOptions>(\n 'SKEW_RECOVERY_OPTIONS',\n);\n\nexport function resolveOptions(options: SkewRecoveryOptions): ResolvedSkewRecoveryOptions {\n return {\n ...options,\n onStaleChunk: options.onStaleChunk ?? 'reload-at-target',\n retryAttempts: options.retryAttempts ?? 1,\n retryDelayMs: options.retryDelayMs ?? 250,\n maxRecoveries: options.maxRecoveries ?? 1,\n fallbackRoute: options.fallbackRoute ?? '/',\n respectUnsavedWork: options.respectUnsavedWork ?? true,\n };\n}\n\n/** A recovery the application has been asked to complete itself. */\nexport interface PendingSkew {\n readonly targetUrl: string;\n readonly moduleId?: string;\n readonly serverBuildId?: string;\n readonly reason: 'notify' | 'exhausted' | 'loop-detected' | 'offline';\n readonly context: StaleChunkContext;\n}\n","/**\n * Wrapping the dynamic import.\n *\n * Two problems are solved here, both before any Angular machinery is involved:\n *\n * 1. **Attribution.** By the time an import rejects, the runtime holds\n * `chunk-ABC123.js` — not `./admin/routes`. Without a logical id there is no\n * way to ask the new build's manifest whether that route still exists.\n * 2. **Transience.** A flaky network, a CDN edge miss, and a genuinely purged\n * asset are indistinguishable from the error alone. A bounded retry resolves\n * the first two without inflicting a page reload on anyone.\n *\n * Retry is therefore a *precondition* to recovery, not one of the strategies.\n */\n\nimport { isSkewDisabled } from '@braidlabs/skew';\n\n/** Defaults for `lazy()`, overridable globally by `provideSkewRecovery()`. */\nexport const lazyDefaults = {\n retryAttempts: 1,\n retryDelayMs: 250,\n};\n\n/** Restores defaults. Exposed for tests, which must not leak configuration. */\nexport function resetLazyDefaults(): void {\n lazyDefaults.retryAttempts = 1;\n lazyDefaults.retryDelayMs = 250;\n}\n\n/**\n * Thrown when a lazy load fails after its retries are exhausted.\n *\n * Angular surfaces this through `NavigationError`, where the recovery service\n * recognises it. Carrying the module id across that boundary is the whole\n * reason this error type exists.\n */\nexport class ChunkLoadFailure extends Error {\n override readonly name = 'ChunkLoadFailure';\n constructor(\n readonly moduleId: string | undefined,\n readonly cause: unknown,\n readonly attempts: number,\n ) {\n super(\n `[skew] failed to load ${moduleId ? `\"${moduleId}\"` : 'a lazy chunk'} ` +\n `after ${attempts} attempt${attempts === 1 ? '' : 's'}`,\n );\n }\n}\n\nexport function isChunkLoadFailure(value: unknown): value is ChunkLoadFailure {\n return value instanceof ChunkLoadFailure;\n}\n\n/**\n * Heuristic for \"this looks like a module that failed to load\" rather than an\n * error thrown *by* the module once it ran.\n *\n * Deliberately broad: bundlers and browsers disagree on the shape here\n * (`ChunkLoadError`, a bare `TypeError`, a CSP violation), and a false positive\n * costs one wasted retry while a false negative costs the user their session.\n */\nexport function looksLikeChunkError(error: unknown): boolean {\n if (!error) return false;\n const name = (error as Error).name ?? '';\n const message = (error as Error).message ?? '';\n return (\n name === 'ChunkLoadError' ||\n /loading (chunk|css chunk)/i.test(message) ||\n /failed to fetch dynamically imported module/i.test(message) ||\n /error loading dynamically imported module/i.test(message) ||\n /importing a module script failed/i.test(message)\n );\n}\n\nexport interface LazyOptions {\n readonly retryAttempts?: number;\n readonly retryDelayMs?: number;\n}\n\n/**\n * Wraps a dynamic import for use as `loadChildren` or `loadComponent`.\n *\n * ```ts\n * { path: 'admin', loadChildren: lazy('admin.routes', () => import('./admin/routes')) }\n * { path: 'help', loadComponent: lazy('help.page', () => import('./help').then(m => m.Help)) }\n * ```\n *\n * The returned function is a plain loader — the router needs no knowledge of\n * this library, and routes remain usable if the package is removed.\n */\nexport function lazy<T>(\n moduleId: string,\n loader: () => Promise<T>,\n options?: LazyOptions,\n): () => Promise<T>;\nexport function lazy<T>(loader: () => Promise<T>, options?: LazyOptions): () => Promise<T>;\nexport function lazy<T>(\n moduleIdOrLoader: string | (() => Promise<T>),\n loaderOrOptions?: (() => Promise<T>) | LazyOptions,\n maybeOptions?: LazyOptions,\n): () => Promise<T> {\n const hasId = typeof moduleIdOrLoader === 'string';\n const moduleId = hasId ? moduleIdOrLoader : undefined;\n const loader = (hasId ? loaderOrOptions : moduleIdOrLoader) as () => Promise<T>;\n const options = (hasId ? maybeOptions : (loaderOrOptions as LazyOptions)) ?? {};\n\n if (typeof loader !== 'function') {\n throw new TypeError('[skew] lazy() requires a loader function');\n }\n\n return async () => {\n // Not public API — see `disabled.ts` in @braidlabs/skew. A bare dynamic import\n // with no retry and no attribution: the rejection propagates exactly as the\n // bundler produced it, and nothing downstream knows which module it was.\n if (isSkewDisabled()) return loader();\n\n const attempts = Math.max(0, options.retryAttempts ?? lazyDefaults.retryAttempts) + 1;\n const delay = options.retryDelayMs ?? lazyDefaults.retryDelayMs;\n\n let lastError: unknown;\n for (let attempt = 1; attempt <= attempts; attempt++) {\n try {\n return await loader();\n } catch (error) {\n lastError = error;\n // An error thrown by module *evaluation* is a bug in the module, not a\n // transport failure. Retrying it just runs the bug again.\n if (!looksLikeChunkError(error)) throw error;\n if (attempt < attempts && delay > 0) {\n await new Promise((resolve) => setTimeout(resolve, delay * attempt));\n }\n }\n }\n throw new ChunkLoadFailure(moduleId, lastError, attempts);\n };\n}\n","import { DestroyRef, Injectable, inject, signal } from '@angular/core';\n\n/**\n * Unsaved-work tracking.\n *\n * A hard reload destroys in-memory state, so recovery must know whether that\n * matters. Angular already has a declaration for exactly this — `CanDeactivate`\n * — but the router exposes no way to ask \"would a guard block here?\", so a\n * library cannot reuse it.\n *\n * This registry is the workaround: components declare dirtiness explicitly and\n * the recovery service consults it. If Angular ever exposes guard\n * introspection, this whole file becomes deletable.\n */\n@Injectable({ providedIn: 'root' })\nexport class UnsavedWorkRegistry {\n private readonly sources = signal<ReadonlyArray<() => boolean>>([]);\n\n /** Registers a predicate. Returns a disposer. */\n register(isDirty: () => boolean): () => void {\n this.sources.update((current) => [...current, isDirty]);\n return () => {\n this.sources.update((current) => current.filter((entry) => entry !== isDirty));\n };\n }\n\n /**\n * True when any registered source reports unsaved work.\n *\n * A throwing predicate is treated as *clean* rather than dirty: a broken\n * dirty-check should not permanently block recovery and strand the user.\n */\n isDirty(): boolean {\n return this.sources().some((isDirty) => {\n try {\n return isDirty();\n } catch {\n return false;\n }\n });\n }\n\n /** Count of registered sources. Diagnostics only. */\n get size(): number {\n return this.sources().length;\n }\n}\n\n/**\n * Declares that this component holds unsaved work, so automatic recovery will\n * not discard it. Unregisters automatically on destroy.\n *\n * ```ts\n * export class BulletinEditor {\n * protected readonly form = …;\n * constructor() {\n * trackUnsavedWork(() => this.form.dirty);\n * }\n * }\n * ```\n *\n * Must be called in an injection context.\n */\nexport function trackUnsavedWork(isDirty: () => boolean): void {\n const registry = inject(UnsavedWorkRegistry);\n const destroyRef = inject(DestroyRef);\n const dispose = registry.register(isDirty);\n destroyRef.onDestroy(dispose);\n}\n","import { DOCUMENT, Injectable, PLATFORM_ID, computed, inject, signal } from '@angular/core';\nimport { isPlatformBrowser } from '@angular/common';\nimport { NavigationError, Router } from '@angular/router';\nimport {\n type SkewStatus,\n type VersionProbe,\n createVersionProbe,\n isSkewDisabled,\n moduleWasRemoved,\n} from '@braidlabs/skew';\nimport {\n type PendingSkew,\n SKEW_RECOVERY_OPTIONS,\n type StaleChunkAction,\n type StaleChunkContext,\n} from './config';\nimport { ChunkLoadFailure, isChunkLoadFailure } from './lazy';\nimport { UnsavedWorkRegistry } from './unsaved-work';\n\nconst LOOP_GUARD_KEY = 'skew:recoveries';\n\n/**\n * Orchestrates recovery from a failed lazy load.\n *\n * Wiring is via router events rather than a global registry: `lazy()` throws a\n * {@link ChunkLoadFailure}, Angular surfaces it as `NavigationError`, and this\n * service recognises it there. That keeps `lazy()` dependency-free and usable\n * in route definitions, which are evaluated long before DI exists.\n *\n * Zoneless-safe and SSR-safe: no `NgZone`, and every `location` /\n * `sessionStorage` touch is guarded.\n */\n@Injectable({ providedIn: 'root' })\nexport class SkewRecoveryService {\n private readonly options = inject(SKEW_RECOVERY_OPTIONS);\n private readonly router = inject(Router);\n private readonly document = inject(DOCUMENT);\n private readonly unsavedWork = inject(UnsavedWorkRegistry);\n private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));\n\n private readonly probe: VersionProbe | null;\n\n private readonly pendingSignal = signal<PendingSkew | null>(null);\n private readonly statusSignal = signal<SkewStatus | null>(null);\n\n /** A recovery the application has been asked to complete itself. */\n readonly pending = this.pendingSignal.asReadonly();\n /** Last known comparison against the origin. */\n readonly status = this.statusSignal.asReadonly();\n /** True when a newer deployment is known to exist. */\n readonly updateAvailable = computed(() => this.statusSignal()?.kind === 'staleClient');\n\n constructor() {\n this.probe = this.options.manifestUrl\n ? createVersionProbe({\n identity: this.options.identity,\n manifestUrl: this.options.manifestUrl,\n })\n : null;\n\n if (this.isBrowser) this.listen();\n }\n\n private listen(): void {\n this.router.events.subscribe((event) => {\n if (event instanceof NavigationError && isChunkLoadFailure(event.error)) {\n // Not public API — see `disabled.ts` in @braidlabs/skew. The NavigationError\n // is left to propagate untouched: no retry, no manifest probe, no\n // classification, no decision. Whatever the application does next is\n // whatever it would have done without this package installed.\n if (isSkewDisabled()) return;\n void this.handle(event.error, event.url);\n }\n });\n }\n\n /** Re-checks the origin. Safe to call from a \"check for updates\" control. */\n async check(): Promise<SkewStatus | null> {\n if (!this.probe) return null;\n const status = await this.probe.check();\n this.statusSignal.set(status);\n return status;\n }\n\n /** Completes a deferred recovery — what a \"Reload\" button calls. */\n recover(): void {\n const pending = this.pendingSignal();\n if (!pending) return;\n this.pendingSignal.set(null);\n this.assign(pending.targetUrl);\n }\n\n /** Dismisses a pending recovery without acting on it. */\n dismiss(): void {\n this.pendingSignal.set(null);\n }\n\n private async handle(failure: ChunkLoadFailure, targetUrl: string): Promise<void> {\n const context = await this.classify(failure, targetUrl);\n const action = await this.chooseAction(context);\n this.dispatch(action, context);\n }\n\n private async classify(\n failure: ChunkLoadFailure,\n targetUrl: string,\n ): Promise<StaleChunkContext> {\n const status = await this.check();\n\n const serverBuildId =\n status?.kind === 'current'\n ? status.buildId\n : status && 'remote' in status\n ? status.remote\n : undefined;\n\n // Absent a module map we must assume the route still exists. Claiming it\n // was deleted would redirect users away from a route that merely moved —\n // a worse failure than the one being recovered from.\n const manifest = this.probe?.lastManifest() ?? null;\n const moduleStillExists =\n failure.moduleId === undefined ||\n manifest === null ||\n !moduleWasRemoved(manifest, failure.moduleId);\n\n return {\n targetUrl,\n currentUrl: this.router.url,\n moduleId: failure.moduleId,\n error: failure,\n attempt: this.recoveryCount(),\n isOnline: this.isOnline(),\n clientBuildId: this.options.identity.buildId,\n serverBuildId,\n moduleStillExists,\n entryDocumentStale: status?.kind === 'staleOrigin',\n newAssetsReachable: status?.kind === 'staleClient',\n hasUnsavedWork: this.options.respectUnsavedWork && this.unsavedWork.isDirty(),\n };\n }\n\n private async chooseAction(context: StaleChunkContext): Promise<StaleChunkAction> {\n // Guard rails run before any application policy, because each of these is a\n // case where reloading is actively harmful rather than merely unhelpful.\n if (!context.isOnline) return 'notify';\n if (context.entryDocumentStale) return 'notify'; // reloading would loop\n if (context.attempt >= this.options.maxRecoveries) return 'notify';\n if (context.hasUnsavedWork) return 'notify';\n if (!context.moduleStillExists) return 'redirect-to-fallback';\n\n const strategy = this.options.onStaleChunk;\n if (typeof strategy === 'function') {\n try {\n return await strategy(context);\n } catch {\n // A throwing policy must not strand the user with nothing.\n return 'notify';\n }\n }\n return strategy;\n }\n\n private dispatch(action: StaleChunkAction, context: StaleChunkContext): void {\n switch (action) {\n case 'reload-at-target':\n this.countRecovery();\n this.assign(context.targetUrl);\n return;\n case 'reload-in-place':\n this.countRecovery();\n this.reload();\n return;\n case 'redirect-to-fallback':\n void this.router.navigateByUrl(this.options.fallbackRoute);\n return;\n case 'ignore':\n return;\n case 'notify':\n this.pendingSignal.set({\n targetUrl: context.targetUrl,\n moduleId: context.moduleId,\n serverBuildId: context.serverBuildId,\n reason: !context.isOnline\n ? 'offline'\n : context.entryDocumentStale\n ? 'loop-detected'\n : context.attempt >= this.options.maxRecoveries\n ? 'exhausted'\n : 'notify',\n context,\n });\n return;\n }\n }\n\n // --- environment access, all browser-guarded ---------------------------\n\n private isOnline(): boolean {\n if (!this.isBrowser) return true;\n const nav = this.document.defaultView?.navigator;\n return nav?.onLine ?? true;\n }\n\n private assign(url: string): void {\n if (!this.isBrowser) return;\n this.document.defaultView?.location.assign(url);\n }\n\n private reload(): void {\n if (!this.isBrowser) return;\n this.document.defaultView?.location.reload();\n }\n\n /**\n * Recoveries already attempted, kept in `sessionStorage` so it survives the\n * very reload it is counting. Without this, an origin serving a stale entry\n * document turns one failure into an infinite reload loop.\n */\n private recoveryCount(): number {\n if (!this.isBrowser) return 0;\n try {\n const raw = this.document.defaultView?.sessionStorage.getItem(LOOP_GUARD_KEY);\n const parsed = raw ? JSON.parse(raw) : null;\n // Scoped to a build: a genuinely new deployment gets a fresh budget.\n return parsed?.buildId === this.options.identity.buildId ? (parsed.count ?? 0) : 0;\n } catch {\n return 0;\n }\n }\n\n private countRecovery(): void {\n if (!this.isBrowser) return;\n try {\n this.document.defaultView?.sessionStorage.setItem(\n LOOP_GUARD_KEY,\n JSON.stringify({ buildId: this.options.identity.buildId, count: this.recoveryCount() + 1 }),\n );\n } catch {\n // Private mode. The manifest probe remains as the precise loop guard.\n }\n }\n}\n","import {\n type EnvironmentProviders,\n provideEnvironmentInitializer,\n makeEnvironmentProviders,\n inject,\n} from '@angular/core';\nimport { SKEW_RECOVERY_OPTIONS, type SkewRecoveryOptions, resolveOptions } from './config';\nimport { SkewRecoveryService } from './recovery.service';\nimport { lazyDefaults } from './lazy';\n\n/**\n * Enables skew recovery.\n *\n * ```ts\n * bootstrapApplication(App, {\n * providers: [\n * provideRouter(routes),\n * provideSkewRecovery({\n * identity: BUILD_IDENTITY, // from @braidlabs/build\n * manifestUrl: '/skew-manifest.json',\n * }),\n * ],\n * });\n * ```\n *\n * Deliberately *not* a `RouterFeature`: Angular's feature factory is internal,\n * so third parties cannot construct one. This composes alongside\n * `provideRouter` instead, and works with any router configuration.\n *\n * The service is instantiated eagerly — it subscribes to router events, and a\n * lazily-created recovery service would miss the very first failure.\n */\nexport function provideSkewRecovery(options: SkewRecoveryOptions): EnvironmentProviders {\n const resolved = resolveOptions(options);\n\n return makeEnvironmentProviders([\n { provide: SKEW_RECOVERY_OPTIONS, useValue: resolved },\n provideEnvironmentInitializer(() => {\n // Route definitions are evaluated before DI exists, so `lazy()` cannot\n // inject its retry policy. Publishing the resolved values here keeps a\n // single source of configuration without making `lazy()` DI-aware.\n lazyDefaults.retryAttempts = resolved.retryAttempts;\n lazyDefaults.retryDelayMs = resolved.retryDelayMs;\n\n inject(SkewRecoveryService);\n }),\n ]);\n}\n","import {\n type EnvironmentProviders,\n makeEnvironmentProviders,\n provideEnvironmentInitializer,\n} from '@angular/core';\nimport { setSkewDisabled } from '@braidlabs/skew';\n\n/**\n * ⚠️ NOT PUBLIC API. Undocumented on purpose. Do not use in an application.\n *\n * Angular-shaped wrapper around `setSkewDisabled()` from `@braidlabs/skew`. Read the\n * module comment in `@braidlabs/skew`'s `disabled.ts` first — it explains what this\n * turns off, why it exists, and why it is deliberately absent from the README,\n * the package docs, and every example.\n *\n * ```ts\n * // demo apps only\n * providers: [provideSkewRecovery({ … }), provideSkewDisabled()]\n * ```\n *\n * Two things worth knowing before using it anyway:\n *\n * **It is not scoped to this injector.** The flag lives in a module-level\n * variable in `@braidlabs/skew`, because core is framework-agnostic and has no\n * injector to read from. Calling this affects every `@braidlabs` package sharing\n * that instance of core — including any other Angular application on the page.\n * It is `provide*`-shaped for familiarity, not because it is scoped.\n *\n * **It applies at bootstrap.** For a control that flips at runtime — which is\n * what a before/after demo actually wants — call `setSkewDisabled()` directly.\n * Every check reads the flag at the moment it runs, so a later toggle takes\n * effect on the next read, navigation or write with no reload.\n *\n * This lives in `@braidlabs/angular-router` for want of a shared Angular package;\n * it belongs in `@braidlabs/angular-core` once that lands, since it governs all of\n * them and nothing here is router-specific.\n *\n * @internal\n */\nexport function provideSkewDisabled(disabled = true): EnvironmentProviders {\n return makeEnvironmentProviders([\n provideEnvironmentInitializer(() => setSkewDisabled(disabled)),\n ]);\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":";;;;;;MAiHa,qBAAqB,GAAG,IAAI,cAAc,CACrD,uBAAuB;AAGnB,SAAU,cAAc,CAAC,OAA4B,EAAA;IACzD,OAAO;AACL,QAAA,GAAG,OAAO;AACV,QAAA,YAAY,EAAE,OAAO,CAAC,YAAY,IAAI,kBAAkB;AACxD,QAAA,aAAa,EAAE,OAAO,CAAC,aAAa,IAAI,CAAC;AACzC,QAAA,YAAY,EAAE,OAAO,CAAC,YAAY,IAAI,GAAG;AACzC,QAAA,aAAa,EAAE,OAAO,CAAC,aAAa,IAAI,CAAC;AACzC,QAAA,aAAa,EAAE,OAAO,CAAC,aAAa,IAAI,GAAG;AAC3C,QAAA,kBAAkB,EAAE,OAAO,CAAC,kBAAkB,IAAI,IAAI;KACvD;AACH;;AC/HA;;;;;;;;;;;;;AAaG;AAIH;AACO,MAAM,YAAY,GAAG;AAC1B,IAAA,aAAa,EAAE,CAAC;AAChB,IAAA,YAAY,EAAE,GAAG;;AAGnB;SACgB,iBAAiB,GAAA;AAC/B,IAAA,YAAY,CAAC,aAAa,GAAG,CAAC;AAC9B,IAAA,YAAY,CAAC,YAAY,GAAG,GAAG;AACjC;AAEA;;;;;;AAMG;AACG,MAAO,gBAAiB,SAAQ,KAAK,CAAA;AAG9B,IAAA,QAAA;AACA,IAAA,KAAA;AACA,IAAA,QAAA;IAJO,IAAI,GAAG,kBAAkB;AAC3C,IAAA,WAAA,CACW,QAA4B,EAC5B,KAAc,EACd,QAAgB,EAAA;AAEzB,QAAA,KAAK,CACH,CAAA,sBAAA,EAAyB,QAAQ,GAAG,CAAA,CAAA,EAAI,QAAQ,CAAA,CAAA,CAAG,GAAG,cAAc,CAAA,CAAA,CAAG;AACrE,YAAA,CAAA,MAAA,EAAS,QAAQ,CAAA,QAAA,EAAW,QAAQ,KAAK,CAAC,GAAG,EAAE,GAAG,GAAG,CAAA,CAAE,CAC1D;QAPQ,IAAA,CAAA,QAAQ,GAAR,QAAQ;QACR,IAAA,CAAA,KAAK,GAAL,KAAK;QACL,IAAA,CAAA,QAAQ,GAAR,QAAQ;IAMnB;AACD;AAEK,SAAU,kBAAkB,CAAC,KAAc,EAAA;IAC/C,OAAO,KAAK,YAAY,gBAAgB;AAC1C;AAEA;;;;;;;AAOG;AACG,SAAU,mBAAmB,CAAC,KAAc,EAAA;AAChD,IAAA,IAAI,CAAC,KAAK;AAAE,QAAA,OAAO,KAAK;AACxB,IAAA,MAAM,IAAI,GAAI,KAAe,CAAC,IAAI,IAAI,EAAE;AACxC,IAAA,MAAM,OAAO,GAAI,KAAe,CAAC,OAAO,IAAI,EAAE;IAC9C,QACE,IAAI,KAAK,gBAAgB;AACzB,QAAA,4BAA4B,CAAC,IAAI,CAAC,OAAO,CAAC;AAC1C,QAAA,8CAA8C,CAAC,IAAI,CAAC,OAAO,CAAC;AAC5D,QAAA,4CAA4C,CAAC,IAAI,CAAC,OAAO,CAAC;AAC1D,QAAA,mCAAmC,CAAC,IAAI,CAAC,OAAO,CAAC;AAErD;SAwBgB,IAAI,CAClB,gBAA6C,EAC7C,eAAkD,EAClD,YAA0B,EAAA;AAE1B,IAAA,MAAM,KAAK,GAAG,OAAO,gBAAgB,KAAK,QAAQ;IAClD,MAAM,QAAQ,GAAG,KAAK,GAAG,gBAAgB,GAAG,SAAS;AACrD,IAAA,MAAM,MAAM,IAAI,KAAK,GAAG,eAAe,GAAG,gBAAgB,CAAqB;AAC/E,IAAA,MAAM,OAAO,GAAG,CAAC,KAAK,GAAG,YAAY,GAAI,eAA+B,KAAK,EAAE;AAE/E,IAAA,IAAI,OAAO,MAAM,KAAK,UAAU,EAAE;AAChC,QAAA,MAAM,IAAI,SAAS,CAAC,0CAA0C,CAAC;IACjE;IAEA,OAAO,YAAW;;;;AAIhB,QAAA,IAAI,cAAc,EAAE;YAAE,OAAO,MAAM,EAAE;AAErC,QAAA,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,aAAa,IAAI,YAAY,CAAC,aAAa,CAAC,GAAG,CAAC;QACrF,MAAM,KAAK,GAAG,OAAO,CAAC,YAAY,IAAI,YAAY,CAAC,YAAY;AAE/D,QAAA,IAAI,SAAkB;AACtB,QAAA,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,QAAQ,EAAE,OAAO,EAAE,EAAE;AACpD,YAAA,IAAI;gBACF,OAAO,MAAM,MAAM,EAAE;YACvB;YAAE,OAAO,KAAK,EAAE;gBACd,SAAS,GAAG,KAAK;;;AAGjB,gBAAA,IAAI,CAAC,mBAAmB,CAAC,KAAK,CAAC;AAAE,oBAAA,MAAM,KAAK;gBAC5C,IAAI,OAAO,GAAG,QAAQ,IAAI,KAAK,GAAG,CAAC,EAAE;AACnC,oBAAA,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,KAAK,UAAU,CAAC,OAAO,EAAE,KAAK,GAAG,OAAO,CAAC,CAAC;gBACtE;YACF;QACF;QACA,MAAM,IAAI,gBAAgB,CAAC,QAAQ,EAAE,SAAS,EAAE,QAAQ,CAAC;AAC3D,IAAA,CAAC;AACH;;ACtIA;;;;;;;;;;;AAWG;MAEU,mBAAmB,CAAA;IACb,OAAO,GAAG,MAAM,CAA+B,EAAE;gFAAC;;AAGnE,IAAA,QAAQ,CAAC,OAAsB,EAAA;AAC7B,QAAA,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,OAAO,KAAK,CAAC,GAAG,OAAO,EAAE,OAAO,CAAC,CAAC;AACvD,QAAA,OAAO,MAAK;YACV,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,KAAK,KAAK,KAAK,OAAO,CAAC,CAAC;AAChF,QAAA,CAAC;IACH;AAEA;;;;;AAKG;IACH,OAAO,GAAA;QACL,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC,OAAO,KAAI;AACrC,YAAA,IAAI;gBACF,OAAO,OAAO,EAAE;YAClB;AAAE,YAAA,MAAM;AACN,gBAAA,OAAO,KAAK;YACd;AACF,QAAA,CAAC,CAAC;IACJ;;AAGA,IAAA,IAAI,IAAI,GAAA;AACN,QAAA,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC,MAAM;IAC9B;uGA9BW,mBAAmB,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,UAAA,EAAA,CAAA;AAAnB,IAAA,OAAA,KAAA,GAAA,EAAA,CAAA,qBAAA,CAAA,EAAA,UAAA,EAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,QAAA,EAAA,EAAA,EAAA,IAAA,EAAA,mBAAmB,cADN,MAAM,EAAA,CAAA;;2FACnB,mBAAmB,EAAA,UAAA,EAAA,CAAA;kBAD/B,UAAU;mBAAC,EAAE,UAAU,EAAE,MAAM,EAAE;;AAkClC;;;;;;;;;;;;;;AAcG;AACG,SAAU,gBAAgB,CAAC,OAAsB,EAAA;AACrD,IAAA,MAAM,QAAQ,GAAG,MAAM,CAAC,mBAAmB,CAAC;AAC5C,IAAA,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC;IACrC,MAAM,OAAO,GAAG,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC;AAC1C,IAAA,UAAU,CAAC,SAAS,CAAC,OAAO,CAAC;AAC/B;;ACjDA,MAAM,cAAc,GAAG,iBAAiB;AAExC;;;;;;;;;;AAUG;MAEU,mBAAmB,CAAA;AACb,IAAA,OAAO,GAAG,MAAM,CAAC,qBAAqB,CAAC;AACvC,IAAA,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;AACvB,IAAA,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC;AAC3B,IAAA,WAAW,GAAG,MAAM,CAAC,mBAAmB,CAAC;IACzC,SAAS,GAAG,iBAAiB,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;AAElD,IAAA,KAAK;IAEL,aAAa,GAAG,MAAM,CAAqB,IAAI;sFAAC;IAChD,YAAY,GAAG,MAAM,CAAoB,IAAI;qFAAC;;AAGtD,IAAA,OAAO,GAAG,IAAI,CAAC,aAAa,CAAC,UAAU,EAAE;;AAEzC,IAAA,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,UAAU,EAAE;;AAEvC,IAAA,eAAe,GAAG,QAAQ,CAAC,MAAM,IAAI,CAAC,YAAY,EAAE,EAAE,IAAI,KAAK,aAAa;wFAAC;AAEtF,IAAA,WAAA,GAAA;AACE,QAAA,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC;cACtB,kBAAkB,CAAC;AACjB,gBAAA,QAAQ,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ;AAC/B,gBAAA,WAAW,EAAE,IAAI,CAAC,OAAO,CAAC,WAAW;aACtC;cACD,IAAI;QAER,IAAI,IAAI,CAAC,SAAS;YAAE,IAAI,CAAC,MAAM,EAAE;IACnC;IAEQ,MAAM,GAAA;QACZ,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,KAAK,KAAI;YACrC,IAAI,KAAK,YAAY,eAAe,IAAI,kBAAkB,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE;;;;;AAKvE,gBAAA,IAAI,cAAc,EAAE;oBAAE;AACtB,gBAAA,KAAK,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC;YAC1C;AACF,QAAA,CAAC,CAAC;IACJ;;AAGA,IAAA,MAAM,KAAK,GAAA;QACT,IAAI,CAAC,IAAI,CAAC,KAAK;AAAE,YAAA,OAAO,IAAI;QAC5B,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE;AACvC,QAAA,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC;AAC7B,QAAA,OAAO,MAAM;IACf;;IAGA,OAAO,GAAA;AACL,QAAA,MAAM,OAAO,GAAG,IAAI,CAAC,aAAa,EAAE;AACpC,QAAA,IAAI,CAAC,OAAO;YAAE;AACd,QAAA,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,IAAI,CAAC;AAC5B,QAAA,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC;IAChC;;IAGA,OAAO,GAAA;AACL,QAAA,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,IAAI,CAAC;IAC9B;AAEQ,IAAA,MAAM,MAAM,CAAC,OAAyB,EAAE,SAAiB,EAAA;QAC/D,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC;QACvD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC;AAC/C,QAAA,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAChC;AAEQ,IAAA,MAAM,QAAQ,CACpB,OAAyB,EACzB,SAAiB,EAAA;AAEjB,QAAA,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,KAAK,EAAE;AAEjC,QAAA,MAAM,aAAa,GACjB,MAAM,EAAE,IAAI,KAAK;cACb,MAAM,CAAC;AACT,cAAE,MAAM,IAAI,QAAQ,IAAI;kBACpB,MAAM,CAAC;kBACP,SAAS;;;;QAKjB,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,EAAE,YAAY,EAAE,IAAI,IAAI;AACnD,QAAA,MAAM,iBAAiB,GACrB,OAAO,CAAC,QAAQ,KAAK,SAAS;AAC9B,YAAA,QAAQ,KAAK,IAAI;YACjB,CAAC,gBAAgB,CAAC,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC;QAE/C,OAAO;YACL,SAAS;AACT,YAAA,UAAU,EAAE,IAAI,CAAC,MAAM,CAAC,GAAG;YAC3B,QAAQ,EAAE,OAAO,CAAC,QAAQ;AAC1B,YAAA,KAAK,EAAE,OAAO;AACd,YAAA,OAAO,EAAE,IAAI,CAAC,aAAa,EAAE;AAC7B,YAAA,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE;AACzB,YAAA,aAAa,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,OAAO;YAC5C,aAAa;YACb,iBAAiB;AACjB,YAAA,kBAAkB,EAAE,MAAM,EAAE,IAAI,KAAK,aAAa;AAClD,YAAA,kBAAkB,EAAE,MAAM,EAAE,IAAI,KAAK,aAAa;AAClD,YAAA,cAAc,EAAE,IAAI,CAAC,OAAO,CAAC,kBAAkB,IAAI,IAAI,CAAC,WAAW,CAAC,OAAO,EAAE;SAC9E;IACH;IAEQ,MAAM,YAAY,CAAC,OAA0B,EAAA;;;QAGnD,IAAI,CAAC,OAAO,CAAC,QAAQ;AAAE,YAAA,OAAO,QAAQ;QACtC,IAAI,OAAO,CAAC,kBAAkB;YAAE,OAAO,QAAQ,CAAC;QAChD,IAAI,OAAO,CAAC,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,aAAa;AAAE,YAAA,OAAO,QAAQ;QAClE,IAAI,OAAO,CAAC,cAAc;AAAE,YAAA,OAAO,QAAQ;QAC3C,IAAI,CAAC,OAAO,CAAC,iBAAiB;AAAE,YAAA,OAAO,sBAAsB;AAE7D,QAAA,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY;AAC1C,QAAA,IAAI,OAAO,QAAQ,KAAK,UAAU,EAAE;AAClC,YAAA,IAAI;AACF,gBAAA,OAAO,MAAM,QAAQ,CAAC,OAAO,CAAC;YAChC;AAAE,YAAA,MAAM;;AAEN,gBAAA,OAAO,QAAQ;YACjB;QACF;AACA,QAAA,OAAO,QAAQ;IACjB;IAEQ,QAAQ,CAAC,MAAwB,EAAE,OAA0B,EAAA;QACnE,QAAQ,MAAM;AACZ,YAAA,KAAK,kBAAkB;gBACrB,IAAI,CAAC,aAAa,EAAE;AACpB,gBAAA,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC;gBAC9B;AACF,YAAA,KAAK,iBAAiB;gBACpB,IAAI,CAAC,aAAa,EAAE;gBACpB,IAAI,CAAC,MAAM,EAAE;gBACb;AACF,YAAA,KAAK,sBAAsB;AACzB,gBAAA,KAAK,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC;gBAC1D;AACF,YAAA,KAAK,QAAQ;gBACX;AACF,YAAA,KAAK,QAAQ;AACX,gBAAA,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC;oBACrB,SAAS,EAAE,OAAO,CAAC,SAAS;oBAC5B,QAAQ,EAAE,OAAO,CAAC,QAAQ;oBAC1B,aAAa,EAAE,OAAO,CAAC,aAAa;AACpC,oBAAA,MAAM,EAAE,CAAC,OAAO,CAAC;AACf,0BAAE;0BACA,OAAO,CAAC;AACR,8BAAE;8BACA,OAAO,CAAC,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC;AAChC,kCAAE;AACF,kCAAE,QAAQ;oBAChB,OAAO;AACR,iBAAA,CAAC;gBACF;;IAEN;;IAIQ,QAAQ,GAAA;QACd,IAAI,CAAC,IAAI,CAAC,SAAS;AAAE,YAAA,OAAO,IAAI;QAChC,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,SAAS;AAChD,QAAA,OAAO,GAAG,EAAE,MAAM,IAAI,IAAI;IAC5B;AAEQ,IAAA,MAAM,CAAC,GAAW,EAAA;QACxB,IAAI,CAAC,IAAI,CAAC,SAAS;YAAE;QACrB,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC;IACjD;IAEQ,MAAM,GAAA;QACZ,IAAI,CAAC,IAAI,CAAC,SAAS;YAAE;QACrB,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,MAAM,EAAE;IAC9C;AAEA;;;;AAIG;IACK,aAAa,GAAA;QACnB,IAAI,CAAC,IAAI,CAAC,SAAS;AAAE,YAAA,OAAO,CAAC;AAC7B,QAAA,IAAI;AACF,YAAA,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,cAAc,CAAC,OAAO,CAAC,cAAc,CAAC;AAC7E,YAAA,MAAM,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI;;YAE3C,OAAO,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,OAAO,IAAI,MAAM,CAAC,KAAK,IAAI,CAAC,IAAI,CAAC;QACpF;AAAE,QAAA,MAAM;AACN,YAAA,OAAO,CAAC;QACV;IACF;IAEQ,aAAa,GAAA;QACnB,IAAI,CAAC,IAAI,CAAC,SAAS;YAAE;AACrB,QAAA,IAAI;AACF,YAAA,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,cAAc,CAAC,OAAO,CAC/C,cAAc,EACd,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,aAAa,EAAE,GAAG,CAAC,EAAE,CAAC,CAC5F;QACH;AAAE,QAAA,MAAM;;QAER;IACF;uGA/MW,mBAAmB,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,UAAA,EAAA,CAAA;AAAnB,IAAA,OAAA,KAAA,GAAA,EAAA,CAAA,qBAAA,CAAA,EAAA,UAAA,EAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,QAAA,EAAA,EAAA,EAAA,IAAA,EAAA,mBAAmB,cADN,MAAM,EAAA,CAAA;;2FACnB,mBAAmB,EAAA,UAAA,EAAA,CAAA;kBAD/B,UAAU;mBAAC,EAAE,UAAU,EAAE,MAAM,EAAE;;;ACtBlC;;;;;;;;;;;;;;;;;;;;;AAqBG;AACG,SAAU,mBAAmB,CAAC,OAA4B,EAAA;AAC9D,IAAA,MAAM,QAAQ,GAAG,cAAc,CAAC,OAAO,CAAC;AAExC,IAAA,OAAO,wBAAwB,CAAC;AAC9B,QAAA,EAAE,OAAO,EAAE,qBAAqB,EAAE,QAAQ,EAAE,QAAQ,EAAE;QACtD,6BAA6B,CAAC,MAAK;;;;AAIjC,YAAA,YAAY,CAAC,aAAa,GAAG,QAAQ,CAAC,aAAa;AACnD,YAAA,YAAY,CAAC,YAAY,GAAG,QAAQ,CAAC,YAAY;YAEjD,MAAM,CAAC,mBAAmB,CAAC;AAC7B,QAAA,CAAC,CAAC;AACH,KAAA,CAAC;AACJ;;ACxCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BG;AACG,SAAU,mBAAmB,CAAC,QAAQ,GAAG,IAAI,EAAA;AACjD,IAAA,OAAO,wBAAwB,CAAC;QAC9B,6BAA6B,CAAC,MAAM,eAAe,CAAC,QAAQ,CAAC,CAAC;AAC/D,KAAA,CAAC;AACJ;;AC3CA;;AAEG;;;;"}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@braidlabs/angular-router",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"peerDependencies": {
|
|
5
|
+
"@angular/core": "^22.0.0",
|
|
6
|
+
"@angular/router": "^22.0.0",
|
|
7
|
+
"@angular/common": "^22.0.0",
|
|
8
|
+
"@braidlabs/skew": "^0.1.1"
|
|
9
|
+
},
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"description": "Recovers from stale lazy-chunk failures after a deploy, with classification that avoids reload loops.",
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/braidjs/braid.git",
|
|
18
|
+
"directory": "libs/angular/router"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/braidjs/braid#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/braidjs/braid/issues"
|
|
23
|
+
},
|
|
24
|
+
"module": "fesm2022/braidlabs-angular-router.mjs",
|
|
25
|
+
"typings": "types/braidlabs-angular-router.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
"./package.json": {
|
|
28
|
+
"default": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./types/braidlabs-angular-router.d.ts",
|
|
32
|
+
"default": "./fesm2022/braidlabs-angular-router.mjs"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"type": "module",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"tslib": "^2.3.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, EnvironmentProviders } from '@angular/core';
|
|
3
|
+
import { BuildIdentity, SkewStatus } from '@braidlabs/skew';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What to do when a lazy chunk cannot be loaded.
|
|
7
|
+
*
|
|
8
|
+
* These are not interchangeable. A missing chunk, an offline device, a deleted
|
|
9
|
+
* route, and an origin serving a stale entry document all arrive as the same
|
|
10
|
+
* rejected dynamic import, and each wants a different response — which is why
|
|
11
|
+
* classification happens before dispatch.
|
|
12
|
+
*/
|
|
13
|
+
type StaleChunkAction =
|
|
14
|
+
/**
|
|
15
|
+
* Hard-navigate to the URL the user was trying to reach.
|
|
16
|
+
*
|
|
17
|
+
* Distinct from a plain reload for a subtle reason: Angular's default
|
|
18
|
+
* `urlUpdateStrategy` is `'deferred'`, so after a failed navigation the
|
|
19
|
+
* address bar still shows the *previous* route. `location.reload()` would
|
|
20
|
+
* therefore return the user where they started and silently discard the
|
|
21
|
+
* navigation they attempted.
|
|
22
|
+
*/
|
|
23
|
+
'reload-at-target'
|
|
24
|
+
/** Refresh the current route, abandoning the attempted navigation. */
|
|
25
|
+
| 'reload-in-place'
|
|
26
|
+
/** Client-side redirect to `fallbackRoute`. Correct when the route is gone. */
|
|
27
|
+
| 'redirect-to-fallback'
|
|
28
|
+
/** Take no automatic action; expose it and let the application decide. */
|
|
29
|
+
| 'notify'
|
|
30
|
+
/** Leave `NavigationError` to propagate untouched. */
|
|
31
|
+
| 'ignore';
|
|
32
|
+
/** Everything known about the failure at the moment a strategy is chosen. */
|
|
33
|
+
interface StaleChunkContext {
|
|
34
|
+
/** The URL the user was navigating to. */
|
|
35
|
+
readonly targetUrl: string;
|
|
36
|
+
/** Where they still are, since the navigation failed. */
|
|
37
|
+
readonly currentUrl: string;
|
|
38
|
+
/** Logical id passed to `lazy()`, when one was supplied. */
|
|
39
|
+
readonly moduleId?: string;
|
|
40
|
+
readonly error: unknown;
|
|
41
|
+
/** Recoveries already spent this session, for loop protection. */
|
|
42
|
+
readonly attempt: number;
|
|
43
|
+
readonly isOnline: boolean;
|
|
44
|
+
readonly clientBuildId: string;
|
|
45
|
+
/** Present only when the manifest probe succeeded. */
|
|
46
|
+
readonly serverBuildId?: string;
|
|
47
|
+
/**
|
|
48
|
+
* False when the module is absent from the new build's manifest — the route
|
|
49
|
+
* was deleted, so reloading would land on a router 404.
|
|
50
|
+
*/
|
|
51
|
+
readonly moduleStillExists: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* True when the origin reports an *older* build than the one running.
|
|
54
|
+
* Reloading fetches the same stale bundle and fails again — forever.
|
|
55
|
+
*/
|
|
56
|
+
readonly entryDocumentStale: boolean;
|
|
57
|
+
/** True when the probe reached the origin and it is ahead of us. */
|
|
58
|
+
readonly newAssetsReachable: boolean;
|
|
59
|
+
/** True when something on the page has registered unsaved work. */
|
|
60
|
+
readonly hasUnsavedWork: boolean;
|
|
61
|
+
}
|
|
62
|
+
type StaleChunkStrategy = StaleChunkAction | ((context: StaleChunkContext) => StaleChunkAction | Promise<StaleChunkAction>);
|
|
63
|
+
interface SkewRecoveryOptions {
|
|
64
|
+
/** Identity of this build. Generate with `@braidlabs/build`. */
|
|
65
|
+
readonly identity: BuildIdentity;
|
|
66
|
+
/**
|
|
67
|
+
* Manifest the origin serves, `Cache-Control: no-store`. Without it the
|
|
68
|
+
* library still recovers, but cannot distinguish a deleted route or a stale
|
|
69
|
+
* origin — so it degrades to conservative behaviour.
|
|
70
|
+
*/
|
|
71
|
+
readonly manifestUrl?: string;
|
|
72
|
+
/** Default `'reload-at-target'`. */
|
|
73
|
+
readonly onStaleChunk?: StaleChunkStrategy;
|
|
74
|
+
/** Transient-failure retries inside `lazy()`. Default 1. */
|
|
75
|
+
readonly retryAttempts?: number;
|
|
76
|
+
/** Backoff between retries. Default 250ms. */
|
|
77
|
+
readonly retryDelayMs?: number;
|
|
78
|
+
/**
|
|
79
|
+
* Hard cap on automatic recoveries per session. Default 1.
|
|
80
|
+
*
|
|
81
|
+
* The blunt guard against reload loops; the manifest probe is the precise
|
|
82
|
+
* one. Both exist because the failure mode — a bricked tab — is severe.
|
|
83
|
+
*/
|
|
84
|
+
readonly maxRecoveries?: number;
|
|
85
|
+
/** Target for `'redirect-to-fallback'`. Default `'/'`. */
|
|
86
|
+
readonly fallbackRoute?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Degrade to `'notify'` when unsaved work is registered. Default true.
|
|
89
|
+
*
|
|
90
|
+
* Angular does not let a library ask the router whether a `CanDeactivate`
|
|
91
|
+
* guard would block, so components opt in via `trackUnsavedWork()`.
|
|
92
|
+
*/
|
|
93
|
+
readonly respectUnsavedWork?: boolean;
|
|
94
|
+
}
|
|
95
|
+
/** Fully-resolved options, with defaults applied. */
|
|
96
|
+
interface ResolvedSkewRecoveryOptions extends SkewRecoveryOptions {
|
|
97
|
+
readonly onStaleChunk: StaleChunkStrategy;
|
|
98
|
+
readonly retryAttempts: number;
|
|
99
|
+
readonly retryDelayMs: number;
|
|
100
|
+
readonly maxRecoveries: number;
|
|
101
|
+
readonly fallbackRoute: string;
|
|
102
|
+
readonly respectUnsavedWork: boolean;
|
|
103
|
+
}
|
|
104
|
+
declare const SKEW_RECOVERY_OPTIONS: InjectionToken<ResolvedSkewRecoveryOptions>;
|
|
105
|
+
declare function resolveOptions(options: SkewRecoveryOptions): ResolvedSkewRecoveryOptions;
|
|
106
|
+
/** A recovery the application has been asked to complete itself. */
|
|
107
|
+
interface PendingSkew {
|
|
108
|
+
readonly targetUrl: string;
|
|
109
|
+
readonly moduleId?: string;
|
|
110
|
+
readonly serverBuildId?: string;
|
|
111
|
+
readonly reason: 'notify' | 'exhausted' | 'loop-detected' | 'offline';
|
|
112
|
+
readonly context: StaleChunkContext;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Wrapping the dynamic import.
|
|
117
|
+
*
|
|
118
|
+
* Two problems are solved here, both before any Angular machinery is involved:
|
|
119
|
+
*
|
|
120
|
+
* 1. **Attribution.** By the time an import rejects, the runtime holds
|
|
121
|
+
* `chunk-ABC123.js` — not `./admin/routes`. Without a logical id there is no
|
|
122
|
+
* way to ask the new build's manifest whether that route still exists.
|
|
123
|
+
* 2. **Transience.** A flaky network, a CDN edge miss, and a genuinely purged
|
|
124
|
+
* asset are indistinguishable from the error alone. A bounded retry resolves
|
|
125
|
+
* the first two without inflicting a page reload on anyone.
|
|
126
|
+
*
|
|
127
|
+
* Retry is therefore a *precondition* to recovery, not one of the strategies.
|
|
128
|
+
*/
|
|
129
|
+
/** Defaults for `lazy()`, overridable globally by `provideSkewRecovery()`. */
|
|
130
|
+
declare const lazyDefaults: {
|
|
131
|
+
retryAttempts: number;
|
|
132
|
+
retryDelayMs: number;
|
|
133
|
+
};
|
|
134
|
+
/** Restores defaults. Exposed for tests, which must not leak configuration. */
|
|
135
|
+
declare function resetLazyDefaults(): void;
|
|
136
|
+
/**
|
|
137
|
+
* Thrown when a lazy load fails after its retries are exhausted.
|
|
138
|
+
*
|
|
139
|
+
* Angular surfaces this through `NavigationError`, where the recovery service
|
|
140
|
+
* recognises it. Carrying the module id across that boundary is the whole
|
|
141
|
+
* reason this error type exists.
|
|
142
|
+
*/
|
|
143
|
+
declare class ChunkLoadFailure extends Error {
|
|
144
|
+
readonly moduleId: string | undefined;
|
|
145
|
+
readonly cause: unknown;
|
|
146
|
+
readonly attempts: number;
|
|
147
|
+
readonly name = "ChunkLoadFailure";
|
|
148
|
+
constructor(moduleId: string | undefined, cause: unknown, attempts: number);
|
|
149
|
+
}
|
|
150
|
+
declare function isChunkLoadFailure(value: unknown): value is ChunkLoadFailure;
|
|
151
|
+
/**
|
|
152
|
+
* Heuristic for "this looks like a module that failed to load" rather than an
|
|
153
|
+
* error thrown *by* the module once it ran.
|
|
154
|
+
*
|
|
155
|
+
* Deliberately broad: bundlers and browsers disagree on the shape here
|
|
156
|
+
* (`ChunkLoadError`, a bare `TypeError`, a CSP violation), and a false positive
|
|
157
|
+
* costs one wasted retry while a false negative costs the user their session.
|
|
158
|
+
*/
|
|
159
|
+
declare function looksLikeChunkError(error: unknown): boolean;
|
|
160
|
+
interface LazyOptions {
|
|
161
|
+
readonly retryAttempts?: number;
|
|
162
|
+
readonly retryDelayMs?: number;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Wraps a dynamic import for use as `loadChildren` or `loadComponent`.
|
|
166
|
+
*
|
|
167
|
+
* ```ts
|
|
168
|
+
* { path: 'admin', loadChildren: lazy('admin.routes', () => import('./admin/routes')) }
|
|
169
|
+
* { path: 'help', loadComponent: lazy('help.page', () => import('./help').then(m => m.Help)) }
|
|
170
|
+
* ```
|
|
171
|
+
*
|
|
172
|
+
* The returned function is a plain loader — the router needs no knowledge of
|
|
173
|
+
* this library, and routes remain usable if the package is removed.
|
|
174
|
+
*/
|
|
175
|
+
declare function lazy<T>(moduleId: string, loader: () => Promise<T>, options?: LazyOptions): () => Promise<T>;
|
|
176
|
+
declare function lazy<T>(loader: () => Promise<T>, options?: LazyOptions): () => Promise<T>;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Unsaved-work tracking.
|
|
180
|
+
*
|
|
181
|
+
* A hard reload destroys in-memory state, so recovery must know whether that
|
|
182
|
+
* matters. Angular already has a declaration for exactly this — `CanDeactivate`
|
|
183
|
+
* — but the router exposes no way to ask "would a guard block here?", so a
|
|
184
|
+
* library cannot reuse it.
|
|
185
|
+
*
|
|
186
|
+
* This registry is the workaround: components declare dirtiness explicitly and
|
|
187
|
+
* the recovery service consults it. If Angular ever exposes guard
|
|
188
|
+
* introspection, this whole file becomes deletable.
|
|
189
|
+
*/
|
|
190
|
+
declare class UnsavedWorkRegistry {
|
|
191
|
+
private readonly sources;
|
|
192
|
+
/** Registers a predicate. Returns a disposer. */
|
|
193
|
+
register(isDirty: () => boolean): () => void;
|
|
194
|
+
/**
|
|
195
|
+
* True when any registered source reports unsaved work.
|
|
196
|
+
*
|
|
197
|
+
* A throwing predicate is treated as *clean* rather than dirty: a broken
|
|
198
|
+
* dirty-check should not permanently block recovery and strand the user.
|
|
199
|
+
*/
|
|
200
|
+
isDirty(): boolean;
|
|
201
|
+
/** Count of registered sources. Diagnostics only. */
|
|
202
|
+
get size(): number;
|
|
203
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<UnsavedWorkRegistry, never>;
|
|
204
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<UnsavedWorkRegistry>;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Declares that this component holds unsaved work, so automatic recovery will
|
|
208
|
+
* not discard it. Unregisters automatically on destroy.
|
|
209
|
+
*
|
|
210
|
+
* ```ts
|
|
211
|
+
* export class BulletinEditor {
|
|
212
|
+
* protected readonly form = …;
|
|
213
|
+
* constructor() {
|
|
214
|
+
* trackUnsavedWork(() => this.form.dirty);
|
|
215
|
+
* }
|
|
216
|
+
* }
|
|
217
|
+
* ```
|
|
218
|
+
*
|
|
219
|
+
* Must be called in an injection context.
|
|
220
|
+
*/
|
|
221
|
+
declare function trackUnsavedWork(isDirty: () => boolean): void;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Orchestrates recovery from a failed lazy load.
|
|
225
|
+
*
|
|
226
|
+
* Wiring is via router events rather than a global registry: `lazy()` throws a
|
|
227
|
+
* {@link ChunkLoadFailure}, Angular surfaces it as `NavigationError`, and this
|
|
228
|
+
* service recognises it there. That keeps `lazy()` dependency-free and usable
|
|
229
|
+
* in route definitions, which are evaluated long before DI exists.
|
|
230
|
+
*
|
|
231
|
+
* Zoneless-safe and SSR-safe: no `NgZone`, and every `location` /
|
|
232
|
+
* `sessionStorage` touch is guarded.
|
|
233
|
+
*/
|
|
234
|
+
declare class SkewRecoveryService {
|
|
235
|
+
private readonly options;
|
|
236
|
+
private readonly router;
|
|
237
|
+
private readonly document;
|
|
238
|
+
private readonly unsavedWork;
|
|
239
|
+
private readonly isBrowser;
|
|
240
|
+
private readonly probe;
|
|
241
|
+
private readonly pendingSignal;
|
|
242
|
+
private readonly statusSignal;
|
|
243
|
+
/** A recovery the application has been asked to complete itself. */
|
|
244
|
+
readonly pending: i0.Signal<PendingSkew | null>;
|
|
245
|
+
/** Last known comparison against the origin. */
|
|
246
|
+
readonly status: i0.Signal<SkewStatus | null>;
|
|
247
|
+
/** True when a newer deployment is known to exist. */
|
|
248
|
+
readonly updateAvailable: i0.Signal<boolean>;
|
|
249
|
+
constructor();
|
|
250
|
+
private listen;
|
|
251
|
+
/** Re-checks the origin. Safe to call from a "check for updates" control. */
|
|
252
|
+
check(): Promise<SkewStatus | null>;
|
|
253
|
+
/** Completes a deferred recovery — what a "Reload" button calls. */
|
|
254
|
+
recover(): void;
|
|
255
|
+
/** Dismisses a pending recovery without acting on it. */
|
|
256
|
+
dismiss(): void;
|
|
257
|
+
private handle;
|
|
258
|
+
private classify;
|
|
259
|
+
private chooseAction;
|
|
260
|
+
private dispatch;
|
|
261
|
+
private isOnline;
|
|
262
|
+
private assign;
|
|
263
|
+
private reload;
|
|
264
|
+
/**
|
|
265
|
+
* Recoveries already attempted, kept in `sessionStorage` so it survives the
|
|
266
|
+
* very reload it is counting. Without this, an origin serving a stale entry
|
|
267
|
+
* document turns one failure into an infinite reload loop.
|
|
268
|
+
*/
|
|
269
|
+
private recoveryCount;
|
|
270
|
+
private countRecovery;
|
|
271
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<SkewRecoveryService, never>;
|
|
272
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<SkewRecoveryService>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Enables skew recovery.
|
|
277
|
+
*
|
|
278
|
+
* ```ts
|
|
279
|
+
* bootstrapApplication(App, {
|
|
280
|
+
* providers: [
|
|
281
|
+
* provideRouter(routes),
|
|
282
|
+
* provideSkewRecovery({
|
|
283
|
+
* identity: BUILD_IDENTITY, // from @braidlabs/build
|
|
284
|
+
* manifestUrl: '/skew-manifest.json',
|
|
285
|
+
* }),
|
|
286
|
+
* ],
|
|
287
|
+
* });
|
|
288
|
+
* ```
|
|
289
|
+
*
|
|
290
|
+
* Deliberately *not* a `RouterFeature`: Angular's feature factory is internal,
|
|
291
|
+
* so third parties cannot construct one. This composes alongside
|
|
292
|
+
* `provideRouter` instead, and works with any router configuration.
|
|
293
|
+
*
|
|
294
|
+
* The service is instantiated eagerly — it subscribes to router events, and a
|
|
295
|
+
* lazily-created recovery service would miss the very first failure.
|
|
296
|
+
*/
|
|
297
|
+
declare function provideSkewRecovery(options: SkewRecoveryOptions): EnvironmentProviders;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* ⚠️ NOT PUBLIC API. Undocumented on purpose. Do not use in an application.
|
|
301
|
+
*
|
|
302
|
+
* Angular-shaped wrapper around `setSkewDisabled()` from `@braidlabs/skew`. Read the
|
|
303
|
+
* module comment in `@braidlabs/skew`'s `disabled.ts` first — it explains what this
|
|
304
|
+
* turns off, why it exists, and why it is deliberately absent from the README,
|
|
305
|
+
* the package docs, and every example.
|
|
306
|
+
*
|
|
307
|
+
* ```ts
|
|
308
|
+
* // demo apps only
|
|
309
|
+
* providers: [provideSkewRecovery({ … }), provideSkewDisabled()]
|
|
310
|
+
* ```
|
|
311
|
+
*
|
|
312
|
+
* Two things worth knowing before using it anyway:
|
|
313
|
+
*
|
|
314
|
+
* **It is not scoped to this injector.** The flag lives in a module-level
|
|
315
|
+
* variable in `@braidlabs/skew`, because core is framework-agnostic and has no
|
|
316
|
+
* injector to read from. Calling this affects every `@braidlabs` package sharing
|
|
317
|
+
* that instance of core — including any other Angular application on the page.
|
|
318
|
+
* It is `provide*`-shaped for familiarity, not because it is scoped.
|
|
319
|
+
*
|
|
320
|
+
* **It applies at bootstrap.** For a control that flips at runtime — which is
|
|
321
|
+
* what a before/after demo actually wants — call `setSkewDisabled()` directly.
|
|
322
|
+
* Every check reads the flag at the moment it runs, so a later toggle takes
|
|
323
|
+
* effect on the next read, navigation or write with no reload.
|
|
324
|
+
*
|
|
325
|
+
* This lives in `@braidlabs/angular-router` for want of a shared Angular package;
|
|
326
|
+
* it belongs in `@braidlabs/angular-core` once that lands, since it governs all of
|
|
327
|
+
* them and nothing here is router-specific.
|
|
328
|
+
*
|
|
329
|
+
* @internal
|
|
330
|
+
*/
|
|
331
|
+
declare function provideSkewDisabled(disabled?: boolean): EnvironmentProviders;
|
|
332
|
+
|
|
333
|
+
export { ChunkLoadFailure, SKEW_RECOVERY_OPTIONS, SkewRecoveryService, UnsavedWorkRegistry, isChunkLoadFailure, lazy, lazyDefaults, looksLikeChunkError, provideSkewDisabled, provideSkewRecovery, resetLazyDefaults, resolveOptions, trackUnsavedWork };
|
|
334
|
+
export type { LazyOptions, PendingSkew, ResolvedSkewRecoveryOptions, SkewRecoveryOptions, StaleChunkAction, StaleChunkContext, StaleChunkStrategy };
|