Zig 0.17.0

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

  1. The app's keys, made once. They say that a message comes from your app.
  2. A subscription, per browser: what the browser gives the page when the visitor allows notifications. The app stores it.
  3. A message, sent to a subscription whenever there is something to say.

The keys

bash
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.

zig
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:

  1. asks the browser for a subscription (registration.pushManager.subscribe), giving the app's public key as applicationServerKey;
  2. posts what it gets (subscription.toJSON()) to the app;
  3. the app stores its three values with the account: endpoint, keys.p256dh and keys.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

zig
/// 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.
  • ttl is 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:

zig
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:

zig
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));
}