Skip to content
Merged
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
73 changes: 70 additions & 3 deletions apps/api/src/platform/Apns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,38 @@ export interface ApnsLiveActivityPush {
readonly priority?: 5 | 10 | undefined
}

/**
* A background push: no alert, no sound, no badge — just `content-available`,
* which wakes the app for a few seconds so it can refresh something.
*
* Maple sends exactly one of these, and only for the Home Screen widgets: an
* incident opening or resolving is the moment the numbers on a Lock Screen are
* most wrong, and it is the one moment worth spending a wake-up on.
*
* Three things about this channel that are easy to get wrong:
*
* - **iOS decides.** Background pushes are throttled on a schedule Apple does
* not publish and does not honour any particular rate. This is a hint, never
* a delivery guarantee, and nothing may depend on one arriving.
* - **Priority 5, always.** Apple explicitly rejects `content-available` at
* priority 10 on newer iOS, and a background push that jumps the queue is
* also the one users notice as battery drain.
* - **It expires.** A wake-up that arrives after the numbers have moved on
* again is a wasted radio, so these carry a short expiry and a collapse id —
* an organization only ever needs the most recent one.
*/
export interface ApnsBackgroundPush {
readonly deviceToken: string
readonly environment: MobilePushEnvironment
readonly bundleId: string
/** Delivered to the app alongside the wake-up; `aps` stays alert-free. */
readonly data: Record<string, string>
/** Newer wake-ups replace older ones — one per organization is plenty. */
readonly collapseId?: string | undefined
/** Seconds. Past this Apple stops trying, which is the wanted behaviour. */
readonly expiresInSeconds?: number | undefined
}

export type ApnsSendResult =
| { readonly outcome: "sent"; readonly apnsId: string | null }
/** Apple says this token is dead: stop sending to it. */
Expand All @@ -100,6 +132,7 @@ export interface ApnsClientApi {
readonly isConfigured: boolean
readonly send: (push: ApnsPush) => Effect.Effect<ApnsSendResult, ApnsError>
readonly sendLiveActivity: (push: ApnsLiveActivityPush) => Effect.Effect<ApnsSendResult, ApnsError>
readonly sendBackground: (push: ApnsBackgroundPush) => Effect.Effect<ApnsSendResult, ApnsError>
}

const APNS_HOSTS = {
Expand Down Expand Up @@ -184,6 +217,7 @@ export class ApnsClient extends Context.Service<ApnsClient, ApnsClientApi>()(
isConfigured: false,
send: unconfigured,
sendLiveActivity: unconfigured,
sendBackground: unconfigured,
} satisfies ApnsClientApi
}

Expand Down Expand Up @@ -283,10 +317,43 @@ export class ApnsClient extends Context.Service<ApnsClient, ApnsClientApi>()(
return yield* dispatch(push.environment, push.deviceToken, headers, body)
})

const sendBackground = Effect.fn("ApnsClient.sendBackground")(function* (
push: ApnsBackgroundPush,
) {
if (!ALLOWED_TOPICS.has(push.bundleId)) {
return yield* new ApnsError({
message: `Refusing to send for an unknown APNs topic: ${push.bundleId}`,
})
}
const token = yield* currentToken
const body = {
// `content-available` and nothing else. Any of `alert`, `sound`
// or `badge` alongside it turns this into a visible
// notification, which is not what a widget refresh should cost
// the user.
aps: { "content-available": 1 },
...push.data,
}
const nowSeconds = Math.floor((yield* Clock.currentTimeMillis) / 1000)
const headers = {
authorization: `bearer ${token}`,
"apns-topic": push.bundleId,
"apns-push-type": "background",
// Apple rejects `content-available` at priority 10.
"apns-priority": "5",
"apns-expiration": String(nowSeconds + (push.expiresInSeconds ?? 900)),
...(push.collapseId !== undefined
? { "apns-collapse-id": push.collapseId.slice(0, 64) }
: undefined),
}
return yield* dispatch(push.environment, push.deviceToken, headers, body)
})

/**
* One POST to Apple and one reading of its answer, shared by both push
* types: the difference between an alert and a Live Activity is entirely
* in the headers and the body, never in how a 410 is interpreted.
* kinds: the difference between an alert, a Live Activity and a
* background wake-up is entirely in the headers and the body, never in
* how a 410 is interpreted.
*/
const dispatch = Effect.fn("ApnsClient.dispatch")(function* (
environment: MobilePushEnvironment,
Expand Down Expand Up @@ -399,7 +466,7 @@ export class ApnsClient extends Context.Service<ApnsClient, ApnsClientApi>()(
return yield* dispatch(push.environment, push.pushToken, headers, body)
})

return { isConfigured: true, send, sendLiveActivity } satisfies ApnsClientApi
return { isConfigured: true, send, sendLiveActivity, sendBackground } satisfies ApnsClientApi
}),
},
) {
Expand Down
31 changes: 17 additions & 14 deletions apps/api/src/routes/v2/mobile-devices.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ export const HttpV2MobileDevicesLive = HttpApiBuilder.group(MapleApiV2, "mobileD
const devices = yield* MobileDevicesService
const activities = yield* LiveActivitiesService

/** The device the credential belongs to, or a 404 rather than a key. */
const requireDevice = Effect.fn("HttpV2MobileDevices.requireDevice")(function* (
orgId: CurrentTenant.TenantSchema["orgId"],
token: string,
) {
const device = yield* devices.find(orgId, "ios", token)
if (device === null) {
return yield* new MobileDeviceNotFoundError({
message: "Device not registered",
token,
})
}
return device
})

return handlers
.handle("list", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -77,13 +92,7 @@ export const HttpV2MobileDevicesLive = HttpApiBuilder.group(MapleApiV2, "mobileD
// The activity belongs to a device, and the device is what says
// which APNs host its tokens live on — an activity whose device is
// gone could never be pushed to.
const device = yield* devices.find(tenant.orgId, "ios", params.token)
if (device === null) {
return yield* new MobileDeviceNotFoundError({
message: "Device not registered",
token: params.token,
})
}
const device = yield* requireDevice(tenant.orgId, params.token)
const activity = yield* activities.register({
orgId: tenant.orgId,
deviceId: device.id,
Expand All @@ -105,13 +114,7 @@ export const HttpV2MobileDevicesLive = HttpApiBuilder.group(MapleApiV2, "mobileD
.handle("endLiveActivity", ({ params }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const device = yield* devices.find(tenant.orgId, "ios", params.token)
if (device === null) {
return yield* new MobileDeviceNotFoundError({
message: "Device not registered",
token: params.token,
})
}
const device = yield* requireDevice(tenant.orgId, params.token)
yield* activities.endForDevice(
tenant.orgId,
device.id,
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/routes/v2/telemetry.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const metricCatalogRowSchema = Schema.Struct({
isMonotonic: CH.CHNumber,
})

const serviceCatalogRowSchema = Schema.Struct({
export const serviceCatalogRowSchema = Schema.Struct({
serviceName: Schema.String,
serviceNamespaces: Schema.Array(Schema.String),
deploymentEnvironments: Schema.Array(Schema.String),
Expand Down Expand Up @@ -1029,7 +1029,7 @@ const BASELINE_CACHE_SECONDS = 3600
* the baseline rows collapse the same way: the busiest row wins rather than
* the numbers being averaged across populations that don't compare.
*/
type ServiceBaselines = ReadonlyMap<string, { p95LatencyMs: number; spanCount: number }>
export type ServiceBaselines = ReadonlyMap<string, { p95LatencyMs: number; spanCount: number }>

const collapseBaselines = (
rows: readonly {
Expand All @@ -1048,7 +1048,7 @@ const collapseBaselines = (
return map
}

const toService = (
export const toService = (
row: {
serviceName: string
serviceNamespaces: readonly string[]
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/routes/v2/v2-test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import {
HttpV2ServicesLive,
HttpV2TracesLive,
} from "./telemetry.http"
import { HttpV2WidgetSummaryLive } from "./widget-summary.http"
import { HttpV2WidgetCredentialsLive } from "./widget-credentials.http"

/**
* Test-only support for the v2 HTTP harnesses. `HttpApiBuilder.layer(MapleApiV2)`
Expand Down Expand Up @@ -90,6 +92,8 @@ export const AllV2GroupLayersLive = Layer.mergeAll(
HttpV2MetricsLive,
HttpV2ServicesLive,
HttpV2ServiceMapLive,
HttpV2WidgetSummaryLive,
HttpV2WidgetCredentialsLive,
// The share group's own dependencies are satisfied here rather than by every
// harness: most v2 route tests never touch the share endpoints, and threading
// inert services through two dozen call sites to register a group they never
Expand Down
Loading
Loading