@memberjunction/ng-explorer-service-worker 0.0.1 → 5.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -28
- package/dist/lib/mj-service-worker.module.d.ts +68 -0
- package/dist/lib/mj-service-worker.module.d.ts.map +1 -0
- package/dist/lib/mj-service-worker.module.js +54 -0
- package/dist/lib/mj-service-worker.module.js.map +1 -0
- package/dist/lib/update-notification.component.d.ts +21 -0
- package/dist/lib/update-notification.component.d.ts.map +1 -0
- package/dist/lib/update-notification.component.js +109 -0
- package/dist/lib/update-notification.component.js.map +1 -0
- package/dist/lib/update-notification.service.d.ts +92 -0
- package/dist/lib/update-notification.service.d.ts.map +1 -0
- package/dist/lib/update-notification.service.js +226 -0
- package/dist/lib/update-notification.service.js.map +1 -0
- package/dist/public-api.d.ts +4 -0
- package/dist/public-api.d.ts.map +1 -0
- package/dist/public-api.js +7 -0
- package/dist/public-api.js.map +1 -0
- package/ngsw-config.json +52 -0
- package/package.json +40 -6
- package/scripts/verify-sw-cache-shape.js +107 -0
package/README.md
CHANGED
|
@@ -1,45 +1,151 @@
|
|
|
1
1
|
# @memberjunction/ng-explorer-service-worker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Service worker app-shell pre-cache + update-notification toast for MJ Explorer-style apps.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## What this package gives you
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- **`ngsw-config.json`** shipped at the package root — pre-tuned cache strategy (app-shell, lazy assets, GraphQL exclusions) that updates with `npm update`.
|
|
8
|
+
- **`MJServiceWorkerModule.forRoot({ enabled })`** — wraps `ServiceWorkerModule.register(...)` with sensible defaults (deferred registration so the SW doesn't fight initial boot for bandwidth).
|
|
9
|
+
- **`UpdateNotificationService`** — RxJS-friendly wrapper around `SwUpdate`.
|
|
10
|
+
- **`<mj-update-notification>`** — standalone toast component, MJ design tokens, OnPush.
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
If you're using `@memberjunction/ng-explorer-app`, `MJExplorerAppModule.forRoot(environment)` already wires this up internally. You only need to touch your shell directly if you're building a non-Explorer Angular app.
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
1. Configure OIDC trusted publishing for the package name `@memberjunction/ng-explorer-service-worker`
|
|
13
|
-
2. Enable secure, token-less publishing from CI/CD workflows
|
|
14
|
-
3. Establish provenance for packages published under this name
|
|
14
|
+
## Consumer setup
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
For a typical MJ Explorer-style app, you only edit two files:
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
### 1. `angular.json` — point the SW config at our shipped file
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"projects": {
|
|
23
|
+
"your-app": {
|
|
24
|
+
"architect": {
|
|
25
|
+
"build": {
|
|
26
|
+
"configurations": {
|
|
27
|
+
"production": {
|
|
28
|
+
"serviceWorker": "node_modules/@memberjunction/ng-explorer-service-worker/ngsw-config.json"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
21
37
|
|
|
22
|
-
|
|
38
|
+
### 2. `environment.ts` — opt-in kill switch
|
|
23
39
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
```typescript
|
|
41
|
+
export const environment = {
|
|
42
|
+
production: true,
|
|
43
|
+
enableServiceWorker: true, // master kill switch — turn off in an emergency
|
|
44
|
+
// ...
|
|
45
|
+
};
|
|
46
|
+
```
|
|
28
47
|
|
|
29
|
-
|
|
48
|
+
That's it. `MJExplorerAppModule.forRoot(environment)` reads `environment.enableServiceWorker` and turns the SW on when (and only when) `production && enableServiceWorker` are both true.
|
|
30
49
|
|
|
31
|
-
|
|
32
|
-
- Contains no executable code
|
|
33
|
-
- Provides no functionality
|
|
34
|
-
- Should not be installed as a dependency
|
|
35
|
-
- Exists only for administrative purposes
|
|
50
|
+
If you skip the angular.json edit, no `ngsw-worker.js` is built and the runtime registration silently no-ops. App behaves exactly as it does today.
|
|
36
51
|
|
|
37
|
-
##
|
|
52
|
+
## Optional consumer enhancements
|
|
38
53
|
|
|
39
|
-
|
|
40
|
-
- [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
|
|
41
|
-
- [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
|
|
54
|
+
These are recommended but not required. The SW works fine without them — they're polish items that depend on consumer-side files we can't reach from this npm package.
|
|
42
55
|
|
|
43
|
-
|
|
56
|
+
### Backport for older versions: inline theme preload script
|
|
44
57
|
|
|
45
|
-
**
|
|
58
|
+
**MJExplorer ships this by default starting with the same release as this package** — new installs get instant correct theming for free, no action required.
|
|
59
|
+
|
|
60
|
+
If you're maintaining an older MJExplorer-based app (or a non-Explorer app that uses MJ's theme system) and want the same benefit, add the snippet below to your app's `src/index.html` inside `<head>`, anywhere before any stylesheets. We can't inject HTML into your `index.html` from an npm package, so this lives on the consumer side.
|
|
61
|
+
|
|
62
|
+
Without this, dark-mode users see a brief light-mode flash on every load before MJ's bootstrap code applies their saved theme. The Angular bootstrap path is too late to set the theme before first paint — the script must be inline in `<head>`.
|
|
63
|
+
|
|
64
|
+
```html
|
|
65
|
+
<script>
|
|
66
|
+
(function () {
|
|
67
|
+
try {
|
|
68
|
+
var saved = localStorage.getItem('mj-theme');
|
|
69
|
+
var isDark = saved
|
|
70
|
+
? saved === 'dark'
|
|
71
|
+
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
72
|
+
if (isDark) document.documentElement.setAttribute('data-theme', 'dark');
|
|
73
|
+
} catch (e) { /* swallow — theme just falls back to light */ }
|
|
74
|
+
})();
|
|
75
|
+
</script>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Reads `mj-theme`, the unified theme key written by:
|
|
79
|
+
|
|
80
|
+
- `ThemeService.applyBaseThemeAttribute()` post-login — mirrors the resolved base theme (`light` or `dark`), including the OS-resolved value for `'system'` preference
|
|
81
|
+
- `MJExplorerAppComponent.ToggleTheme()` when the user toggles theme on the login screen
|
|
82
|
+
|
|
83
|
+
The three readers (this inline script, `MJExplorerAppComponent.applyLoginTheme`, `ThemeService`) all share `mj-theme` as the single source of truth, so they paint in sync with no flash.
|
|
84
|
+
|
|
85
|
+
`mj-theme` is preserved across logout by `MJExplorerAuthBase.preservedLocalStorageKeys` so the login screen continues to show the user's last theme without flashing back to OS default.
|
|
86
|
+
|
|
87
|
+
Falls back to OS `prefers-color-scheme` on true first visits with no saved preference. Wrapped in try/catch so localStorage exceptions (Safari private mode, disabled storage) can never block app load.
|
|
88
|
+
|
|
89
|
+
Combined with the SW app-shell pre-cache, dark-mode users see correct theme paint within ~100ms of navigation start.
|
|
90
|
+
|
|
91
|
+
### Backport for older versions: build-time SW cache-shape gate
|
|
92
|
+
|
|
93
|
+
**MJExplorer ships this by default starting with the same release as this package** — new installs get the build-time defense automatically.
|
|
94
|
+
|
|
95
|
+
If you're maintaining an older MJExplorer-based app and want the same safety net, add the snippet below to your `package.json`. The gate guards against future Angular releases that introduce new build-output filename prefixes (`runtime-*`, `vendor-*`, etc.) that our `ngsw-config.json` doesn't classify. Without it, such a change would silently bypass the SW cache for those new files. The gate fails the build with a diagnostic listing the unrecognized filenames so the issue is caught at your build time, not in production.
|
|
96
|
+
|
|
97
|
+
Add to your app's `package.json` scripts:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"scripts": {
|
|
102
|
+
"postbuild": "node node_modules/@memberjunction/ng-explorer-service-worker/scripts/verify-sw-cache-shape.js dist/<your-app-name>/browser"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Replace `<your-app-name>` with whatever Angular emits to (check your `angular.json` `outputPath`).
|
|
108
|
+
|
|
109
|
+
The script exits 0 when every root-level `.js` and `.css` file matches a known prefix (`main-`, `polyfills-`, `chunk-`, `styles-`) and ignores SW infrastructure files (`ngsw-worker.js`, `ngsw.json`, `safety-worker.js`, `worker-basic.min.js`). Exits 1 with a clear diagnostic on anything unrecognized.
|
|
110
|
+
|
|
111
|
+
Without this opt-in, the regression tests inside the SW package (`ngsw-config-shape.test.ts`, `verify-sw-cache-shape.test.ts`) still catch config-edit regressions at MJ-monorepo PR review time — but only consumer build-time invocation can catch downstream Angular naming changes.
|
|
112
|
+
|
|
113
|
+
## Manual wiring (non-Explorer apps)
|
|
114
|
+
|
|
115
|
+
If you're not using `MJExplorerAppModule`, wire it yourself:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import { MJServiceWorkerModule } from '@memberjunction/ng-explorer-service-worker';
|
|
119
|
+
|
|
120
|
+
@NgModule({
|
|
121
|
+
imports: [
|
|
122
|
+
MJServiceWorkerModule.forRoot({
|
|
123
|
+
enabled: environment.production && environment.enableServiceWorker
|
|
124
|
+
})
|
|
125
|
+
]
|
|
126
|
+
})
|
|
127
|
+
export class AppModule {}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
And drop the toast somewhere in your shell template:
|
|
131
|
+
|
|
132
|
+
```html
|
|
133
|
+
<mj-update-notification />
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## How update notifications work
|
|
137
|
+
|
|
138
|
+
1. SW polls for new versions in the background (Angular default cadence).
|
|
139
|
+
2. When a new version is fully downloaded, `SwUpdate.versionUpdates` emits `VERSION_READY`.
|
|
140
|
+
3. `UpdateNotificationService` flips `updateAvailable$` to `true`.
|
|
141
|
+
4. `<mj-update-notification>` renders a bottom-right toast with **Reload** / **Later**.
|
|
142
|
+
5. **Reload** triggers `document.location.reload()`, which activates the new SW version.
|
|
143
|
+
6. **Later** dismisses for the session; next reload picks up the update naturally.
|
|
144
|
+
|
|
145
|
+
## Customization
|
|
146
|
+
|
|
147
|
+
To override the cache strategy for your app, copy `ngsw-config.json` into your repo and point `angular.json` at the local copy. You'll lose automatic updates to the cache rules but can tune freely.
|
|
148
|
+
|
|
149
|
+
## Ops runbook
|
|
150
|
+
|
|
151
|
+
See `docs/operations/SERVICE_WORKER_RUNBOOK.md` in the MJ repo for kill-switch procedures, cache invalidation, and rollback playbook.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ModuleWithProviders } from '@angular/core';
|
|
2
|
+
import * as i0 from "@angular/core";
|
|
3
|
+
import * as i1 from "./update-notification.component";
|
|
4
|
+
/**
|
|
5
|
+
* Options for configuring `MJServiceWorkerModule.forRoot()`.
|
|
6
|
+
*/
|
|
7
|
+
export interface MJServiceWorkerOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Master enable flag — when `false`, no service worker is registered and
|
|
10
|
+
* `SwUpdate.isEnabled` resolves to `false`. The toast component is always
|
|
11
|
+
* exported, but it renders nothing when SW is disabled.
|
|
12
|
+
*
|
|
13
|
+
* Wire this to something like `environment.production && environment.enableServiceWorker`
|
|
14
|
+
* so dev builds and ops kill-switches both turn it off cleanly.
|
|
15
|
+
*/
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The path to the generated SW worker script, relative to the deployed
|
|
19
|
+
* app root. Defaults to `'ngsw-worker.js'` (the Angular CLI default).
|
|
20
|
+
* Override only if you've customized the build output.
|
|
21
|
+
*/
|
|
22
|
+
scriptPath?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Registration strategy passed to `ServiceWorkerModule.register`.
|
|
25
|
+
* Defaults to `'registerWhenStable:30000'` — defer registration until
|
|
26
|
+
* the app is idle for 30s OR 30s have elapsed, whichever first. Avoids
|
|
27
|
+
* competing with initial app boot for network bandwidth.
|
|
28
|
+
*/
|
|
29
|
+
registrationStrategy?: string;
|
|
30
|
+
/**
|
|
31
|
+
* How often (in ms) UpdateNotificationService should ask the SW to check
|
|
32
|
+
* for a new manifest while the tab is visible. Defaults to 15 minutes.
|
|
33
|
+
* Pass `0` to disable periodic checking entirely (the SW's internal poll
|
|
34
|
+
* still runs, but on a much longer cadence — typically hours).
|
|
35
|
+
*
|
|
36
|
+
* Note: this is wired by `UpdateNotificationService` itself in its
|
|
37
|
+
* constructor; we don't currently provide a DI token for it. To override
|
|
38
|
+
* at runtime, inject `UpdateNotificationService` and call
|
|
39
|
+
* `service.startAutoCheck(yourIntervalMs)`.
|
|
40
|
+
*/
|
|
41
|
+
pollIntervalMs?: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Bundles MJ Explorer's service worker concerns into one module:
|
|
45
|
+
* - Conditionally registers `@angular/service-worker` based on `enabled`
|
|
46
|
+
* - Exports `UpdateNotificationComponent` (standalone toast)
|
|
47
|
+
* - Exposes `UpdateNotificationService` via `providedIn: 'root'`
|
|
48
|
+
*
|
|
49
|
+
* Consumers should call `MJServiceWorkerModule.forRoot({ enabled: ... })`
|
|
50
|
+
* exactly once in their root NgModule. Typically this is done internally by
|
|
51
|
+
* `MJExplorerAppModule.forRoot(environment)` so MJExplorer (and downstream
|
|
52
|
+
* Explorer-style apps) don't have to wire it themselves.
|
|
53
|
+
*
|
|
54
|
+
* Pre-built `ngsw-config.json` ships at the package root; consumers point
|
|
55
|
+
* their `angular.json` `serviceWorker` field at:
|
|
56
|
+
* `node_modules/@memberjunction/ng-explorer-service-worker/ngsw-config.json`
|
|
57
|
+
*
|
|
58
|
+
* If a consumer doesn't update `angular.json` (no SW worker built) AND
|
|
59
|
+
* doesn't flip `enabled` true in their environment, this module is a no-op.
|
|
60
|
+
* The app keeps working exactly as if the SW didn't exist.
|
|
61
|
+
*/
|
|
62
|
+
export declare class MJServiceWorkerModule {
|
|
63
|
+
static forRoot(options: MJServiceWorkerOptions): ModuleWithProviders<MJServiceWorkerModule>;
|
|
64
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<MJServiceWorkerModule, never>;
|
|
65
|
+
static ɵmod: i0.ɵɵNgModuleDeclaration<MJServiceWorkerModule, never, [typeof i1.UpdateNotificationComponent], [typeof i1.UpdateNotificationComponent]>;
|
|
66
|
+
static ɵinj: i0.ɵɵInjectorDeclaration<MJServiceWorkerModule>;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=mj-service-worker.module.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mj-service-worker.module.d.ts","sourceRoot":"","sources":["../../src/lib/mj-service-worker.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,mBAAmB,EAAE,MAAM,eAAe,CAAC;;;AAI9D;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACnC;;;;;;;OAOG;IACH,OAAO,EAAE,OAAO,CAAC;IAEjB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAE9B;;;;;;;;;;OAUG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAIa,qBAAqB;IAC9B,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,sBAAsB,GAAG,mBAAmB,CAAC,qBAAqB,CAAC;yCADlF,qBAAqB;0CAArB,qBAAqB;0CAArB,qBAAqB;CAmBjC"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { NgModule } from '@angular/core';
|
|
2
|
+
import { ServiceWorkerModule } from '@angular/service-worker';
|
|
3
|
+
import { UpdateNotificationComponent } from './update-notification.component';
|
|
4
|
+
import * as i0 from "@angular/core";
|
|
5
|
+
/**
|
|
6
|
+
* Bundles MJ Explorer's service worker concerns into one module:
|
|
7
|
+
* - Conditionally registers `@angular/service-worker` based on `enabled`
|
|
8
|
+
* - Exports `UpdateNotificationComponent` (standalone toast)
|
|
9
|
+
* - Exposes `UpdateNotificationService` via `providedIn: 'root'`
|
|
10
|
+
*
|
|
11
|
+
* Consumers should call `MJServiceWorkerModule.forRoot({ enabled: ... })`
|
|
12
|
+
* exactly once in their root NgModule. Typically this is done internally by
|
|
13
|
+
* `MJExplorerAppModule.forRoot(environment)` so MJExplorer (and downstream
|
|
14
|
+
* Explorer-style apps) don't have to wire it themselves.
|
|
15
|
+
*
|
|
16
|
+
* Pre-built `ngsw-config.json` ships at the package root; consumers point
|
|
17
|
+
* their `angular.json` `serviceWorker` field at:
|
|
18
|
+
* `node_modules/@memberjunction/ng-explorer-service-worker/ngsw-config.json`
|
|
19
|
+
*
|
|
20
|
+
* If a consumer doesn't update `angular.json` (no SW worker built) AND
|
|
21
|
+
* doesn't flip `enabled` true in their environment, this module is a no-op.
|
|
22
|
+
* The app keeps working exactly as if the SW didn't exist.
|
|
23
|
+
*/
|
|
24
|
+
export class MJServiceWorkerModule {
|
|
25
|
+
static forRoot(options) {
|
|
26
|
+
// ServiceWorkerModule.register is always called — the `enabled` flag
|
|
27
|
+
// tells Angular whether to actually attempt registration. We pass the
|
|
28
|
+
// module reference into the providers chain so the SwUpdate token tree
|
|
29
|
+
// is set up regardless (so injection always succeeds; it just reports
|
|
30
|
+
// isEnabled = false when disabled).
|
|
31
|
+
const swModule = ServiceWorkerModule.register(options.scriptPath ?? 'ngsw-worker.js', {
|
|
32
|
+
enabled: options.enabled,
|
|
33
|
+
registrationStrategy: options.registrationStrategy ?? 'registerWhenStable:30000'
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
ngModule: MJServiceWorkerModule,
|
|
37
|
+
providers: [
|
|
38
|
+
...(swModule.providers ?? [])
|
|
39
|
+
]
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
static ɵfac = function MJServiceWorkerModule_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || MJServiceWorkerModule)(); };
|
|
43
|
+
static ɵmod = /*@__PURE__*/ i0.ɵɵdefineNgModule({ type: MJServiceWorkerModule });
|
|
44
|
+
static ɵinj = /*@__PURE__*/ i0.ɵɵdefineInjector({ imports: [UpdateNotificationComponent] });
|
|
45
|
+
}
|
|
46
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MJServiceWorkerModule, [{
|
|
47
|
+
type: NgModule,
|
|
48
|
+
args: [{
|
|
49
|
+
imports: [UpdateNotificationComponent],
|
|
50
|
+
exports: [UpdateNotificationComponent]
|
|
51
|
+
}]
|
|
52
|
+
}], null, null); })();
|
|
53
|
+
(function () { (typeof ngJitMode === "undefined" || ngJitMode) && i0.ɵɵsetNgModuleScope(MJServiceWorkerModule, { imports: [UpdateNotificationComponent], exports: [UpdateNotificationComponent] }); })();
|
|
54
|
+
//# sourceMappingURL=mj-service-worker.module.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mj-service-worker.module.js","sourceRoot":"","sources":["../../src/lib/mj-service-worker.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAuB,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,EAAE,2BAA2B,EAAE,MAAM,iCAAiC,CAAC;;AA6C9E;;;;;;;;;;;;;;;;;;GAkBG;AAKH,MAAM,OAAO,qBAAqB;IAC9B,MAAM,CAAC,OAAO,CAAC,OAA+B;QAC1C,qEAAqE;QACrE,sEAAsE;QACtE,uEAAuE;QACvE,sEAAsE;QACtE,oCAAoC;QACpC,MAAM,QAAQ,GAAG,mBAAmB,CAAC,QAAQ,CAAC,OAAO,CAAC,UAAU,IAAI,gBAAgB,EAAE;YAClF,OAAO,EAAE,OAAO,CAAC,OAAO;YACxB,oBAAoB,EAAE,OAAO,CAAC,oBAAoB,IAAI,0BAA0B;SACnF,CAAC,CAAC;QAEH,OAAO;YACH,QAAQ,EAAE,qBAAqB;YAC/B,SAAS,EAAE;gBACP,GAAG,CAAC,QAAQ,CAAC,SAAS,IAAI,EAAE,CAAC;aAChC;SACJ,CAAC;IACN,CAAC;+GAlBQ,qBAAqB;4DAArB,qBAAqB;gEAHpB,2BAA2B;;iFAG5B,qBAAqB;cAJjC,QAAQ;eAAC;gBACN,OAAO,EAAE,CAAC,2BAA2B,CAAC;gBACtC,OAAO,EAAE,CAAC,2BAA2B,CAAC;aACzC;;wFACY,qBAAqB,cAHpB,2BAA2B,aAC3B,2BAA2B","sourcesContent":["import { NgModule, ModuleWithProviders } from '@angular/core';\nimport { ServiceWorkerModule } from '@angular/service-worker';\nimport { UpdateNotificationComponent } from './update-notification.component';\n\n/**\n * Options for configuring `MJServiceWorkerModule.forRoot()`.\n */\nexport interface MJServiceWorkerOptions {\n /**\n * Master enable flag — when `false`, no service worker is registered and\n * `SwUpdate.isEnabled` resolves to `false`. The toast component is always\n * exported, but it renders nothing when SW is disabled.\n *\n * Wire this to something like `environment.production && environment.enableServiceWorker`\n * so dev builds and ops kill-switches both turn it off cleanly.\n */\n enabled: boolean;\n\n /**\n * The path to the generated SW worker script, relative to the deployed\n * app root. Defaults to `'ngsw-worker.js'` (the Angular CLI default).\n * Override only if you've customized the build output.\n */\n scriptPath?: string;\n\n /**\n * Registration strategy passed to `ServiceWorkerModule.register`.\n * Defaults to `'registerWhenStable:30000'` — defer registration until\n * the app is idle for 30s OR 30s have elapsed, whichever first. Avoids\n * competing with initial app boot for network bandwidth.\n */\n registrationStrategy?: string;\n\n /**\n * How often (in ms) UpdateNotificationService should ask the SW to check\n * for a new manifest while the tab is visible. Defaults to 15 minutes.\n * Pass `0` to disable periodic checking entirely (the SW's internal poll\n * still runs, but on a much longer cadence — typically hours).\n *\n * Note: this is wired by `UpdateNotificationService` itself in its\n * constructor; we don't currently provide a DI token for it. To override\n * at runtime, inject `UpdateNotificationService` and call\n * `service.startAutoCheck(yourIntervalMs)`.\n */\n pollIntervalMs?: number;\n}\n\n/**\n * Bundles MJ Explorer's service worker concerns into one module:\n * - Conditionally registers `@angular/service-worker` based on `enabled`\n * - Exports `UpdateNotificationComponent` (standalone toast)\n * - Exposes `UpdateNotificationService` via `providedIn: 'root'`\n *\n * Consumers should call `MJServiceWorkerModule.forRoot({ enabled: ... })`\n * exactly once in their root NgModule. Typically this is done internally by\n * `MJExplorerAppModule.forRoot(environment)` so MJExplorer (and downstream\n * Explorer-style apps) don't have to wire it themselves.\n *\n * Pre-built `ngsw-config.json` ships at the package root; consumers point\n * their `angular.json` `serviceWorker` field at:\n * `node_modules/@memberjunction/ng-explorer-service-worker/ngsw-config.json`\n *\n * If a consumer doesn't update `angular.json` (no SW worker built) AND\n * doesn't flip `enabled` true in their environment, this module is a no-op.\n * The app keeps working exactly as if the SW didn't exist.\n */\n@NgModule({\n imports: [UpdateNotificationComponent],\n exports: [UpdateNotificationComponent]\n})\nexport class MJServiceWorkerModule {\n static forRoot(options: MJServiceWorkerOptions): ModuleWithProviders<MJServiceWorkerModule> {\n // ServiceWorkerModule.register is always called — the `enabled` flag\n // tells Angular whether to actually attempt registration. We pass the\n // module reference into the providers chain so the SwUpdate token tree\n // is set up regardless (so injection always succeeds; it just reports\n // isEnabled = false when disabled).\n const swModule = ServiceWorkerModule.register(options.scriptPath ?? 'ngsw-worker.js', {\n enabled: options.enabled,\n registrationStrategy: options.registrationStrategy ?? 'registerWhenStable:30000'\n });\n\n return {\n ngModule: MJServiceWorkerModule,\n providers: [\n ...(swModule.providers ?? [])\n ]\n };\n }\n}\n"]}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { UpdateNotificationService } from './update-notification.service';
|
|
2
|
+
import * as i0 from "@angular/core";
|
|
3
|
+
/**
|
|
4
|
+
* Renders a small bottom-right toast when a new app version is available.
|
|
5
|
+
* Two actions: "Reload" applies the update; "Later" dismisses for the session.
|
|
6
|
+
*
|
|
7
|
+
* Standalone component — no NgModule needed, drop into the shell template
|
|
8
|
+
* with `<mj-update-notification></mj-update-notification>`.
|
|
9
|
+
*
|
|
10
|
+
* Visual design uses MJ design tokens (no hardcoded colors) so it picks up
|
|
11
|
+
* dark mode + brand customization automatically. Slide-up entrance animation
|
|
12
|
+
* + subtle pulse on the icon to draw attention without being aggressive.
|
|
13
|
+
*/
|
|
14
|
+
export declare class UpdateNotificationComponent {
|
|
15
|
+
readonly updateService: UpdateNotificationService;
|
|
16
|
+
onReload(): void;
|
|
17
|
+
onDismiss(): void;
|
|
18
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<UpdateNotificationComponent, never>;
|
|
19
|
+
static ɵcmp: i0.ɵɵComponentDeclaration<UpdateNotificationComponent, "mj-update-notification", never, {}, {}, never, never, true, never>;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=update-notification.component.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"update-notification.component.d.ts","sourceRoot":"","sources":["../../src/lib/update-notification.component.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAC;;AAE1E;;;;;;;;;;GAUG;AACH,qBA8Na,2BAA2B;IACpC,SAAgB,aAAa,4BAAqC;IAElE,QAAQ,IAAI,IAAI;IAIhB,SAAS,IAAI,IAAI;yCAPR,2BAA2B;2CAA3B,2BAA2B;CAUvC"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
|
2
|
+
import { CommonModule } from '@angular/common';
|
|
3
|
+
import { UpdateNotificationService } from './update-notification.service';
|
|
4
|
+
import * as i0 from "@angular/core";
|
|
5
|
+
import * as i1 from "@angular/common";
|
|
6
|
+
function UpdateNotificationComponent_Conditional_0_Template(rf, ctx) { if (rf & 1) {
|
|
7
|
+
const _r1 = i0.ɵɵgetCurrentView();
|
|
8
|
+
i0.ɵɵdomElementStart(0, "div", 0)(1, "div", 1);
|
|
9
|
+
i0.ɵɵdomElement(2, "i", 2);
|
|
10
|
+
i0.ɵɵdomElementEnd();
|
|
11
|
+
i0.ɵɵdomElementStart(3, "div", 3)(4, "div", 4);
|
|
12
|
+
i0.ɵɵtext(5, " Update available (test build #7) ");
|
|
13
|
+
i0.ɵɵdomElementEnd();
|
|
14
|
+
i0.ɵɵdomElementStart(6, "div", 5);
|
|
15
|
+
i0.ɵɵtext(7, " A newer version of MemberJunction has been downloaded. Reload now to apply, or keep working and we'll prompt you again next time. ");
|
|
16
|
+
i0.ɵɵdomElementEnd();
|
|
17
|
+
i0.ɵɵdomElementStart(8, "div", 6)(9, "button", 7);
|
|
18
|
+
i0.ɵɵdomListener("click", function UpdateNotificationComponent_Conditional_0_Template_button_click_9_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onReload()); });
|
|
19
|
+
i0.ɵɵdomElement(10, "i", 8);
|
|
20
|
+
i0.ɵɵdomElementStart(11, "span");
|
|
21
|
+
i0.ɵɵtext(12, "Reload now");
|
|
22
|
+
i0.ɵɵdomElementEnd()();
|
|
23
|
+
i0.ɵɵdomElementStart(13, "button", 9);
|
|
24
|
+
i0.ɵɵdomListener("click", function UpdateNotificationComponent_Conditional_0_Template_button_click_13_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onDismiss()); });
|
|
25
|
+
i0.ɵɵtext(14, " Later ");
|
|
26
|
+
i0.ɵɵdomElementEnd()()();
|
|
27
|
+
i0.ɵɵdomElementStart(15, "button", 10);
|
|
28
|
+
i0.ɵɵdomListener("click", function UpdateNotificationComponent_Conditional_0_Template_button_click_15_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onDismiss()); });
|
|
29
|
+
i0.ɵɵdomElement(16, "i", 11);
|
|
30
|
+
i0.ɵɵdomElementEnd()();
|
|
31
|
+
} }
|
|
32
|
+
/**
|
|
33
|
+
* Renders a small bottom-right toast when a new app version is available.
|
|
34
|
+
* Two actions: "Reload" applies the update; "Later" dismisses for the session.
|
|
35
|
+
*
|
|
36
|
+
* Standalone component — no NgModule needed, drop into the shell template
|
|
37
|
+
* with `<mj-update-notification></mj-update-notification>`.
|
|
38
|
+
*
|
|
39
|
+
* Visual design uses MJ design tokens (no hardcoded colors) so it picks up
|
|
40
|
+
* dark mode + brand customization automatically. Slide-up entrance animation
|
|
41
|
+
* + subtle pulse on the icon to draw attention without being aggressive.
|
|
42
|
+
*/
|
|
43
|
+
export class UpdateNotificationComponent {
|
|
44
|
+
updateService = inject(UpdateNotificationService);
|
|
45
|
+
onReload() {
|
|
46
|
+
this.updateService.applyUpdate();
|
|
47
|
+
}
|
|
48
|
+
onDismiss() {
|
|
49
|
+
this.updateService.dismissForSession();
|
|
50
|
+
}
|
|
51
|
+
static ɵfac = function UpdateNotificationComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || UpdateNotificationComponent)(); };
|
|
52
|
+
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: UpdateNotificationComponent, selectors: [["mj-update-notification"]], decls: 2, vars: 3, consts: [["role", "status", "aria-live", "polite", 1, "mj-sw-update-toast"], ["aria-hidden", "true", 1, "mj-sw-update-toast__icon"], [1, "fa-solid", "fa-arrows-rotate"], [1, "mj-sw-update-toast__content"], [1, "mj-sw-update-toast__title"], [1, "mj-sw-update-toast__subtitle"], [1, "mj-sw-update-toast__actions"], ["type", "button", 1, "mj-sw-update-toast__btn", "mj-sw-update-toast__btn--primary", 3, "click"], ["aria-hidden", "true", 1, "fa-solid", "fa-rotate-right"], ["type", "button", 1, "mj-sw-update-toast__btn", "mj-sw-update-toast__btn--secondary", 3, "click"], ["type", "button", "aria-label", "Dismiss", 1, "mj-sw-update-toast__close", 3, "click"], ["aria-hidden", "true", 1, "fa-solid", "fa-xmark"]], template: function UpdateNotificationComponent_Template(rf, ctx) { if (rf & 1) {
|
|
53
|
+
i0.ɵɵconditionalCreate(0, UpdateNotificationComponent_Conditional_0_Template, 17, 0, "div", 0);
|
|
54
|
+
i0.ɵɵpipe(1, "async");
|
|
55
|
+
} if (rf & 2) {
|
|
56
|
+
i0.ɵɵconditional(i0.ɵɵpipeBind1(1, 1, ctx.updateService.updateAvailable$) ? 0 : -1);
|
|
57
|
+
} }, dependencies: [CommonModule, i1.AsyncPipe], styles: ["[_nghost-%COMP%] {\n position: fixed;\n bottom: 24px;\n right: 24px;\n z-index: 1000;\n pointer-events: none;\n max-width: 100%;\n }\n\n @keyframes _ngcontent-%COMP%_mj-sw-toast-in {\n from {\n opacity: 0;\n transform: translateY(16px) scale(0.98);\n }\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n }\n\n @keyframes _ngcontent-%COMP%_mj-sw-toast-pulse {\n 0%, 100% {\n box-shadow: 0 0 0 0 color-mix(in srgb, var(--mj-brand-primary) 35%, transparent);\n }\n 50% {\n box-shadow: 0 0 0 8px color-mix(in srgb, var(--mj-brand-primary) 0%, transparent);\n }\n }\n\n .mj-sw-update-toast[_ngcontent-%COMP%] {\n display: flex;\n align-items: flex-start;\n gap: 14px;\n position: relative;\n background: var(--mj-bg-surface-elevated);\n color: var(--mj-text-primary);\n border: 1px solid var(--mj-border-default);\n border-radius: 12px;\n padding: 16px 18px 16px 16px;\n width: 380px;\n max-width: calc(100vw - 48px);\n box-shadow:\n 0 1px 2px color-mix(in srgb, var(--mj-text-primary) 6%, transparent),\n 0 12px 32px color-mix(in srgb, var(--mj-text-primary) 16%, transparent);\n pointer-events: auto;\n font-size: 13.5px;\n line-height: 1.45;\n animation: _ngcontent-%COMP%_mj-sw-toast-in 220ms cubic-bezier(0.2, 0.9, 0.32, 1.15) both;\n }\n\n .mj-sw-update-toast__icon[_ngcontent-%COMP%] {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n display: grid;\n place-items: center;\n background: color-mix(in srgb, var(--mj-brand-primary) 12%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n font-size: 16px;\n animation: _ngcontent-%COMP%_mj-sw-toast-pulse 2.4s ease-in-out 0.4s 2;\n }\n\n .mj-sw-update-toast__content[_ngcontent-%COMP%] {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n\n .mj-sw-update-toast__title[_ngcontent-%COMP%] {\n color: var(--mj-text-primary);\n font-weight: 600;\n font-size: 14px;\n letter-spacing: -0.005em;\n }\n\n .mj-sw-update-toast__subtitle[_ngcontent-%COMP%] {\n color: var(--mj-text-secondary);\n font-size: 12.5px;\n line-height: 1.5;\n margin-bottom: 4px;\n }\n\n .mj-sw-update-toast__actions[_ngcontent-%COMP%] {\n display: flex;\n gap: 8px;\n margin-top: 8px;\n }\n\n .mj-sw-update-toast__btn[_ngcontent-%COMP%] {\n font: inherit;\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n padding: 7px 14px;\n border-radius: 8px;\n border: 1px solid transparent;\n line-height: 1.2;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.08s ease;\n }\n .mj-sw-update-toast__btn[_ngcontent-%COMP%]:active {\n transform: translateY(1px);\n }\n .mj-sw-update-toast__btn[_ngcontent-%COMP%]:focus-visible {\n outline: 2px solid var(--mj-border-focus);\n outline-offset: 2px;\n }\n\n .mj-sw-update-toast__btn--primary[_ngcontent-%COMP%] {\n background: var(--mj-brand-primary);\n color: var(--mj-text-inverse);\n }\n .mj-sw-update-toast__btn--primary[_ngcontent-%COMP%]:hover {\n background: var(--mj-brand-primary-hover);\n }\n .mj-sw-update-toast__btn--primary[_ngcontent-%COMP%]:active {\n background: var(--mj-brand-primary-active);\n }\n\n .mj-sw-update-toast__btn--secondary[_ngcontent-%COMP%] {\n background: transparent;\n border-color: var(--mj-border-default);\n color: var(--mj-text-secondary);\n }\n .mj-sw-update-toast__btn--secondary[_ngcontent-%COMP%]:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n border-color: var(--mj-border-strong);\n }\n\n .mj-sw-update-toast__close[_ngcontent-%COMP%] {\n position: absolute;\n top: 8px;\n right: 8px;\n width: 24px;\n height: 24px;\n border: none;\n background: transparent;\n color: var(--mj-text-muted);\n border-radius: 6px;\n cursor: pointer;\n display: grid;\n place-items: center;\n font-size: 12px;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .mj-sw-update-toast__close[_ngcontent-%COMP%]:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n }\n .mj-sw-update-toast__close[_ngcontent-%COMP%]:focus-visible {\n outline: 2px solid var(--mj-border-focus);\n outline-offset: 1px;\n }\n\n @media (prefers-reduced-motion: reduce) {\n .mj-sw-update-toast[_ngcontent-%COMP%] {\n animation: none;\n }\n .mj-sw-update-toast__icon[_ngcontent-%COMP%] {\n animation: none;\n }\n }"], changeDetection: 0 });
|
|
58
|
+
}
|
|
59
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(UpdateNotificationComponent, [{
|
|
60
|
+
type: Component,
|
|
61
|
+
args: [{ standalone: true, selector: 'mj-update-notification', imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
62
|
+
@if (updateService.updateAvailable$ | async) {
|
|
63
|
+
<div
|
|
64
|
+
class="mj-sw-update-toast"
|
|
65
|
+
role="status"
|
|
66
|
+
aria-live="polite"
|
|
67
|
+
>
|
|
68
|
+
<div class="mj-sw-update-toast__icon" aria-hidden="true">
|
|
69
|
+
<i class="fa-solid fa-arrows-rotate"></i>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="mj-sw-update-toast__content">
|
|
72
|
+
<div class="mj-sw-update-toast__title">
|
|
73
|
+
Update available (test build #7)
|
|
74
|
+
</div>
|
|
75
|
+
<div class="mj-sw-update-toast__subtitle">
|
|
76
|
+
A newer version of MemberJunction has been downloaded. Reload now to apply, or keep working and we'll prompt you again next time.
|
|
77
|
+
</div>
|
|
78
|
+
<div class="mj-sw-update-toast__actions">
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
class="mj-sw-update-toast__btn mj-sw-update-toast__btn--primary"
|
|
82
|
+
(click)="onReload()"
|
|
83
|
+
>
|
|
84
|
+
<i class="fa-solid fa-rotate-right" aria-hidden="true"></i>
|
|
85
|
+
<span>Reload now</span>
|
|
86
|
+
</button>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
class="mj-sw-update-toast__btn mj-sw-update-toast__btn--secondary"
|
|
90
|
+
(click)="onDismiss()"
|
|
91
|
+
>
|
|
92
|
+
Later
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
class="mj-sw-update-toast__close"
|
|
99
|
+
aria-label="Dismiss"
|
|
100
|
+
(click)="onDismiss()"
|
|
101
|
+
>
|
|
102
|
+
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
}
|
|
106
|
+
`, styles: ["\n :host {\n position: fixed;\n bottom: 24px;\n right: 24px;\n z-index: 1000;\n pointer-events: none;\n max-width: 100%;\n }\n\n @keyframes mj-sw-toast-in {\n from {\n opacity: 0;\n transform: translateY(16px) scale(0.98);\n }\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n }\n\n @keyframes mj-sw-toast-pulse {\n 0%, 100% {\n box-shadow: 0 0 0 0 color-mix(in srgb, var(--mj-brand-primary) 35%, transparent);\n }\n 50% {\n box-shadow: 0 0 0 8px color-mix(in srgb, var(--mj-brand-primary) 0%, transparent);\n }\n }\n\n .mj-sw-update-toast {\n display: flex;\n align-items: flex-start;\n gap: 14px;\n position: relative;\n background: var(--mj-bg-surface-elevated);\n color: var(--mj-text-primary);\n border: 1px solid var(--mj-border-default);\n border-radius: 12px;\n padding: 16px 18px 16px 16px;\n width: 380px;\n max-width: calc(100vw - 48px);\n box-shadow:\n 0 1px 2px color-mix(in srgb, var(--mj-text-primary) 6%, transparent),\n 0 12px 32px color-mix(in srgb, var(--mj-text-primary) 16%, transparent);\n pointer-events: auto;\n font-size: 13.5px;\n line-height: 1.45;\n animation: mj-sw-toast-in 220ms cubic-bezier(0.2, 0.9, 0.32, 1.15) both;\n }\n\n .mj-sw-update-toast__icon {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n display: grid;\n place-items: center;\n background: color-mix(in srgb, var(--mj-brand-primary) 12%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n font-size: 16px;\n animation: mj-sw-toast-pulse 2.4s ease-in-out 0.4s 2;\n }\n\n .mj-sw-update-toast__content {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n\n .mj-sw-update-toast__title {\n color: var(--mj-text-primary);\n font-weight: 600;\n font-size: 14px;\n letter-spacing: -0.005em;\n }\n\n .mj-sw-update-toast__subtitle {\n color: var(--mj-text-secondary);\n font-size: 12.5px;\n line-height: 1.5;\n margin-bottom: 4px;\n }\n\n .mj-sw-update-toast__actions {\n display: flex;\n gap: 8px;\n margin-top: 8px;\n }\n\n .mj-sw-update-toast__btn {\n font: inherit;\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n padding: 7px 14px;\n border-radius: 8px;\n border: 1px solid transparent;\n line-height: 1.2;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.08s ease;\n }\n .mj-sw-update-toast__btn:active {\n transform: translateY(1px);\n }\n .mj-sw-update-toast__btn:focus-visible {\n outline: 2px solid var(--mj-border-focus);\n outline-offset: 2px;\n }\n\n .mj-sw-update-toast__btn--primary {\n background: var(--mj-brand-primary);\n color: var(--mj-text-inverse);\n }\n .mj-sw-update-toast__btn--primary:hover {\n background: var(--mj-brand-primary-hover);\n }\n .mj-sw-update-toast__btn--primary:active {\n background: var(--mj-brand-primary-active);\n }\n\n .mj-sw-update-toast__btn--secondary {\n background: transparent;\n border-color: var(--mj-border-default);\n color: var(--mj-text-secondary);\n }\n .mj-sw-update-toast__btn--secondary:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n border-color: var(--mj-border-strong);\n }\n\n .mj-sw-update-toast__close {\n position: absolute;\n top: 8px;\n right: 8px;\n width: 24px;\n height: 24px;\n border: none;\n background: transparent;\n color: var(--mj-text-muted);\n border-radius: 6px;\n cursor: pointer;\n display: grid;\n place-items: center;\n font-size: 12px;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .mj-sw-update-toast__close:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n }\n .mj-sw-update-toast__close:focus-visible {\n outline: 2px solid var(--mj-border-focus);\n outline-offset: 1px;\n }\n\n @media (prefers-reduced-motion: reduce) {\n .mj-sw-update-toast {\n animation: none;\n }\n .mj-sw-update-toast__icon {\n animation: none;\n }\n }\n "] }]
|
|
107
|
+
}], null, null); })();
|
|
108
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(UpdateNotificationComponent, { className: "UpdateNotificationComponent", filePath: "src/lib/update-notification.component.ts", lineNumber: 238 }); })();
|
|
109
|
+
//# sourceMappingURL=update-notification.component.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"update-notification.component.js","sourceRoot":"","sources":["../../src/lib/update-notification.component.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAC;;;;;IAyB1D,AALJ,iCAIC,aAC4D;IACrD,0BAAyC;IAC7C,oBAAM;IAEF,AADJ,iCAAyC,aACE;IACnC,kDACJ;IAAA,oBAAM;IACN,iCAA0C;IACtC,mJACJ;IAAA,oBAAM;IAEF,AADJ,iCAAyC,gBAKpC;IADG,kMAAS,iBAAU,KAAC;IAEpB,2BAA2D;IAC3D,gCAAM;IAAA,2BAAU;IACpB,AADoB,oBAAO,EAClB;IACT,qCAIC;IADG,mMAAS,kBAAW,KAAC;IAErB,wBACJ;IAER,AADI,AADI,oBAAS,EACP,EACJ;IACN,sCAKC;IADG,mMAAS,kBAAW,KAAC;IAErB,4BAAoD;IAE5D,AADI,oBAAS,EACP;;AA3DlB;;;;;;;;;;GAUG;AA+NH,MAAM,OAAO,2BAA2B;IACpB,aAAa,GAAG,MAAM,CAAC,yBAAyB,CAAC,CAAC;IAElE,QAAQ;QACJ,IAAI,CAAC,aAAa,CAAC,WAAW,EAAE,CAAC;IACrC,CAAC;IAED,SAAS;QACL,IAAI,CAAC,aAAa,CAAC,iBAAiB,EAAE,CAAC;IAC3C,CAAC;qHATQ,2BAA2B;6DAA3B,2BAA2B;YAxNhC,8FAA8C;;;YAA9C,mFA2CC;4BA9CK,YAAY;;iFA2Nb,2BAA2B;cA9NvC,SAAS;6BACM,IAAI,YACN,wBAAwB,WACzB,CAAC,YAAY,CAAC,mBACN,uBAAuB,CAAC,MAAM,YACrC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA6CT;;kFA4KQ,2BAA2B","sourcesContent":["import { ChangeDetectionStrategy, Component, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { UpdateNotificationService } from './update-notification.service';\n\n/**\n * Renders a small bottom-right toast when a new app version is available.\n * Two actions: \"Reload\" applies the update; \"Later\" dismisses for the session.\n *\n * Standalone component — no NgModule needed, drop into the shell template\n * with `<mj-update-notification></mj-update-notification>`.\n *\n * Visual design uses MJ design tokens (no hardcoded colors) so it picks up\n * dark mode + brand customization automatically. Slide-up entrance animation\n * + subtle pulse on the icon to draw attention without being aggressive.\n */\n@Component({\n standalone: true,\n selector: 'mj-update-notification',\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (updateService.updateAvailable$ | async) {\n <div\n class=\"mj-sw-update-toast\"\n role=\"status\"\n aria-live=\"polite\"\n >\n <div class=\"mj-sw-update-toast__icon\" aria-hidden=\"true\">\n <i class=\"fa-solid fa-arrows-rotate\"></i>\n </div>\n <div class=\"mj-sw-update-toast__content\">\n <div class=\"mj-sw-update-toast__title\">\n Update available (test build #7)\n </div>\n <div class=\"mj-sw-update-toast__subtitle\">\n A newer version of MemberJunction has been downloaded. Reload now to apply, or keep working and we'll prompt you again next time.\n </div>\n <div class=\"mj-sw-update-toast__actions\">\n <button\n type=\"button\"\n class=\"mj-sw-update-toast__btn mj-sw-update-toast__btn--primary\"\n (click)=\"onReload()\"\n >\n <i class=\"fa-solid fa-rotate-right\" aria-hidden=\"true\"></i>\n <span>Reload now</span>\n </button>\n <button\n type=\"button\"\n class=\"mj-sw-update-toast__btn mj-sw-update-toast__btn--secondary\"\n (click)=\"onDismiss()\"\n >\n Later\n </button>\n </div>\n </div>\n <button\n type=\"button\"\n class=\"mj-sw-update-toast__close\"\n aria-label=\"Dismiss\"\n (click)=\"onDismiss()\"\n >\n <i class=\"fa-solid fa-xmark\" aria-hidden=\"true\"></i>\n </button>\n </div>\n }\n `,\n styles: [`\n :host {\n position: fixed;\n bottom: 24px;\n right: 24px;\n z-index: 1000;\n pointer-events: none;\n max-width: 100%;\n }\n\n @keyframes mj-sw-toast-in {\n from {\n opacity: 0;\n transform: translateY(16px) scale(0.98);\n }\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n }\n\n @keyframes mj-sw-toast-pulse {\n 0%, 100% {\n box-shadow: 0 0 0 0 color-mix(in srgb, var(--mj-brand-primary) 35%, transparent);\n }\n 50% {\n box-shadow: 0 0 0 8px color-mix(in srgb, var(--mj-brand-primary) 0%, transparent);\n }\n }\n\n .mj-sw-update-toast {\n display: flex;\n align-items: flex-start;\n gap: 14px;\n position: relative;\n background: var(--mj-bg-surface-elevated);\n color: var(--mj-text-primary);\n border: 1px solid var(--mj-border-default);\n border-radius: 12px;\n padding: 16px 18px 16px 16px;\n width: 380px;\n max-width: calc(100vw - 48px);\n box-shadow:\n 0 1px 2px color-mix(in srgb, var(--mj-text-primary) 6%, transparent),\n 0 12px 32px color-mix(in srgb, var(--mj-text-primary) 16%, transparent);\n pointer-events: auto;\n font-size: 13.5px;\n line-height: 1.45;\n animation: mj-sw-toast-in 220ms cubic-bezier(0.2, 0.9, 0.32, 1.15) both;\n }\n\n .mj-sw-update-toast__icon {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n display: grid;\n place-items: center;\n background: color-mix(in srgb, var(--mj-brand-primary) 12%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n font-size: 16px;\n animation: mj-sw-toast-pulse 2.4s ease-in-out 0.4s 2;\n }\n\n .mj-sw-update-toast__content {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n\n .mj-sw-update-toast__title {\n color: var(--mj-text-primary);\n font-weight: 600;\n font-size: 14px;\n letter-spacing: -0.005em;\n }\n\n .mj-sw-update-toast__subtitle {\n color: var(--mj-text-secondary);\n font-size: 12.5px;\n line-height: 1.5;\n margin-bottom: 4px;\n }\n\n .mj-sw-update-toast__actions {\n display: flex;\n gap: 8px;\n margin-top: 8px;\n }\n\n .mj-sw-update-toast__btn {\n font: inherit;\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n padding: 7px 14px;\n border-radius: 8px;\n border: 1px solid transparent;\n line-height: 1.2;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.08s ease;\n }\n .mj-sw-update-toast__btn:active {\n transform: translateY(1px);\n }\n .mj-sw-update-toast__btn:focus-visible {\n outline: 2px solid var(--mj-border-focus);\n outline-offset: 2px;\n }\n\n .mj-sw-update-toast__btn--primary {\n background: var(--mj-brand-primary);\n color: var(--mj-text-inverse);\n }\n .mj-sw-update-toast__btn--primary:hover {\n background: var(--mj-brand-primary-hover);\n }\n .mj-sw-update-toast__btn--primary:active {\n background: var(--mj-brand-primary-active);\n }\n\n .mj-sw-update-toast__btn--secondary {\n background: transparent;\n border-color: var(--mj-border-default);\n color: var(--mj-text-secondary);\n }\n .mj-sw-update-toast__btn--secondary:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n border-color: var(--mj-border-strong);\n }\n\n .mj-sw-update-toast__close {\n position: absolute;\n top: 8px;\n right: 8px;\n width: 24px;\n height: 24px;\n border: none;\n background: transparent;\n color: var(--mj-text-muted);\n border-radius: 6px;\n cursor: pointer;\n display: grid;\n place-items: center;\n font-size: 12px;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .mj-sw-update-toast__close:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n }\n .mj-sw-update-toast__close:focus-visible {\n outline: 2px solid var(--mj-border-focus);\n outline-offset: 1px;\n }\n\n @media (prefers-reduced-motion: reduce) {\n .mj-sw-update-toast {\n animation: none;\n }\n .mj-sw-update-toast__icon {\n animation: none;\n }\n }\n `]\n})\nexport class UpdateNotificationComponent {\n public readonly updateService = inject(UpdateNotificationService);\n\n onReload(): void {\n this.updateService.applyUpdate();\n }\n\n onDismiss(): void {\n this.updateService.dismissForSession();\n }\n}\n"]}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { OnDestroy } from '@angular/core';
|
|
2
|
+
import { VersionReadyEvent } from '@angular/service-worker';
|
|
3
|
+
import { Observable } from 'rxjs';
|
|
4
|
+
import * as i0 from "@angular/core";
|
|
5
|
+
export declare class UpdateNotificationService implements OnDestroy {
|
|
6
|
+
private readonly _updateAvailable$;
|
|
7
|
+
private readonly _destroy$;
|
|
8
|
+
private readonly _swUpdate;
|
|
9
|
+
/** Last `VERSION_READY` event captured (for diagnostics / tests). */
|
|
10
|
+
private _lastVersionEvent;
|
|
11
|
+
/** Handle from `setInterval` for the periodic poll; cleared in ngOnDestroy. */
|
|
12
|
+
private _pollHandle;
|
|
13
|
+
/** Bound listener for `visibilitychange`; needs the same reference for removal. */
|
|
14
|
+
private _visibilityListener;
|
|
15
|
+
/** Effective poll interval — set in `startAutoCheck()`. */
|
|
16
|
+
private _pollIntervalMs;
|
|
17
|
+
constructor();
|
|
18
|
+
/**
|
|
19
|
+
* Start a periodic update check on the given interval. Called automatically
|
|
20
|
+
* by the constructor with `DEFAULT_POLL_INTERVAL_MS`. Consumers may call
|
|
21
|
+
* this again with a different interval to retune at runtime — the previous
|
|
22
|
+
* timer is cleared first.
|
|
23
|
+
*
|
|
24
|
+
* Pass `0` (or any non-positive number) to disable periodic checking.
|
|
25
|
+
* Polling is automatically suspended while the tab is hidden and resumed
|
|
26
|
+
* (with an immediate check) when the tab becomes visible again.
|
|
27
|
+
*/
|
|
28
|
+
startAutoCheck(intervalMs: number): void;
|
|
29
|
+
/** Stop the periodic poll started by `startAutoCheck()`. */
|
|
30
|
+
stopAutoCheck(): void;
|
|
31
|
+
/**
|
|
32
|
+
* Attach this service to `window.__mjUpdateNotificationService__` so it can
|
|
33
|
+
* be poked from the DevTools console. Useful for ops/QA verification of the
|
|
34
|
+
* update-detection roundtrip without code changes.
|
|
35
|
+
*
|
|
36
|
+
* No-op in non-browser contexts.
|
|
37
|
+
*/
|
|
38
|
+
private exposeDebugHandle;
|
|
39
|
+
/**
|
|
40
|
+
* Emits `true` once a new SW-cached version is ready to activate.
|
|
41
|
+
* Stays `true` until either `applyUpdate()` (which reloads the page) or
|
|
42
|
+
* `dismissForSession()` (which resets the flag without reloading).
|
|
43
|
+
*/
|
|
44
|
+
get updateAvailable$(): Observable<boolean>;
|
|
45
|
+
/** Synchronous accessor for current update-available state. */
|
|
46
|
+
get isUpdateAvailable(): boolean;
|
|
47
|
+
/** True if the underlying SwUpdate API is active (SW registered + supported). */
|
|
48
|
+
get isServiceWorkerEnabled(): boolean;
|
|
49
|
+
/** The most recent `VERSION_READY` event, if any. Useful for diagnostics. */
|
|
50
|
+
get lastVersionEvent(): VersionReadyEvent | null;
|
|
51
|
+
/**
|
|
52
|
+
* Reload the page to activate the newly-downloaded SW version.
|
|
53
|
+
*
|
|
54
|
+
* Implementation note: the SW activates new versions on the next
|
|
55
|
+
* navigation, not via any in-process API. So we just reload — `SwUpdate`
|
|
56
|
+
* doesn't expose a non-reload way to swap the running JS without losing
|
|
57
|
+
* application state, and any such mechanism would be unsafe anyway.
|
|
58
|
+
*
|
|
59
|
+
* This method is split out (vs. inlining `location.reload()`) so consumers
|
|
60
|
+
* can be tested without actually reloading the page.
|
|
61
|
+
*/
|
|
62
|
+
applyUpdate(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Dismiss the prompt for this session without reloading. The flag will
|
|
65
|
+
* re-fire on the next page load if the SW is still pointing at the
|
|
66
|
+
* pending version (which it will be, until something triggers a reload).
|
|
67
|
+
*
|
|
68
|
+
* Use case: a power user is in the middle of editing something and doesn't
|
|
69
|
+
* want to interrupt; they'll get prompted again on their next reload.
|
|
70
|
+
*/
|
|
71
|
+
dismissForSession(): void;
|
|
72
|
+
/**
|
|
73
|
+
* Manually nudge the SW to check for an update. The Angular SW polls
|
|
74
|
+
* automatically on a schedule, but consumers can call this to force an
|
|
75
|
+
* immediate check (e.g., from a "Check for updates" menu item).
|
|
76
|
+
*
|
|
77
|
+
* Returns `true` if a new version was found and downloaded; `false` if
|
|
78
|
+
* we're already on the latest. Resolves to `false` if the SW is disabled.
|
|
79
|
+
*/
|
|
80
|
+
checkForUpdate(): Promise<boolean>;
|
|
81
|
+
/**
|
|
82
|
+
* Indirection for test injection — overriding `location.reload` directly
|
|
83
|
+
* is not possible in a JSDOM/happy-dom environment without warnings, and
|
|
84
|
+
* spying on `globalThis.location` is brittle. This wrapper lets tests
|
|
85
|
+
* mock the reload via a subclass / `vi.spyOn(service as any, 'performReload')`.
|
|
86
|
+
*/
|
|
87
|
+
protected performReload(): void;
|
|
88
|
+
ngOnDestroy(): void;
|
|
89
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<UpdateNotificationService, never>;
|
|
90
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<UpdateNotificationService>;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=update-notification.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"update-notification.service.d.ts","sourceRoot":"","sources":["../../src/lib/update-notification.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,SAAS,EAAU,MAAM,eAAe,CAAC;AAC9D,OAAO,EAA0B,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACpF,OAAO,EAAmB,UAAU,EAAkB,MAAM,MAAM,CAAC;;AA2CnE,qBACa,yBAA0B,YAAW,SAAS;IACvD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAuC;IACzE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAuB;IACjD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoB;IAE9C,qEAAqE;IACrE,OAAO,CAAC,iBAAiB,CAAkC;IAE3D,+EAA+E;IAC/E,OAAO,CAAC,WAAW,CAA+C;IAElE,mFAAmF;IACnF,OAAO,CAAC,mBAAmB,CAA6B;IAExD,2DAA2D;IAC3D,OAAO,CAAC,eAAe,CAA4B;;IA6BnD;;;;;;;;;OASG;IACI,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAqB/C,4DAA4D;IACrD,aAAa,IAAI,IAAI;IAW5B;;;;;;OAMG;IACH,OAAO,CAAC,iBAAiB;IAMzB;;;;OAIG;IACH,IAAW,gBAAgB,IAAI,UAAU,CAAC,OAAO,CAAC,CAEjD;IAED,+DAA+D;IAC/D,IAAW,iBAAiB,IAAI,OAAO,CAEtC;IAED,iFAAiF;IACjF,IAAW,sBAAsB,IAAI,OAAO,CAE3C;IAED,6EAA6E;IAC7E,IAAW,gBAAgB,IAAI,iBAAiB,GAAG,IAAI,CAEtD;IAED;;;;;;;;;;OAUG;IACI,WAAW,IAAI,IAAI;IAQ1B;;;;;;;OAOG;IACI,iBAAiB,IAAI,IAAI;IAIhC;;;;;;;OAOG;IACU,cAAc,IAAI,OAAO,CAAC,OAAO,CAAC;IAW/C;;;;;OAKG;IAEH,SAAS,CAAC,aAAa,IAAI,IAAI;IAO/B,WAAW,IAAI,IAAI;yCA5LV,yBAAyB;6CAAzB,yBAAyB;CAkMrC"}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { Injectable, inject } from '@angular/core';
|
|
2
|
+
import { SwUpdate } from '@angular/service-worker';
|
|
3
|
+
import { BehaviorSubject, Subject, EMPTY } from 'rxjs';
|
|
4
|
+
import { catchError, filter, takeUntil } from 'rxjs/operators';
|
|
5
|
+
import * as i0 from "@angular/core";
|
|
6
|
+
/**
|
|
7
|
+
* Coordinates notifying the user when a new app-shell version has been
|
|
8
|
+
* downloaded by the service worker and is waiting to activate.
|
|
9
|
+
*
|
|
10
|
+
* Background — the Angular service worker downloads new versions silently in
|
|
11
|
+
* the background; the currently-loaded page keeps using the old cached version
|
|
12
|
+
* until a navigation reload activates the new one. This service watches for
|
|
13
|
+
* `VERSION_READY` events from `SwUpdate` and exposes them via an observable
|
|
14
|
+
* the shell can subscribe to (e.g. to render an "Update available" toast).
|
|
15
|
+
*
|
|
16
|
+
* Use in MJExplorer:
|
|
17
|
+
* 1. Inject this service in `AppComponent` (or wherever the shell lives)
|
|
18
|
+
* 2. Subscribe to `updateAvailable$` and render UI when it emits
|
|
19
|
+
* 3. Call `applyUpdate()` when the user accepts the prompt — this performs
|
|
20
|
+
* a full page reload, which causes the SW to activate the new version
|
|
21
|
+
*
|
|
22
|
+
* **Dev note**: when the service worker is disabled (development builds, or
|
|
23
|
+
* `enableServiceWorker = false` in the environment), `SwUpdate.isEnabled`
|
|
24
|
+
* returns `false` and this service is effectively a no-op. The shell can
|
|
25
|
+
* always subscribe; it just never receives an emission.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* How often (in ms) to ask the SW to check for a new manifest while the tab
|
|
29
|
+
* is visible. 15 minutes balances responsiveness against pointless network
|
|
30
|
+
* chatter on idle tabs. Checks are skipped while the tab is hidden (Page
|
|
31
|
+
* Visibility API), and a check is also fired immediately when the tab becomes
|
|
32
|
+
* visible after being backgrounded.
|
|
33
|
+
*
|
|
34
|
+
* Override via `MJServiceWorkerOptions.pollIntervalMs`.
|
|
35
|
+
*/
|
|
36
|
+
const DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000;
|
|
37
|
+
/**
|
|
38
|
+
* Window-attached debug handle. Type-safe accessor avoids polluting the global
|
|
39
|
+
* namespace with `any`. Use from DevTools console:
|
|
40
|
+
* __mjUpdateNotificationService__.checkForUpdate()
|
|
41
|
+
* __mjUpdateNotificationService__.isUpdateAvailable
|
|
42
|
+
*/
|
|
43
|
+
const WINDOW_DEBUG_KEY = '__mjUpdateNotificationService__';
|
|
44
|
+
export class UpdateNotificationService {
|
|
45
|
+
_updateAvailable$ = new BehaviorSubject(false);
|
|
46
|
+
_destroy$ = new Subject();
|
|
47
|
+
_swUpdate = inject(SwUpdate);
|
|
48
|
+
/** Last `VERSION_READY` event captured (for diagnostics / tests). */
|
|
49
|
+
_lastVersionEvent = null;
|
|
50
|
+
/** Handle from `setInterval` for the periodic poll; cleared in ngOnDestroy. */
|
|
51
|
+
_pollHandle = null;
|
|
52
|
+
/** Bound listener for `visibilitychange`; needs the same reference for removal. */
|
|
53
|
+
_visibilityListener = null;
|
|
54
|
+
/** Effective poll interval — set in `startAutoCheck()`. */
|
|
55
|
+
_pollIntervalMs = DEFAULT_POLL_INTERVAL_MS;
|
|
56
|
+
constructor() {
|
|
57
|
+
if (this._swUpdate.isEnabled) {
|
|
58
|
+
// Subscribe to all version events; filter to ones that mean a new
|
|
59
|
+
// version is fully downloaded and waiting to activate.
|
|
60
|
+
this._swUpdate.versionUpdates
|
|
61
|
+
.pipe(filter((e) => e.type === 'VERSION_READY'), catchError(err => {
|
|
62
|
+
// Defensive — observable should never error in practice, but if the
|
|
63
|
+
// SW connection breaks for any reason, swallow it. Caller can re-init
|
|
64
|
+
// the service later if needed.
|
|
65
|
+
// eslint-disable-next-line no-console
|
|
66
|
+
console.warn('[UpdateNotificationService] versionUpdates errored:', err);
|
|
67
|
+
return EMPTY;
|
|
68
|
+
}), takeUntil(this._destroy$))
|
|
69
|
+
.subscribe((event) => {
|
|
70
|
+
this._lastVersionEvent = event;
|
|
71
|
+
this._updateAvailable$.next(true);
|
|
72
|
+
});
|
|
73
|
+
this.startAutoCheck(DEFAULT_POLL_INTERVAL_MS);
|
|
74
|
+
this.exposeDebugHandle();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Start a periodic update check on the given interval. Called automatically
|
|
79
|
+
* by the constructor with `DEFAULT_POLL_INTERVAL_MS`. Consumers may call
|
|
80
|
+
* this again with a different interval to retune at runtime — the previous
|
|
81
|
+
* timer is cleared first.
|
|
82
|
+
*
|
|
83
|
+
* Pass `0` (or any non-positive number) to disable periodic checking.
|
|
84
|
+
* Polling is automatically suspended while the tab is hidden and resumed
|
|
85
|
+
* (with an immediate check) when the tab becomes visible again.
|
|
86
|
+
*/
|
|
87
|
+
startAutoCheck(intervalMs) {
|
|
88
|
+
this.stopAutoCheck();
|
|
89
|
+
this._pollIntervalMs = intervalMs;
|
|
90
|
+
if (!this._swUpdate.isEnabled || intervalMs <= 0)
|
|
91
|
+
return;
|
|
92
|
+
this._pollHandle = setInterval(() => {
|
|
93
|
+
// Only fire when visible. Hidden tabs catch up via the
|
|
94
|
+
// visibilitychange handler.
|
|
95
|
+
if (typeof document === 'undefined' || !document.hidden) {
|
|
96
|
+
void this.checkForUpdate();
|
|
97
|
+
}
|
|
98
|
+
}, intervalMs);
|
|
99
|
+
if (typeof document !== 'undefined') {
|
|
100
|
+
this._visibilityListener = () => {
|
|
101
|
+
if (!document.hidden)
|
|
102
|
+
void this.checkForUpdate();
|
|
103
|
+
};
|
|
104
|
+
document.addEventListener('visibilitychange', this._visibilityListener);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Stop the periodic poll started by `startAutoCheck()`. */
|
|
108
|
+
stopAutoCheck() {
|
|
109
|
+
if (this._pollHandle !== null) {
|
|
110
|
+
clearInterval(this._pollHandle);
|
|
111
|
+
this._pollHandle = null;
|
|
112
|
+
}
|
|
113
|
+
if (this._visibilityListener && typeof document !== 'undefined') {
|
|
114
|
+
document.removeEventListener('visibilitychange', this._visibilityListener);
|
|
115
|
+
this._visibilityListener = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Attach this service to `window.__mjUpdateNotificationService__` so it can
|
|
120
|
+
* be poked from the DevTools console. Useful for ops/QA verification of the
|
|
121
|
+
* update-detection roundtrip without code changes.
|
|
122
|
+
*
|
|
123
|
+
* No-op in non-browser contexts.
|
|
124
|
+
*/
|
|
125
|
+
exposeDebugHandle() {
|
|
126
|
+
if (typeof window === 'undefined')
|
|
127
|
+
return;
|
|
128
|
+
// Cast through unknown to avoid a global declaration just for the debug hook.
|
|
129
|
+
window[WINDOW_DEBUG_KEY] = this;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Emits `true` once a new SW-cached version is ready to activate.
|
|
133
|
+
* Stays `true` until either `applyUpdate()` (which reloads the page) or
|
|
134
|
+
* `dismissForSession()` (which resets the flag without reloading).
|
|
135
|
+
*/
|
|
136
|
+
get updateAvailable$() {
|
|
137
|
+
return this._updateAvailable$.asObservable();
|
|
138
|
+
}
|
|
139
|
+
/** Synchronous accessor for current update-available state. */
|
|
140
|
+
get isUpdateAvailable() {
|
|
141
|
+
return this._updateAvailable$.value;
|
|
142
|
+
}
|
|
143
|
+
/** True if the underlying SwUpdate API is active (SW registered + supported). */
|
|
144
|
+
get isServiceWorkerEnabled() {
|
|
145
|
+
return this._swUpdate.isEnabled;
|
|
146
|
+
}
|
|
147
|
+
/** The most recent `VERSION_READY` event, if any. Useful for diagnostics. */
|
|
148
|
+
get lastVersionEvent() {
|
|
149
|
+
return this._lastVersionEvent;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Reload the page to activate the newly-downloaded SW version.
|
|
153
|
+
*
|
|
154
|
+
* Implementation note: the SW activates new versions on the next
|
|
155
|
+
* navigation, not via any in-process API. So we just reload — `SwUpdate`
|
|
156
|
+
* doesn't expose a non-reload way to swap the running JS without losing
|
|
157
|
+
* application state, and any such mechanism would be unsafe anyway.
|
|
158
|
+
*
|
|
159
|
+
* This method is split out (vs. inlining `location.reload()`) so consumers
|
|
160
|
+
* can be tested without actually reloading the page.
|
|
161
|
+
*/
|
|
162
|
+
applyUpdate() {
|
|
163
|
+
// Hide the prompt before reloading so it doesn't briefly flash again
|
|
164
|
+
// on the new page if anything in the shell renders before the SW
|
|
165
|
+
// activates.
|
|
166
|
+
this._updateAvailable$.next(false);
|
|
167
|
+
this.performReload();
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Dismiss the prompt for this session without reloading. The flag will
|
|
171
|
+
* re-fire on the next page load if the SW is still pointing at the
|
|
172
|
+
* pending version (which it will be, until something triggers a reload).
|
|
173
|
+
*
|
|
174
|
+
* Use case: a power user is in the middle of editing something and doesn't
|
|
175
|
+
* want to interrupt; they'll get prompted again on their next reload.
|
|
176
|
+
*/
|
|
177
|
+
dismissForSession() {
|
|
178
|
+
this._updateAvailable$.next(false);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Manually nudge the SW to check for an update. The Angular SW polls
|
|
182
|
+
* automatically on a schedule, but consumers can call this to force an
|
|
183
|
+
* immediate check (e.g., from a "Check for updates" menu item).
|
|
184
|
+
*
|
|
185
|
+
* Returns `true` if a new version was found and downloaded; `false` if
|
|
186
|
+
* we're already on the latest. Resolves to `false` if the SW is disabled.
|
|
187
|
+
*/
|
|
188
|
+
async checkForUpdate() {
|
|
189
|
+
if (!this._swUpdate.isEnabled)
|
|
190
|
+
return false;
|
|
191
|
+
try {
|
|
192
|
+
return await this._swUpdate.checkForUpdate();
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
// eslint-disable-next-line no-console
|
|
196
|
+
console.warn('[UpdateNotificationService] checkForUpdate failed:', err);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Indirection for test injection — overriding `location.reload` directly
|
|
202
|
+
* is not possible in a JSDOM/happy-dom environment without warnings, and
|
|
203
|
+
* spying on `globalThis.location` is brittle. This wrapper lets tests
|
|
204
|
+
* mock the reload via a subclass / `vi.spyOn(service as any, 'performReload')`.
|
|
205
|
+
*/
|
|
206
|
+
/* istanbul ignore next — exercised manually, hard to unit-test cleanly */
|
|
207
|
+
performReload() {
|
|
208
|
+
// Use document.location for cross-environment reliability. `window`
|
|
209
|
+
// can be shadowed in test environments; `document.location` is more
|
|
210
|
+
// consistently available.
|
|
211
|
+
document.location.reload();
|
|
212
|
+
}
|
|
213
|
+
ngOnDestroy() {
|
|
214
|
+
this.stopAutoCheck();
|
|
215
|
+
this._destroy$.next();
|
|
216
|
+
this._destroy$.complete();
|
|
217
|
+
this._updateAvailable$.complete();
|
|
218
|
+
}
|
|
219
|
+
static ɵfac = function UpdateNotificationService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || UpdateNotificationService)(); };
|
|
220
|
+
static ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: UpdateNotificationService, factory: UpdateNotificationService.ɵfac, providedIn: 'root' });
|
|
221
|
+
}
|
|
222
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(UpdateNotificationService, [{
|
|
223
|
+
type: Injectable,
|
|
224
|
+
args: [{ providedIn: 'root' }]
|
|
225
|
+
}], () => [], null); })();
|
|
226
|
+
//# sourceMappingURL=update-notification.service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"update-notification.service.js","sourceRoot":"","sources":["../../src/lib/update-notification.service.ts"],"names":[],"mappings":"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","sourcesContent":["import { Injectable, OnDestroy, inject } from '@angular/core';\nimport { SwUpdate, VersionEvent, VersionReadyEvent } from '@angular/service-worker';\nimport { BehaviorSubject, Observable, Subject, EMPTY } from 'rxjs';\nimport { catchError, filter, takeUntil } from 'rxjs/operators';\n\n/**\n * Coordinates notifying the user when a new app-shell version has been\n * downloaded by the service worker and is waiting to activate.\n *\n * Background — the Angular service worker downloads new versions silently in\n * the background; the currently-loaded page keeps using the old cached version\n * until a navigation reload activates the new one. This service watches for\n * `VERSION_READY` events from `SwUpdate` and exposes them via an observable\n * the shell can subscribe to (e.g. to render an \"Update available\" toast).\n *\n * Use in MJExplorer:\n * 1. Inject this service in `AppComponent` (or wherever the shell lives)\n * 2. Subscribe to `updateAvailable$` and render UI when it emits\n * 3. Call `applyUpdate()` when the user accepts the prompt — this performs\n * a full page reload, which causes the SW to activate the new version\n *\n * **Dev note**: when the service worker is disabled (development builds, or\n * `enableServiceWorker = false` in the environment), `SwUpdate.isEnabled`\n * returns `false` and this service is effectively a no-op. The shell can\n * always subscribe; it just never receives an emission.\n */\n/**\n * How often (in ms) to ask the SW to check for a new manifest while the tab\n * is visible. 15 minutes balances responsiveness against pointless network\n * chatter on idle tabs. Checks are skipped while the tab is hidden (Page\n * Visibility API), and a check is also fired immediately when the tab becomes\n * visible after being backgrounded.\n *\n * Override via `MJServiceWorkerOptions.pollIntervalMs`.\n */\nconst DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000;\n\n/**\n * Window-attached debug handle. Type-safe accessor avoids polluting the global\n * namespace with `any`. Use from DevTools console:\n * __mjUpdateNotificationService__.checkForUpdate()\n * __mjUpdateNotificationService__.isUpdateAvailable\n */\nconst WINDOW_DEBUG_KEY = '__mjUpdateNotificationService__';\n\n@Injectable({ providedIn: 'root' })\nexport class UpdateNotificationService implements OnDestroy {\n private readonly _updateAvailable$ = new BehaviorSubject<boolean>(false);\n private readonly _destroy$ = new Subject<void>();\n private readonly _swUpdate = inject(SwUpdate);\n\n /** Last `VERSION_READY` event captured (for diagnostics / tests). */\n private _lastVersionEvent: VersionReadyEvent | null = null;\n\n /** Handle from `setInterval` for the periodic poll; cleared in ngOnDestroy. */\n private _pollHandle: ReturnType<typeof setInterval> | null = null;\n\n /** Bound listener for `visibilitychange`; needs the same reference for removal. */\n private _visibilityListener: (() => void) | null = null;\n\n /** Effective poll interval — set in `startAutoCheck()`. */\n private _pollIntervalMs = DEFAULT_POLL_INTERVAL_MS;\n\n constructor() {\n if (this._swUpdate.isEnabled) {\n // Subscribe to all version events; filter to ones that mean a new\n // version is fully downloaded and waiting to activate.\n this._swUpdate.versionUpdates\n .pipe(\n filter((e: VersionEvent): e is VersionReadyEvent => e.type === 'VERSION_READY'),\n catchError(err => {\n // Defensive — observable should never error in practice, but if the\n // SW connection breaks for any reason, swallow it. Caller can re-init\n // the service later if needed.\n // eslint-disable-next-line no-console\n console.warn('[UpdateNotificationService] versionUpdates errored:', err);\n return EMPTY;\n }),\n takeUntil(this._destroy$)\n )\n .subscribe((event: VersionReadyEvent) => {\n this._lastVersionEvent = event;\n this._updateAvailable$.next(true);\n });\n\n this.startAutoCheck(DEFAULT_POLL_INTERVAL_MS);\n this.exposeDebugHandle();\n }\n }\n\n /**\n * Start a periodic update check on the given interval. Called automatically\n * by the constructor with `DEFAULT_POLL_INTERVAL_MS`. Consumers may call\n * this again with a different interval to retune at runtime — the previous\n * timer is cleared first.\n *\n * Pass `0` (or any non-positive number) to disable periodic checking.\n * Polling is automatically suspended while the tab is hidden and resumed\n * (with an immediate check) when the tab becomes visible again.\n */\n public startAutoCheck(intervalMs: number): void {\n this.stopAutoCheck();\n this._pollIntervalMs = intervalMs;\n if (!this._swUpdate.isEnabled || intervalMs <= 0) return;\n\n this._pollHandle = setInterval(() => {\n // Only fire when visible. Hidden tabs catch up via the\n // visibilitychange handler.\n if (typeof document === 'undefined' || !document.hidden) {\n void this.checkForUpdate();\n }\n }, intervalMs);\n\n if (typeof document !== 'undefined') {\n this._visibilityListener = () => {\n if (!document.hidden) void this.checkForUpdate();\n };\n document.addEventListener('visibilitychange', this._visibilityListener);\n }\n }\n\n /** Stop the periodic poll started by `startAutoCheck()`. */\n public stopAutoCheck(): void {\n if (this._pollHandle !== null) {\n clearInterval(this._pollHandle);\n this._pollHandle = null;\n }\n if (this._visibilityListener && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', this._visibilityListener);\n this._visibilityListener = null;\n }\n }\n\n /**\n * Attach this service to `window.__mjUpdateNotificationService__` so it can\n * be poked from the DevTools console. Useful for ops/QA verification of the\n * update-detection roundtrip without code changes.\n *\n * No-op in non-browser contexts.\n */\n private exposeDebugHandle(): void {\n if (typeof window === 'undefined') return;\n // Cast through unknown to avoid a global declaration just for the debug hook.\n (window as unknown as Record<string, unknown>)[WINDOW_DEBUG_KEY] = this;\n }\n\n /**\n * Emits `true` once a new SW-cached version is ready to activate.\n * Stays `true` until either `applyUpdate()` (which reloads the page) or\n * `dismissForSession()` (which resets the flag without reloading).\n */\n public get updateAvailable$(): Observable<boolean> {\n return this._updateAvailable$.asObservable();\n }\n\n /** Synchronous accessor for current update-available state. */\n public get isUpdateAvailable(): boolean {\n return this._updateAvailable$.value;\n }\n\n /** True if the underlying SwUpdate API is active (SW registered + supported). */\n public get isServiceWorkerEnabled(): boolean {\n return this._swUpdate.isEnabled;\n }\n\n /** The most recent `VERSION_READY` event, if any. Useful for diagnostics. */\n public get lastVersionEvent(): VersionReadyEvent | null {\n return this._lastVersionEvent;\n }\n\n /**\n * Reload the page to activate the newly-downloaded SW version.\n *\n * Implementation note: the SW activates new versions on the next\n * navigation, not via any in-process API. So we just reload — `SwUpdate`\n * doesn't expose a non-reload way to swap the running JS without losing\n * application state, and any such mechanism would be unsafe anyway.\n *\n * This method is split out (vs. inlining `location.reload()`) so consumers\n * can be tested without actually reloading the page.\n */\n public applyUpdate(): void {\n // Hide the prompt before reloading so it doesn't briefly flash again\n // on the new page if anything in the shell renders before the SW\n // activates.\n this._updateAvailable$.next(false);\n this.performReload();\n }\n\n /**\n * Dismiss the prompt for this session without reloading. The flag will\n * re-fire on the next page load if the SW is still pointing at the\n * pending version (which it will be, until something triggers a reload).\n *\n * Use case: a power user is in the middle of editing something and doesn't\n * want to interrupt; they'll get prompted again on their next reload.\n */\n public dismissForSession(): void {\n this._updateAvailable$.next(false);\n }\n\n /**\n * Manually nudge the SW to check for an update. The Angular SW polls\n * automatically on a schedule, but consumers can call this to force an\n * immediate check (e.g., from a \"Check for updates\" menu item).\n *\n * Returns `true` if a new version was found and downloaded; `false` if\n * we're already on the latest. Resolves to `false` if the SW is disabled.\n */\n public async checkForUpdate(): Promise<boolean> {\n if (!this._swUpdate.isEnabled) return false;\n try {\n return await this._swUpdate.checkForUpdate();\n } catch (err) {\n // eslint-disable-next-line no-console\n console.warn('[UpdateNotificationService] checkForUpdate failed:', err);\n return false;\n }\n }\n\n /**\n * Indirection for test injection — overriding `location.reload` directly\n * is not possible in a JSDOM/happy-dom environment without warnings, and\n * spying on `globalThis.location` is brittle. This wrapper lets tests\n * mock the reload via a subclass / `vi.spyOn(service as any, 'performReload')`.\n */\n /* istanbul ignore next — exercised manually, hard to unit-test cleanly */\n protected performReload(): void {\n // Use document.location for cross-environment reliability. `window`\n // can be shadowed in test environments; `document.location` is more\n // consistently available.\n document.location.reload();\n }\n\n ngOnDestroy(): void {\n this.stopAutoCheck();\n this._destroy$.next();\n this._destroy$.complete();\n this._updateAvailable$.complete();\n }\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-api.d.ts","sourceRoot":"","sources":["../src/public-api.ts"],"names":[],"mappings":"AAIA,cAAc,gCAAgC,CAAC;AAC/C,cAAc,mCAAmC,CAAC;AAClD,cAAc,qCAAqC,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Public API Surface of @memberjunction/ng-explorer-service-worker
|
|
3
|
+
*/
|
|
4
|
+
export * from './lib/mj-service-worker.module';
|
|
5
|
+
export * from './lib/update-notification.service';
|
|
6
|
+
export * from './lib/update-notification.component';
|
|
7
|
+
//# sourceMappingURL=public-api.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-api.js","sourceRoot":"","sources":["../src/public-api.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,cAAc,gCAAgC,CAAC;AAC/C,cAAc,mCAAmC,CAAC;AAClD,cAAc,qCAAqC,CAAC","sourcesContent":["/*\n * Public API Surface of @memberjunction/ng-explorer-service-worker\n */\n\nexport * from './lib/mj-service-worker.module';\nexport * from './lib/update-notification.service';\nexport * from './lib/update-notification.component';\n"]}
|
package/ngsw-config.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../../node_modules/@angular/service-worker/config/schema.json",
|
|
3
|
+
"index": "/index.html",
|
|
4
|
+
"assetGroups": [
|
|
5
|
+
{
|
|
6
|
+
"name": "app-shell",
|
|
7
|
+
"installMode": "prefetch",
|
|
8
|
+
"updateMode": "prefetch",
|
|
9
|
+
"resources": {
|
|
10
|
+
"files": [
|
|
11
|
+
"/favicon.ico",
|
|
12
|
+
"/index.html",
|
|
13
|
+
"/manifest.webmanifest",
|
|
14
|
+
"/main-*.js",
|
|
15
|
+
"/polyfills-*.js",
|
|
16
|
+
"/styles-*.css"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "lazy-chunks",
|
|
22
|
+
"installMode": "lazy",
|
|
23
|
+
"updateMode": "prefetch",
|
|
24
|
+
"resources": {
|
|
25
|
+
"files": [
|
|
26
|
+
"/chunk-*.js"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "lazy-assets",
|
|
32
|
+
"installMode": "lazy",
|
|
33
|
+
"updateMode": "prefetch",
|
|
34
|
+
"resources": {
|
|
35
|
+
"files": [
|
|
36
|
+
"/assets/**",
|
|
37
|
+
"/media/**",
|
|
38
|
+
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2|ico)"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"navigationUrls": [
|
|
44
|
+
"/**",
|
|
45
|
+
"!/**/graphql",
|
|
46
|
+
"!/**/graphql-ws",
|
|
47
|
+
"!/api/**",
|
|
48
|
+
"!/auth/**",
|
|
49
|
+
"!/**/?msal*",
|
|
50
|
+
"!/**/*__*"
|
|
51
|
+
]
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@memberjunction/ng-explorer-service-worker",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "5.32.0",
|
|
4
|
+
"description": "MemberJunction: Service worker app-shell pre-cache + update notification UI for MJ Explorer",
|
|
5
|
+
"main": "./dist/public-api.js",
|
|
6
|
+
"typings": "./dist/public-api.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"/dist",
|
|
9
|
+
"/ngsw-config.json",
|
|
10
|
+
"/scripts"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"build": "ngc"
|
|
15
|
+
},
|
|
5
16
|
"keywords": [
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
17
|
+
"MemberJunction",
|
|
18
|
+
"Angular",
|
|
19
|
+
"ServiceWorker",
|
|
20
|
+
"PWA",
|
|
21
|
+
"Explorer"
|
|
22
|
+
],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "ISC",
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@angular/compiler": "21.1.3",
|
|
27
|
+
"@angular/compiler-cli": "21.1.3",
|
|
28
|
+
"vitest": "^4.0.18"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@angular/common": "21.1.3",
|
|
32
|
+
"@angular/core": "21.1.3",
|
|
33
|
+
"@angular/service-worker": "21.1.3",
|
|
34
|
+
"rxjs": "^7.8.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"tslib": "^2.8.1"
|
|
38
|
+
},
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/MemberJunction/MJ"
|
|
43
|
+
}
|
|
10
44
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Verify that every root-level .js file in the consumer's prod build matches
|
|
4
|
+
* one of the filename prefixes that `ngsw-config.json` knows how to classify.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: Angular's `application` builder emits all bundles flat at
|
|
7
|
+
* the dist root with hashed filenames. Our `ngsw-config.json` uses prefix
|
|
8
|
+
* globs (`main-*.js`, `polyfills-*.js`, `chunk-*.js`, `styles-*.css`) to
|
|
9
|
+
* decide which group each file belongs to (eager prefetch vs lazy). If a
|
|
10
|
+
* future Angular release introduces a new prefix (`runtime-*`, `vendor-*`,
|
|
11
|
+
* something nobody saw coming), the SW would silently *not* cache those
|
|
12
|
+
* files — leaving offline users with a half-broken app. This gate fails the
|
|
13
|
+
* build the moment that happens, forcing a conscious update to the config.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* node node_modules/@memberjunction/ng-explorer-service-worker/scripts/verify-sw-cache-shape.js \
|
|
17
|
+
* path/to/dist/<app>/browser
|
|
18
|
+
*
|
|
19
|
+
* Suggested wire-up: add a `postbuild` script to your app's package.json:
|
|
20
|
+
* "postbuild": "node node_modules/.../verify-sw-cache-shape.js dist/<app>/browser"
|
|
21
|
+
*
|
|
22
|
+
* Exits 0 on success (every root JS file matches a known prefix), 1 on
|
|
23
|
+
* failure (with a diagnostic listing the unrecognized files).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
const KNOWN_JS_PREFIXES = ['main-', 'polyfills-', 'chunk-', 'styles-'];
|
|
32
|
+
const KNOWN_CSS_PREFIXES = ['styles-'];
|
|
33
|
+
|
|
34
|
+
// SW-infrastructure files that the SW itself manages — NOT application assets.
|
|
35
|
+
// These live at the dist root but should never appear in any asset group.
|
|
36
|
+
const SW_INFRASTRUCTURE_FILES = new Set([
|
|
37
|
+
'ngsw-worker.js',
|
|
38
|
+
'ngsw.json',
|
|
39
|
+
'safety-worker.js',
|
|
40
|
+
'worker-basic.min.js',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function fail(message) {
|
|
44
|
+
// eslint-disable-next-line no-console
|
|
45
|
+
console.error('\n[verify-sw-cache-shape] ❌ ' + message + '\n');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ok(message) {
|
|
50
|
+
// eslint-disable-next-line no-console
|
|
51
|
+
console.log('[verify-sw-cache-shape] ✅ ' + message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const distArg = process.argv[2];
|
|
55
|
+
if (!distArg) {
|
|
56
|
+
fail('Usage: verify-sw-cache-shape.js <path-to-browser-dist>');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const distDir = path.resolve(distArg);
|
|
60
|
+
if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) {
|
|
61
|
+
fail('Directory not found: ' + distDir);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const entries = fs.readdirSync(distDir, { withFileTypes: true });
|
|
65
|
+
const rootJs = entries
|
|
66
|
+
.filter((e) => e.isFile() && e.name.endsWith('.js'))
|
|
67
|
+
.map((e) => e.name);
|
|
68
|
+
const rootCss = entries
|
|
69
|
+
.filter((e) => e.isFile() && e.name.endsWith('.css'))
|
|
70
|
+
.map((e) => e.name);
|
|
71
|
+
|
|
72
|
+
const unknownJs = rootJs.filter((name) => {
|
|
73
|
+
if (SW_INFRASTRUCTURE_FILES.has(name)) return false;
|
|
74
|
+
return !KNOWN_JS_PREFIXES.some((p) => name.startsWith(p));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const unknownCss = rootCss.filter(
|
|
78
|
+
(name) => !KNOWN_CSS_PREFIXES.some((p) => name.startsWith(p))
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (unknownJs.length > 0 || unknownCss.length > 0) {
|
|
82
|
+
const lines = [
|
|
83
|
+
'Unrecognized root-level files in dist — these are NOT classified by',
|
|
84
|
+
'ngsw-config.json and will silently bypass the service worker cache:',
|
|
85
|
+
'',
|
|
86
|
+
];
|
|
87
|
+
if (unknownJs.length > 0) {
|
|
88
|
+
lines.push(' Unrecognized .js prefixes:');
|
|
89
|
+
unknownJs.forEach((n) => lines.push(' - ' + n));
|
|
90
|
+
}
|
|
91
|
+
if (unknownCss.length > 0) {
|
|
92
|
+
lines.push(' Unrecognized .css prefixes:');
|
|
93
|
+
unknownCss.forEach((n) => lines.push(' - ' + n));
|
|
94
|
+
}
|
|
95
|
+
lines.push('');
|
|
96
|
+
lines.push('Either:');
|
|
97
|
+
lines.push(' 1. Update @memberjunction/ng-explorer-service-worker/ngsw-config.json');
|
|
98
|
+
lines.push(' to add an asset-group entry for the new prefix.');
|
|
99
|
+
lines.push(' 2. Update KNOWN_JS_PREFIXES / KNOWN_CSS_PREFIXES in this script');
|
|
100
|
+
lines.push(' if the file should be intentionally excluded from caching.');
|
|
101
|
+
fail(lines.join('\n'));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
ok(
|
|
105
|
+
`${rootJs.length} root .js + ${rootCss.length} root .css verified ` +
|
|
106
|
+
`(${SW_INFRASTRUCTURE_FILES.size} SW infrastructure files ignored)`
|
|
107
|
+
);
|