@lunora/notify 1.0.0-alpha.1 → 1.0.0-alpha.3

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 CHANGED
@@ -98,6 +98,19 @@ for (const message of batch.messages) await runPushBroadcastJob(ctx.push, messag
98
98
 
99
99
  `SubscriptionStore` implementations: `memorySubscriptionStore()` (non-durable default, tests/dev) and `d1SubscriptionStore(db)` (durable, edge-safe, lazy table creation). Lifecycle: register (upsert), list/filter (by kind or user), status marking, and automatic prune of gone subscriptions on send/broadcast.
100
100
 
101
+ ## Delivery observability
102
+
103
+ Every send is counted onto `ctx.metrics` and failures onto `ctx.log` for you — codegen threads the request's logger/metrics into `ctx.notify` (`createNotify(notifyConfig, env, { log, metrics })`), so there is nothing to wire. Two low-cardinality metric series feed the durable metric history + trend charts:
104
+
105
+ - **`notify.send`** `{ channel, provider, status }` — attempted sends. `status` is `accepted` (the provider took it), `failed`, or `gone` (endpoint unregistered — 404/410 / FCM `UNREGISTERED` — and pruned). A single send counts 1; a **broadcast aggregates** into one count per `(provider, status)` bucket (value = the bucket's count), not one per recipient — each `ctx.metrics.count` is a durable write.
106
+ - **`notify.skipped`** `{ channel, reason }` — a send that reached nobody: `no-subscriptions-matched` (empty broadcast) or `channel-not-configured`.
107
+
108
+ A **failed** send also emits one `ctx.log.warn` line carrying the error and, for push, the subscription/user ids — trace-correlated to the enclosing action and durably archived. Successes and prunes stay off the log; failure logs stay per-recipient even in a broadcast (they have no durable write).
109
+
110
+ `accepted` means the provider **accepted** the message, not that it was delivered or opened: Web Push and FCM give no delivery/open receipts, so the status stops at the send attempt. See [Observability → Delivery metrics](/docs/concepts/observability#delivery-metrics-notify).
111
+
101
112
  ## Status
102
113
 
103
- Phases 0–2 and the Phase-3 advisor lints + queue-backed fan-out are shipped. Remaining: the codegen ctx-splice that auto-wires `ctx.notify` / `ctx.push` from `lunora/notify.ts` (mirroring `defineFlags` → `ctx.flags`; `createNotify` is the factory it calls), the codegen advisor feeder, and the Studio Notifications page. See `plans/165-push-notifications.md`.
114
+ Shipped: Web Push + FCM channels, chat / in-app / webhook senders, device-subscription storage (memory + D1), queue-backed fan-out, the codegen ctx-splice that auto-wires `ctx.notify` / `ctx.push` from `lunora/notify.ts` (via `createNotify`, mirroring `defineFlags` → `ctx.flags`), the `notify_send_outside_action` advisor lint, the Studio **Notifications** page (registered-device inspector), and [delivery observability](#delivery-observability).
115
+
116
+ Deferred: a filterable per-delivery **activity feed** and per-device history (a Novu-style drill-down). Web Push / FCM give no delivery/open receipts, so it would report only the send-attempt outcome; it needs a field-level predicate on the durable log reader (or a dedicated store) and is not planned until asked for.
package/dist/index.d.mts CHANGED
@@ -119,6 +119,49 @@ interface BroadcastResult {
119
119
  /** Total subscriptions attempted. */
120
120
  total: number;
121
121
  }
122
+ /**
123
+ * The compact, stable delivery-status vocabulary emitted on notify observability
124
+ * signals — the `status` dimension on the `notify.send` metric and the failure
125
+ * log line. Modeled on Novu's execution status, but honest to edge push:
126
+ *
127
+ * - `accepted` — the provider took the message (a `Receipt.successful` send).
128
+ * - `failed` — a provider error; the log line carries the `error` text.
129
+ * - `gone` — the endpoint is unregistered (404/410, FCM `UNREGISTERED`) and pruned; push-only.
130
+ *
131
+ * Web Push and FCM give no delivery/open receipts, so the vocabulary stops at the
132
+ * send attempt: a `delivered`/`opened` status would be a lie for these channels.
133
+ * The one place a later `seen`/`read` is real is the in-app inbox, where the
134
+ * client posts a read receipt back — out of scope here.
135
+ */
136
+ type NotifyDeliveryStatus = "accepted" | "failed" | "gone";
137
+ /**
138
+ * Why a send fanned out to nobody — the "sent 0 because…" signal (mirrors Novu's
139
+ * pre-send `DetailEnum` reasons). Emitted as the `reason` dimension on a
140
+ * `notify.skipped` metric so a no-op is visible in the Studio metric/trend view
141
+ * instead of silent.
142
+ *
143
+ * - `no-subscriptions-matched` — the store held no device for the broadcast filter.
144
+ * - `channel-not-configured` — the target channel was never wired in `defineNotify`.
145
+ */
146
+ type NotifySkipReason = "channel-not-configured" | "no-subscriptions-matched";
147
+ /**
148
+ * The minimal structural slice of `ctx.log` the notify facade emits through — just
149
+ * the `warn` severity it uses for a failed delivery. Structural (rather than a
150
+ * dependency on `@lunora/server`'s `LunoraLogger`) so codegen passes the real
151
+ * `ctx.log` and a test passes a spy — the D1-store `D1Like` pattern, applied to
152
+ * observability.
153
+ */
154
+ interface NotifyLogger {
155
+ warn: (message: string, fields?: Record<string, unknown>) => void;
156
+ }
157
+ /**
158
+ * The minimal structural slice of `ctx.metrics` the notify facade emits through —
159
+ * the `count` instrument backing the `notify.send` / `notify.skipped` series.
160
+ * Structural for the same reason as {@link NotifyLogger}.
161
+ */
162
+ interface NotifyMetrics {
163
+ count: (name: string, value?: number, attributes?: Record<string, unknown>) => void;
164
+ }
122
165
  /**
123
166
  * The push sub-facade — spliced onto ctx as `ctx.push` (and reachable as
124
167
  * `ctx.notify.push`). Owns the device-subscription lifecycle plus targeted and
@@ -268,6 +311,22 @@ interface CreateNotifyOptions {
268
311
  * config resolution entirely.
269
312
  */
270
313
  engine?: Notification;
314
+ /**
315
+ * The request's `ctx.log` (structural {@link NotifyLogger}). Codegen threads
316
+ * `ctx.log` in; when present the facade emits one `warn` line per FAILED
317
+ * delivery — trace-correlated to the enclosing action and durably archived by
318
+ * the log sink. Successes and prunes stay off the log to keep the archive
319
+ * clean; they are counted on `metrics` instead. Absent ⇒ no log emits.
320
+ */
321
+ log?: NotifyLogger;
322
+ /**
323
+ * The request's `ctx.metrics` (structural {@link NotifyMetrics}). Codegen
324
+ * threads `ctx.metrics` in; when present the facade counts every send on the
325
+ * `notify.send` series (dimensions `channel` / `provider` / `status`) and every
326
+ * no-op on `notify.skipped` (`channel` / `reason`) — feeding the durable metric
327
+ * history + trend charts. Absent ⇒ no metric emits.
328
+ */
329
+ metrics?: NotifyMetrics;
271
330
  /** Suppress the in-memory-store dev warning (tests set this). */
272
331
  silent?: boolean;
273
332
  }
@@ -422,4 +481,4 @@ declare const targetOf: (subscription: StoredSubscription) => string;
422
481
  * (a cert/session expiry) can never permanently drop a valid subscription.
423
482
  */
424
483
  declare const isGoneError: (message: string | undefined) => boolean;
425
- export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions, FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyEnv, type PushBroadcastJob, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore, WEB_PUSH_ENV_KEYS, type WebPushConfigFactory, buildEngine, createNotify, d1SubscriptionStore, defineNotify, enqueuePushBroadcast, fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastJob, targetOf, webPushFromEnv, webPushId };
484
+ export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions, FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore, WEB_PUSH_ENV_KEYS, type WebPushConfigFactory, buildEngine, createNotify, d1SubscriptionStore, defineNotify, enqueuePushBroadcast, fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastJob, targetOf, webPushFromEnv, webPushId };
package/dist/index.d.ts CHANGED
@@ -119,6 +119,49 @@ interface BroadcastResult {
119
119
  /** Total subscriptions attempted. */
120
120
  total: number;
121
121
  }
122
+ /**
123
+ * The compact, stable delivery-status vocabulary emitted on notify observability
124
+ * signals — the `status` dimension on the `notify.send` metric and the failure
125
+ * log line. Modeled on Novu's execution status, but honest to edge push:
126
+ *
127
+ * - `accepted` — the provider took the message (a `Receipt.successful` send).
128
+ * - `failed` — a provider error; the log line carries the `error` text.
129
+ * - `gone` — the endpoint is unregistered (404/410, FCM `UNREGISTERED`) and pruned; push-only.
130
+ *
131
+ * Web Push and FCM give no delivery/open receipts, so the vocabulary stops at the
132
+ * send attempt: a `delivered`/`opened` status would be a lie for these channels.
133
+ * The one place a later `seen`/`read` is real is the in-app inbox, where the
134
+ * client posts a read receipt back — out of scope here.
135
+ */
136
+ type NotifyDeliveryStatus = "accepted" | "failed" | "gone";
137
+ /**
138
+ * Why a send fanned out to nobody — the "sent 0 because…" signal (mirrors Novu's
139
+ * pre-send `DetailEnum` reasons). Emitted as the `reason` dimension on a
140
+ * `notify.skipped` metric so a no-op is visible in the Studio metric/trend view
141
+ * instead of silent.
142
+ *
143
+ * - `no-subscriptions-matched` — the store held no device for the broadcast filter.
144
+ * - `channel-not-configured` — the target channel was never wired in `defineNotify`.
145
+ */
146
+ type NotifySkipReason = "channel-not-configured" | "no-subscriptions-matched";
147
+ /**
148
+ * The minimal structural slice of `ctx.log` the notify facade emits through — just
149
+ * the `warn` severity it uses for a failed delivery. Structural (rather than a
150
+ * dependency on `@lunora/server`'s `LunoraLogger`) so codegen passes the real
151
+ * `ctx.log` and a test passes a spy — the D1-store `D1Like` pattern, applied to
152
+ * observability.
153
+ */
154
+ interface NotifyLogger {
155
+ warn: (message: string, fields?: Record<string, unknown>) => void;
156
+ }
157
+ /**
158
+ * The minimal structural slice of `ctx.metrics` the notify facade emits through —
159
+ * the `count` instrument backing the `notify.send` / `notify.skipped` series.
160
+ * Structural for the same reason as {@link NotifyLogger}.
161
+ */
162
+ interface NotifyMetrics {
163
+ count: (name: string, value?: number, attributes?: Record<string, unknown>) => void;
164
+ }
122
165
  /**
123
166
  * The push sub-facade — spliced onto ctx as `ctx.push` (and reachable as
124
167
  * `ctx.notify.push`). Owns the device-subscription lifecycle plus targeted and
@@ -268,6 +311,22 @@ interface CreateNotifyOptions {
268
311
  * config resolution entirely.
269
312
  */
270
313
  engine?: Notification;
314
+ /**
315
+ * The request's `ctx.log` (structural {@link NotifyLogger}). Codegen threads
316
+ * `ctx.log` in; when present the facade emits one `warn` line per FAILED
317
+ * delivery — trace-correlated to the enclosing action and durably archived by
318
+ * the log sink. Successes and prunes stay off the log to keep the archive
319
+ * clean; they are counted on `metrics` instead. Absent ⇒ no log emits.
320
+ */
321
+ log?: NotifyLogger;
322
+ /**
323
+ * The request's `ctx.metrics` (structural {@link NotifyMetrics}). Codegen
324
+ * threads `ctx.metrics` in; when present the facade counts every send on the
325
+ * `notify.send` series (dimensions `channel` / `provider` / `status`) and every
326
+ * no-op on `notify.skipped` (`channel` / `reason`) — feeding the durable metric
327
+ * history + trend charts. Absent ⇒ no metric emits.
328
+ */
329
+ metrics?: NotifyMetrics;
271
330
  /** Suppress the in-memory-store dev warning (tests set this). */
272
331
  silent?: boolean;
273
332
  }
@@ -422,4 +481,4 @@ declare const targetOf: (subscription: StoredSubscription) => string;
422
481
  * (a cert/session expiry) can never permanently drop a valid subscription.
423
482
  */
424
483
  declare const isGoneError: (message: string | undefined) => boolean;
425
- export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions, FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyEnv, type PushBroadcastJob, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore, WEB_PUSH_ENV_KEYS, type WebPushConfigFactory, buildEngine, createNotify, d1SubscriptionStore, defineNotify, enqueuePushBroadcast, fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastJob, targetOf, webPushFromEnv, webPushId };
484
+ export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions, FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore, WEB_PUSH_ENV_KEYS, type WebPushConfigFactory, buildEngine, createNotify, d1SubscriptionStore, defineNotify, enqueuePushBroadcast, fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastJob, targetOf, webPushFromEnv, webPushId };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  export { FCM_ENV_KEYS, WEB_PUSH_ENV_KEYS, fcmFromEnv, webPushFromEnv } from './packem_shared/FCM_ENV_KEYS-DY4-A717.mjs';
2
2
  export { defineNotify, isNotifyDefinition } from './packem_shared/defineNotify-B6S_47C2.mjs';
3
- export { createNotify } from './packem_shared/createNotify-Gg40Htm-.mjs';
3
+ export { createNotify } from './packem_shared/createNotify-bDWs4qBm.mjs';
4
4
  export { buildEngine, routingPushProvider } from './packem_shared/buildEngine-DlmjvnNk.mjs';
5
5
  export { enqueuePushBroadcast, runPushBroadcastJob } from './packem_shared/enqueuePushBroadcast-DiK7Hyja.mjs';
6
6
  export { d1SubscriptionStore } from './packem_shared/d1SubscriptionStore-Dv3VPMI_.mjs';
@@ -5,6 +5,12 @@ import { normalizeRegisterInput, targetOf, isGoneError } from './fcmId-B-YPgHi7.
5
5
 
6
6
  const resolveMaybeFactory = (value, env) => typeof value === "function" ? value(env) : value;
7
7
  const receiptError = (receipt) => receipt.successful ? void 0 : receipt.errorMessages.join("; ");
8
+ const pushDeliveryStatus = (receipt, error) => {
9
+ if (receipt.successful) {
10
+ return "accepted";
11
+ }
12
+ return isGoneError(error) ? "gone" : "failed";
13
+ };
8
14
  const mapWithConcurrency = async (items, limit, task) => {
9
15
  const results = Array.from({ length: items.length });
10
16
  let cursor = 0;
@@ -63,6 +69,16 @@ const createNotify = (definition, env, options = {}) => {
63
69
  }
64
70
  const subscriptionStore = store;
65
71
  const concurrency = Math.max(1, options.concurrency ?? 10);
72
+ const { log, metrics } = options;
73
+ const countSend = (channel, provider, status, count = 1) => {
74
+ metrics?.count("notify.send", count, { channel, provider: provider ?? channel, status });
75
+ };
76
+ const warnFailedSend = (channel, provider, fields) => {
77
+ log?.warn(`notify ${channel} delivery failed`, { channel, provider: provider ?? channel, status: "failed", ...fields });
78
+ };
79
+ const observeSkip = (channel, reason) => {
80
+ metrics?.count("notify.skipped", 1, { channel, reason });
81
+ };
66
82
  const resolveSubscription = async (target) => {
67
83
  if (typeof target !== "string") {
68
84
  return target;
@@ -73,28 +89,54 @@ const createNotify = (definition, env, options = {}) => {
73
89
  }
74
90
  return found;
75
91
  };
76
- const deliver = async (subscription, payload) => {
92
+ const deliver = async (subscription, payload, countInline) => {
77
93
  const receipt = await engine.sendToChannel("push", { ...payload, to: targetOf(subscription) });
78
94
  const error = receiptError(receipt);
79
- if (receipt.successful) {
95
+ const status = pushDeliveryStatus(receipt, error);
96
+ if (status === "accepted") {
80
97
  await subscriptionStore.markStatus(subscription.id, "ok");
81
- } else if (isGoneError(error)) {
98
+ } else if (status === "gone") {
82
99
  await subscriptionStore.delete(subscription.id);
83
100
  } else {
84
101
  await subscriptionStore.markStatus(subscription.id, "failed", error);
85
102
  }
86
- return receipt;
103
+ if (status === "failed") {
104
+ warnFailedSend("push", subscription.kind, { error, subscriptionId: subscription.id, userId: subscription.userId ?? null });
105
+ }
106
+ if (countInline) {
107
+ countSend("push", subscription.kind, status);
108
+ }
109
+ return { receipt, status };
87
110
  };
88
111
  const push = {
89
112
  broadcast: async (payload, filter) => {
90
113
  const subscriptions = await subscriptionStore.list(filter);
91
- const outcomes = await mapWithConcurrency(subscriptions, concurrency, async (subscription) => {
92
- const receipt = await deliver(subscription, payload);
93
- if (receipt.successful) {
114
+ if (subscriptions.length === 0) {
115
+ observeSkip("push", "no-subscriptions-matched");
116
+ }
117
+ const rows = await mapWithConcurrency(subscriptions, concurrency, async (subscription) => {
118
+ const { receipt, status } = await deliver(subscription, payload, false);
119
+ return { kind: subscription.kind, receipt, status, subscription };
120
+ });
121
+ const buckets = /* @__PURE__ */ new Map();
122
+ for (const { kind, status } of rows) {
123
+ const key = `${kind}\0${status}`;
124
+ const bucket = buckets.get(key);
125
+ if (bucket === void 0) {
126
+ buckets.set(key, { count: 1, kind, status });
127
+ } else {
128
+ bucket.count += 1;
129
+ }
130
+ }
131
+ for (const { count, kind, status } of buckets.values()) {
132
+ countSend("push", kind, status, count);
133
+ }
134
+ const outcomes = rows.map(({ receipt, status, subscription }) => {
135
+ if (status === "accepted") {
94
136
  return { id: subscription.id, status: "ok" };
95
137
  }
96
138
  const error = receiptError(receipt);
97
- return isGoneError(error) ? { error, id: subscription.id, status: "expired" } : { error, id: subscription.id, status: "failed" };
139
+ return status === "gone" ? { error, id: subscription.id, status: "expired" } : { error, id: subscription.id, status: "failed" };
98
140
  });
99
141
  return {
100
142
  failed: outcomes.filter((outcome) => outcome.status === "failed").length,
@@ -106,20 +148,41 @@ const createNotify = (definition, env, options = {}) => {
106
148
  },
107
149
  list: (filter) => subscriptionStore.list(filter),
108
150
  register: (input) => subscriptionStore.put(normalizeRegisterInput(input)),
109
- send: async (target, payload) => deliver(await resolveSubscription(target), payload),
151
+ send: async (target, payload) => {
152
+ const { receipt } = await deliver(await resolveSubscription(target), payload, true);
153
+ return receipt;
154
+ },
110
155
  unregister: (id) => subscriptionStore.delete(id)
111
156
  };
112
157
  const sendToChannel = async (channel, payload) => {
113
158
  if (engine.getProvider(channel) === void 0) {
159
+ observeSkip(channel, "channel-not-configured");
114
160
  throw new LunoraError("BAD_REQUEST", `@lunora/notify: the "${channel}" channel is not configured in defineNotify(...)`);
115
161
  }
116
- return engine.sendToChannel(channel, payload);
162
+ const receipt = await engine.sendToChannel(channel, payload);
163
+ const status = receipt.successful ? "accepted" : "failed";
164
+ countSend(channel, receipt.provider, status);
165
+ if (status === "failed") {
166
+ warnFailedSend(channel, receipt.provider, { error: receiptError(receipt) });
167
+ }
168
+ return receipt;
117
169
  };
118
170
  const notify = {
119
171
  chat: (payload) => sendToChannel("chat", payload),
120
172
  inApp: (payload) => sendToChannel("inapp", payload),
121
173
  push,
122
- send: (message) => engine.send(message),
174
+ send: async (message) => {
175
+ const receipts = await engine.send(message);
176
+ for (const receipt of receipts) {
177
+ const channel = receipt.channel ?? "unknown";
178
+ const status = receipt.successful ? "accepted" : "failed";
179
+ countSend(channel, receipt.provider, status);
180
+ if (status === "failed") {
181
+ warnFailedSend(channel, receipt.provider, { error: receiptError(receipt) });
182
+ }
183
+ }
184
+ return receipts;
185
+ },
123
186
  webhook: (payload) => sendToChannel("webhook", payload)
124
187
  };
125
188
  return { notify, push };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/notify",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.3",
4
4
  "description": "Multi-channel notifications for Lunora — ctx.notify / ctx.push over @visulima/notification: edge-safe Web Push + FCM, plus chat, in-app inbox and webhook channels, with subscription storage and queue-backed fan-out",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -49,7 +49,7 @@
49
49
  "access": "public"
50
50
  },
51
51
  "dependencies": {
52
- "@lunora/errors": "1.0.0-alpha.7",
52
+ "@lunora/errors": "1.0.0-alpha.8",
53
53
  "@visulima/notification": "1.0.5"
54
54
  },
55
55
  "engines": {