Push notifications
Web Push shows a notification on someone's phone or computer when the app is closed: a booking was confirmed, a ticket was answered. The message goes from the app to a push service the browser's maker runs, and from there to the device. spider.push does the app's part: it encrypts the message so that only that browser reads it, signs it as coming from your app, and sends it.
The code on this page is from examples/guide (source): tests that send to a push service of their own. What the browser does is described, not shown: it needs a person to allow notifications.
The three parts
- The app's keys, made once. They say that a message comes from your app.
- A subscription, per browser: what the browser gives the page when the visitor allows notifications. The app stores it.
- A message, sent to a subscription whenever there is something to say.
The keys
spider generate-vapid mailto:ops@example.com
It prints three lines for .env: VAPID_SUBJECT, VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY (the CLI page shows the output). The subject is how a push service can reach whoever runs the app. spider.push.WebPush.initFromEnv() reads the three.
Make the pair once and keep it: subscriptions are tied to the public key, and a new pair loses every subscriber. The private key is a secret; the public one goes to the page.
test "push: the keys are made once; the public one goes to the page" {
const keys = push.WebPush.generateKeys(std.testing.io);
// The two values of the app's configuration.
try std.testing.expectEqual(@as(usize, 43), keys.privateKeyText().len);
try std.testing.expectEqual(@as(usize, 87), keys.publicKeyText().len);
// Another call is another pair.
const other = push.WebPush.generateKeys(std.testing.io);
try std.testing.expect(!std.mem.eql(u8, &keys.publicKeyText(), &other.publicKeyText()));
}
The browser's side
spider add pwa gives the project a service worker (public/sw.js), the script a browser keeps running for the site. It already shows a notification for each message that arrives, and opens the app at the message's address when the notification is clicked. A message is JSON with title, body, url and tag.
Asking the visitor is the app's to write, because when to ask is a decision: after they did something that makes a notification useful, never on the first page. The page then:
- asks the browser for a subscription (
registration.pushManager.subscribe), giving the app's public key asapplicationServerKey; - posts what it gets (
subscription.toJSON()) to the app; - the app stores its three values with the account:
endpoint,keys.p256dhandkeys.auth.
One account can have several subscriptions: a phone, a laptop. An endpoint is a secret of sorts, since whoever holds a subscription can try to push to that device: keep it out of logs.
Sending
/// What a browser hands the page when the visitor allows notifications:
/// where to send, and the two keys only that browser can read with. Here
/// they are made up; an app stores the ones each browser gave it.
const Browser = struct {
keys: push.VapidKeys,
p256dh: [87]u8,
endpoint_buf: [64]u8 = undefined,
fn init() Browser {
const keys = push.WebPush.generateKeys(std.testing.io);
return .{ .keys = keys, .p256dh = keys.publicKeyText() };
}
fn subscription(self: *Browser, service_port: u16) !push.PushSubscription {
return .{
.endpoint = try std.fmt.bufPrint(&self.endpoint_buf, "http://127.0.0.1:{d}/send/a1b2c3", .{service_port}),
.p256dh = &self.p256dh,
// 16 bytes, base64url without padding.
.auth = "MDEyMzQ1Njc4OWFiY2RlZg",
};
}
};
test "push: a message goes to the browser's push service, encrypted and signed" {
var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena_state.deinit();
// The app's identity: `spider generate-vapid` prints these three.
const keys = push.WebPush.generateKeys(std.testing.io);
const private_key = keys.privateKeyText();
const public_key = keys.publicKeyText();
const sender = push.WebPush.init(.{ .subject = "mailto:ops@guide.example", .private_key = &private_key, .public_key = &public_key });
var service = try Service.listen("201 Created", "");
const thread = try std.Thread.spawn(.{}, Service.serveOne, .{&service});
var browser = Browser.init();
const message =
\\{"title":"Booking confirmed","body":"Cedar, tomorrow at 10:00","url":"/bookings/42"}
;
// Kept for an hour if the device is offline.
try sender.sendRaw(arena_state.allocator(), std.testing.io, try browser.subscription(service.port), message, 3600);
thread.join();
try std.testing.expect(service.got("POST /send/a1b2c3 "));
try std.testing.expect(service.got("Authorization: vapid t="));
try std.testing.expect(service.got("Content-Encoding: aes128gcm"));
try std.testing.expect(service.got("TTL: 3600"));
// The service carries the message and cannot read it.
try std.testing.expect(!service.got("Booking confirmed"));
}
sendRaw(arena, io, subscription, payload, ttl)is for outside a request: a job, a start-up task. In a handler,send(c, subscription, payload, ttl)uses the request's memory.- The payload is bytes: JSON, by the service worker's convention above.
ttlis how many seconds the push service keeps the message for a device that is offline. A message that is useless an hour later should not arrive a day later.
Sending waits for the push service to answer. To notify many devices, do it in a job, not in the request that caused it.
When a subscription is gone
A visitor turns notifications off, clears the browser or changes phone, and nobody tells the app. The push service does, the next time the app sends:
test "push: a subscription the browser dropped says so, and the app forgets it" {
var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena_state.deinit();
const keys = push.WebPush.generateKeys(std.testing.io);
const private_key = keys.privateKeyText();
const public_key = keys.publicKeyText();
const sender = push.WebPush.init(.{ .subject = "mailto:ops@guide.example", .private_key = &private_key, .public_key = &public_key });
var service = try Service.listen("410 Gone", "");
const thread = try std.Thread.spawn(.{}, Service.serveOne, .{&service});
var browser = Browser.init();
const result = sender.sendRaw(arena_state.allocator(), std.testing.io, try browser.subscription(service.port), "{}", 3600);
thread.join();
// Delete the subscription: it will never work again.
try std.testing.expectError(error.PushSubscriptionExpired, result);
}
error.PushSubscriptionExpired: delete the subscription.error.PushForbidden: the subscription was made with other keys. It happens after the app's keys were changed: delete it too.error.PushSendFailed: the service refused for another reason, and the log has the status. Try again later.error.InvalidKeyLength: the stored subscription is damaged or was forged. Nothing is sent.
How much fits
One message carries up to spider.push.max_payload_len bytes, 3993. Send what the notification shows and an address, and let the app load the rest when it opens:
test "push: a message has a size limit" {
var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena_state.deinit();
const keys = push.WebPush.generateKeys(std.testing.io);
const private_key = keys.privateKeyText();
const public_key = keys.publicKeyText();
const sender = push.WebPush.init(.{ .subject = "mailto:ops@guide.example", .private_key = &private_key, .public_key = &public_key });
var browser = Browser.init();
const too_long: [push.max_payload_len + 1]u8 = @splat('x');
try std.testing.expectError(error.PayloadTooLarge, sender.sendRaw(arena_state.allocator(), std.testing.io, try browser.subscription(1), &too_long, 60));
}