Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions docs/service-worker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# The service worker in development

Push notifications are received by a service worker. Until MN-F03, the service
worker only ran in production builds, so **no push could ever arrive while
developing** and nobody could test any push work.

This describes what changed, what it costs, and how to get out of trouble.

## What changed

Two halves. Doing only one of them leaves the app worse than before.

1. **`angular.json`** — the `development` build configuration now sets
`"serviceWorker": "ngsw-config.json"`. Before this it was set only on
`production`, so a development build never generated `ngsw-worker.js` at all.
2. **`doubtfire-angular.module.ts`** — `ServiceWorkerModule.register` now reads
`environment.production || environment.enableServiceWorker` instead of
`environment.production`.

Order matters. Flipping the flag without the first half leaves the app asking the
server for a file that does not exist. That fails quietly and looks exactly like
push being broken.

## Turning it off

`src/environments/environment.ts`:

```ts
enableServiceWorker: false,
```

Then reload with the worker cleared (below). Production is unaffected either way,
because `production: true` already enables it.

## Which build configuration is actually in use

`package.json` runs `ng serve --configuration $NODE_ENV`, so **the configuration
depends on an environment variable and is not necessarily `development`.**
"I added it to `development`" does not mean `development` is the one running.

Check what `$NODE_ENV` is:

docker exec doubtfire-web printenv NODE_ENV

In the Docker stack it is `docker`. That is a **serve** configuration in
`angular.json`, and it maps to the `development` **build** configuration:

```json
"docker": { "buildTarget": "doubtfire:build:development" }
```

So the change does apply in Docker. If you add another serve configuration, point
it at a build configuration that has `serviceWorker` set, or push silently stops
working for anyone using it.

## Registration is delayed six seconds

`doubtfire-angular.module.ts` sets:

```ts
registrationStrategy: () => interval(6000).pipe(take(1)),
```

The worker registers **six seconds after bootstrap**, not at bootstrap. Anything
that asks for the service worker during app init finds nothing there. Wait on
`navigator.serviceWorker.ready` rather than assuming it exists — MN-C01 depends
on this.

## What it costs

**Measured** on the Docker stack, Angular 22, `ng serve`:

- `ngsw-worker.js` and `ngsw.json` are served (they were 404 before). The dev
server generates them, so no separate `ng build` step is needed.
- `ngsw.json` lists 158 hashed files, and the `app` asset group prefetches
`/index.html`, `/main.js`, `/styles.css`, `/polyfills.js` and `/scripts.js`.
**The whole app bundle is cached.**
- A source change does regenerate the manifest: after editing a file under `src/`
the `/main.js` hash in `ngsw.json` changed, so the worker can see there is an
update.
- API calls are not cached. The `api` data group in `ngsw-config.json` uses
`"strategy": "freshness"` with `maxAge: 0u` and `maxSize: 0`.

**Confirmed in a browser** on 2026-08-02: the worker registers, and a push sent
from the api arrives as a desktop notification. So the two halves above are
enough to make push work in development.

What still has not been measured is how live reload behaves once the worker is
serving from its cache over a long session. Angular's worker normally picks up a
new version on a later page load rather than the current one, which would mean
**after saving a file you reload and still see the old code**. Treat that as
expected until somebody hits it.

The cost to watch for is stale app code, not stale data — API responses are not
cached. If something you just changed is not showing up, clear the worker before
assuming the change is wrong.

## Push subscription rotation

Angular 22's worker handles the browser's `pushsubscriptionchange` event and
forwards it through `SwPush.pushSubscriptionChanges`. The app starts that
listener with its other push lifecycle services. When the browser supplies a
replacement, the app POSTs it to `/api/push_subscriptions` first and removes the
old endpoint only after the replacement is stored. A keys-only rotation keeps
the same endpoint and updates the existing api row in place.

The focused service tests simulate this event because browsers do not provide a
reliable way to force a real rotation. They verify the POST-before-DELETE order,
keys-only updates, teardown, and that one failed api request does not stop later
rotations. A change event with no replacement cannot be repaired automatically:
creating a fresh subscription may require a user gesture, so the user must use
the existing opt-in control again; the api removes the dead row after a failed
delivery.

## Clearing a stuck service worker

Fastest, in the browser console:

```js
(await navigator.serviceWorker.getRegistrations()).forEach((r) => r.unregister());
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
location.reload();
```

Through dev tools instead:

1. Application → Service Workers → **Unregister**.
2. Application → Storage → **Clear site data**.
3. Reload.

While actively working on the app, Application → Service Workers → **Bypass for
network** stops the worker serving cached responses without unregistering it. A
normal hard reload is not enough on its own, because the worker still intercepts.

## Checking it is working

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4200/ngsw-worker.js

200 is what you want. 404 means the build configuration in use has no
`serviceWorker` entry — see "Which build configuration is actually in use".

In the browser: dev tools → Application → Service Workers. It should be
registered and activated roughly six seconds after the page loads.

Then follow `doubtfire-api/docs/notifications/push-setup.md` to register the
browser and send a push.
9 changes: 4 additions & 5 deletions ngsw-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,7 @@
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(eot|svg|cur|jpg|png|png?default=blank&size=25webp|gif|otf|ttf|woff|woff2|ani)"
]
"files": ["/assets/**", "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"]
}
},
{
Expand All @@ -60,6 +57,8 @@
"!/beta/**",
"!/beta",
"!/legacy",
"!/legacy/**"
"!/legacy/**",
"!/api",
"!/api/**"
]
}
67 changes: 67 additions & 0 deletions src/app/api/models/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {Entity} from 'ngx-entity-service';

/**
* Something that happened which the signed in user should be told about.
*
* `notificationType` is the category the user's preferences switch on, one of
* task, feedback, portfolio, extension, general or unit_hub. The api gates
* delivery on task, feedback, portfolio and unit_hub against the
* receive_*_notifications columns, so the web app never has to check a
* preference before rendering one of these.
*
* `event` is the stable, specific hook (for example `task_comment_created`).
* Presentation can vary by event without inferring meaning from message text.
*/
export class Notification extends Entity {
id: number;
notificationType: string;
event: string;
message: string;

/**
* Where to send the user when they click this, or null when there is nowhere
* to go. Treat it as a route within the app, not an absolute url.
*
* The column is nullable on the api, so guard it before calling anything on
* it. The union is documentation for now, the repo builds with strict off.
*/
link: string | null;

/**
* The ids of the page this is about, sent beside link by newer apis.
*
* Left undefined when the api did not send them at all, which is how an older
* api is told apart from a newer one saying the record is gone (null). Do not
* give these a default, or that difference is lost.
*/
unitId?: number | null;
projectId?: number | null;
studentId?: number | null;
taskDefinitionId?: number | null;
taskDefinitionAbbr?: string | null;
taskId?: number | null;
commentId?: number | null;
groupId?: number | null;

/**
* The Unit Hub announcement or session a unit_hub notification is about.
* Null once it has been deleted, undefined from an api that predates them.
*/
announcementId?: number | null;
sessionId?: number | null;

/**
* When the user read this, or null while it is still unread.
*/
readAt: Date | null;

/**
* Never null. The api column is NOT NULL, which is why this one can go
* through the plain date mapping and readAt cannot.
*/
createdAt: Date;

public get isRead(): boolean {
return this.readAt != null;
}
}
153 changes: 153 additions & 0 deletions src/app/api/services/notification-route.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// MN-C03: one security boundary for every notification destination.
import {Injectable} from '@angular/core';
import {Router} from '@angular/router';
import {AuthReturnUrlService} from 'src/app/security/auth-return-url.service';
import {AuthenticationService} from './authentication.service';
import {
NotificationFeedbackRouteIntent,
NotificationFeedbackRouteIntentService,
} from './notification-feedback-route-intent.service';

export const NOTIFICATION_ROUTE_FALLBACK = '/notifications';

const MAX_NOTIFICATION_ROUTE_LENGTH = 256;
const CONTROL_CHARACTER_MAX = 0x1f;
const DELETE_CHARACTER = 0x7f;
const FORBIDDEN_ROUTE_TEXT = /[\s\\?#%]/;
const PROJECT_ROOT_ROUTE = /^\/projects\/[1-9]\d*\/(?:dashboard|groups)$/;
const PROJECT_TASK_ROUTE =
/^\/projects\/[1-9]\d*\/dashboard\/[A-Za-z0-9][A-Za-z0-9._-]{0,31}(?:\/feedback)?$/;
// The one destination allowed a query string, and only as two numeric ids.
const UNIT_HUB_ROUTE = /^\/unit-hub\?unit=[1-9]\d{0,9}&(?:announcement|session)=[1-9]\d{0,9}$/;
const PROJECT_FEEDBACK_ROUTE =
/^\/projects\/([1-9]\d*)\/dashboard\/([A-Za-z0-9][A-Za-z0-9._-]{0,31})\/feedback$/;

function hasControlCharacters(value: string): boolean {
return Array.from(value).some((character) => {
const characterCode = character.charCodeAt(0);
return characterCode <= CONTROL_CHARACTER_MAX || characterCode === DELETE_CHARACTER;
});
}

@Injectable({providedIn: 'root'})
export class NotificationRouteService {
constructor(
private router: Router,
private authentication: AuthenticationService,
private authReturnUrl: AuthReturnUrlService,
private feedbackIntents?: NotificationFeedbackRouteIntentService,
) {}

public resolve(link: unknown): string {
if (typeof link !== 'string') {
return NOTIFICATION_ROUTE_FALLBACK;
}
if (link.length === 0 || link.length > MAX_NOTIFICATION_ROUTE_LENGTH) {
return NOTIFICATION_ROUTE_FALLBACK;
}
if (link !== link.trim()) {
return NOTIFICATION_ROUTE_FALLBACK;
}
if (!link.startsWith('/') || link.startsWith('//')) {
return NOTIFICATION_ROUTE_FALLBACK;
}
if (UNIT_HUB_ROUTE.test(link)) {
return link;
}
if (hasControlCharacters(link) || FORBIDDEN_ROUTE_TEXT.test(link)) {
return NOTIFICATION_ROUTE_FALLBACK;
}

if (link === NOTIFICATION_ROUTE_FALLBACK || PROJECT_ROOT_ROUTE.test(link)) {
return link;
}

if (!PROJECT_TASK_ROUTE.test(link)) {
return NOTIFICATION_ROUTE_FALLBACK;
}

return link;
}

public navigate(link: unknown): Promise<boolean> {
return this.navigateToTarget(this.resolve(link));
}

/**
* Go to an in-app url that was built here from ids, not taken from a link.
*
* NotificationTargetService builds these with the router from numeric ids and
* a task abbreviation the router encodes, so there is no raw text to screen
* and the allow-list above, which only knows the api's link shapes, would
* turn away the staff pages it needs.
*/
public navigateToTarget(target: string): Promise<boolean> {
const feedbackIntent = this.createFeedbackIntent(target);

// A service-worker click can reach an already-open anonymous client after
// the one-off startup authentication check has finished. Save the already
// allow-listed notification target and enter the normal sign-in flow now,
// rather than waiting for a protected request to fail.
if (!this.authentication.isAuthenticated()) {
this.authReturnUrl.remember(target);
if (this.currentPath() === '/sign_in') {
return Promise.resolve(true);
}
return this.finishNavigation(this.router.navigateByUrl('/sign_in'), feedbackIntent);
}

if (this.currentPath() === target) {
return Promise.resolve(true);
}
return this.finishNavigation(this.router.navigateByUrl(target), feedbackIntent);
}

private createFeedbackIntent(target: string): NotificationFeedbackRouteIntent | null {
if (!this.feedbackIntents) {
return null;
}

// A later notification click supersedes any earlier route intent, including
// one waiting behind sign-in. This prevents an old task from revealing when
// a different destination eventually resolves.
this.feedbackIntents.clear();

const match = target.match(PROJECT_FEEDBACK_ROUTE);
if (!match) {
return null;
}

return this.feedbackIntents.request({
projectId: Number(match[1]),
taskAbbreviation: match[2],
});
}

private async finishNavigation(
navigation: Promise<boolean>,
feedbackIntent: NotificationFeedbackRouteIntent | null,
): Promise<boolean> {
try {
const navigated = await navigation;
if (!navigated && feedbackIntent) {
this.feedbackIntents?.cancel(feedbackIntent);
}
return navigated;
} catch (error) {
if (feedbackIntent) {
this.feedbackIntents?.cancel(feedbackIntent);
}
throw error;
}
}

private currentPath(): string {
const routerUrl = typeof this.router.url === 'string' ? this.router.url : '/';
const withoutFragment = routerUrl.split('#', 1)[0];
const withoutQuery = withoutFragment.split('?', 1)[0];
if (!withoutQuery) {
return '/';
}
return withoutQuery.length > 1 ? withoutQuery.replace(/\/+$/, '') : withoutQuery;
}
}
Loading