@ggui-ai/iframe-runtime 0.8.0 → 0.9.0

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Vanilla-TS port of `@ggui-ai/react::components/GguiRender.tsx`'s
2
+ * Vanilla-TS port of `@ggui-ai/mcp-apps-react::components/GguiRender.tsx`'s
3
3
  * MCP-Apps branch. Used when a ggui render's `type === 'mcpApps'`
4
4
  * — the renderer mounts a nested iframe that embeds a foreign
5
5
  * MCP App.
@@ -239,6 +239,12 @@ function projectMeta(meta, hasStaticContent) {
239
239
  ...(meta.pollingUrl !== undefined
240
240
  ? { pollingUrl: meta.pollingUrl }
241
241
  : {}),
242
+ // sseUrl mirrors pollingUrl's posture: unconditional pass-through,
243
+ // OUTSIDE the dropLiveCreds guard. Both are server-stamped
244
+ // wsToken-gated URLs; on the expired-creds degrade path they're
245
+ // harmless dead data because runtime.ts early-returns static-only
246
+ // before any live bind.
247
+ ...(meta.sseUrl !== undefined ? { sseUrl: meta.sseUrl } : {}),
242
248
  ...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
243
249
  ...(themeMode !== undefined ? { themeMode } : {}),
244
250
  // Per-app theme overlay carried through verbatim. Already validated
@@ -251,6 +257,13 @@ function projectMeta(meta, hasStaticContent) {
251
257
  : {}),
252
258
  ...(permissionsPolicy !== undefined ? { permissionsPolicy } : {}),
253
259
  ...(meta.lastSequence !== undefined ? { lastSequence: meta.lastSequence } : {}),
260
+ // Freeze-latch self-epoch (#483): this mount's own history number.
261
+ // Non-number collapses to undefined (⇒ boot treats itself as epoch
262
+ // 0). Dropping this field is catastrophic-but-quiet: every mount
263
+ // then boots believing epoch 0, and the first epoch-stamped live
264
+ // frame — including an amend's, addressed to the HEAD — freezes it
265
+ // (probe 18 round 2: the head card froze on its own amend).
266
+ ...(typeof meta.epoch === 'number' ? { epoch: meta.epoch } : {}),
254
267
  ...(meta.propsJson !== undefined ? { propsJson: meta.propsJson } : {}),
255
268
  ...(contextSlots !== undefined ? { contextSlots } : {}),
256
269
  ...(meta.contractHash !== undefined && meta.validatorsUrl !== undefined
@@ -148,7 +148,7 @@ export interface ChannelPollRecoveredEvent {
148
148
  /**
149
149
  * Fired when the end user submits the runtime's in-iframe UI-feedback
150
150
  * affordance ("did this generated UI work for you?"). Field semantics
151
- * mirror `UiFeedbackPayload` in `@ggui-ai/react` / `@ggui-ai/react-native`
151
+ * mirror `UiFeedbackPayload` in `@ggui-ai/mcp-apps-react` / `@ggui-ai/mcp-apps-react-native`
152
152
  * (the host-chrome twin of this affordance):
153
153
  *
154
154
  * - `verdict` — `'love'` / `'dislike'`, or `'other'` for the
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Provisional (A2UI preview) renderer for the iframe runtime.
3
3
  *
4
- * Port of `@ggui-ai/react::components/ProvisionalRenderer.tsx` with
4
+ * Port of `@ggui-ai/mcp-apps-react::components/ProvisionalRenderer.tsx` with
5
5
  * the React-hook consumer stripped. The A2UI reduce + adjacency-list
6
6
  * render is preserved verbatim; the inputs + mount lifecycle differ:
7
7
  *
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Provisional (A2UI preview) renderer for the iframe runtime.
3
3
  *
4
- * Port of `@ggui-ai/react::components/ProvisionalRenderer.tsx` with
4
+ * Port of `@ggui-ai/mcp-apps-react::components/ProvisionalRenderer.tsx` with
5
5
  * the React-hook consumer stripped. The A2UI reduce + adjacency-list
6
6
  * render is preserved verbatim; the inputs + mount lifecycle differ:
7
7
  *
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * React component renderer for the iframe runtime.
3
3
  *
4
- * Port of `@ggui-ai/react::components/ReactComponentRenderer.tsx` (L10-384)
4
+ * Port of `@ggui-ai/mcp-apps-react::components/ReactComponentRenderer.tsx` (L10-384)
5
5
  * with the enclosing React component stripped — the iframe runtime
6
6
  * does not have an outer React tree; it owns the root `createRoot`
7
7
  * mount itself. The inner eval + data-URL-shim + error-boundary
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * React component renderer for the iframe runtime.
3
3
  *
4
- * Port of `@ggui-ai/react::components/ReactComponentRenderer.tsx` (L10-384)
4
+ * Port of `@ggui-ai/mcp-apps-react::components/ReactComponentRenderer.tsx` (L10-384)
5
5
  * with the enclosing React component stripped — the iframe runtime
6
6
  * does not have an outer React tree; it owns the root `createRoot`
7
7
  * mount itself. The inner eval + data-URL-shim + error-boundary
@@ -91,12 +91,17 @@ class RcrErrorBoundary extends Component {
91
91
  clearTimeout(this.retryTimer);
92
92
  }
93
93
  render() {
94
- const { error, autoRetrying, catchCount } = this.state;
94
+ const { error, autoRetrying } = this.state;
95
95
  if (error === null)
96
96
  return this.props.children;
97
97
  // The two fallback UIs match the host-SDK error boundary
98
98
  // verbatim (same inline styles so operator-visible DOM is
99
99
  // identical between the host-SDK and iframe-renderer paths).
100
+ // Colors inherit the themed body (`--ggui-color-onSurface`) with
101
+ // opacity for hierarchy — hardcoded white-alpha text was
102
+ // illegible on light shells (#481); the boundary must stay
103
+ // readable on WHATEVER background the theme painted, including
104
+ // when theme CSS itself failed to inject (browser-default white).
100
105
  const FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
101
106
  if (autoRetrying) {
102
107
  return createElement('div', {
@@ -109,14 +114,15 @@ class RcrErrorBoundary extends Component {
109
114
  gap: 8,
110
115
  fontFamily: FONT,
111
116
  fontSize: 13,
112
- color: 'rgba(255,255,255,0.4)',
117
+ color: 'var(--ggui-color-onSurface, inherit)',
118
+ opacity: 0.6,
113
119
  },
114
120
  }, createElement('div', {
115
121
  style: {
116
122
  width: 16,
117
123
  height: 16,
118
- border: '2px solid rgba(255,255,255,0.15)',
119
- borderTopColor: 'rgba(255,255,255,0.5)',
124
+ border: '2px solid rgba(128,128,128,0.25)',
125
+ borderTopColor: 'currentColor',
120
126
  borderRadius: '50%',
121
127
  animation: 'ggui-err-spin 0.6s linear infinite',
122
128
  },
@@ -133,6 +139,7 @@ class RcrErrorBoundary extends Component {
133
139
  gap: 12,
134
140
  textAlign: 'center',
135
141
  fontFamily: FONT,
142
+ color: 'var(--ggui-color-onSurface, inherit)',
136
143
  },
137
144
  }, createElement('div', {
138
145
  style: {
@@ -140,27 +147,48 @@ class RcrErrorBoundary extends Component {
140
147
  height: 48,
141
148
  borderRadius: 14,
142
149
  background: 'rgba(239, 68, 68, 0.1)',
150
+ color: '#ef4444',
143
151
  display: 'flex',
144
152
  alignItems: 'center',
145
153
  justifyContent: 'center',
146
154
  fontSize: 22,
147
155
  },
148
- }, '!'), createElement('div', { style: { fontSize: 14, fontWeight: 600, color: 'rgba(255,255,255,0.8)' } }, 'Something went wrong'), createElement('div', { style: { fontSize: 12, color: 'rgba(255,255,255,0.4)', maxWidth: 280, lineHeight: 1.4 } }, 'This component encountered an error. Try asking for a different view.'), createElement('button', {
156
+ }, '!'), createElement('div', { style: { fontSize: 14, fontWeight: 600, opacity: 0.9 } }, 'Something went wrong'), createElement('div', { style: { fontSize: 12, opacity: 0.6, maxWidth: 280, lineHeight: 1.4 } }, 'This component encountered an error. Try asking for a different view.'), createElement('button', {
149
157
  onClick: () => this.setState({ error: null, catchCount: 0 }),
150
158
  style: {
151
159
  marginTop: 4,
152
160
  padding: '8px 20px',
153
161
  borderRadius: 8,
154
- border: '1px solid rgba(255,255,255,0.15)',
155
- background: 'rgba(255,255,255,0.08)',
156
- color: 'rgba(255,255,255,0.7)',
162
+ border: '1px solid currentColor',
163
+ background: 'transparent',
164
+ color: 'inherit',
165
+ opacity: 0.75,
157
166
  fontSize: 13,
158
167
  fontWeight: 500,
159
168
  cursor: 'pointer',
160
169
  },
161
- }, 'Retry'), catchCount > 1
162
- ? createElement('div', { style: { fontSize: 11, color: 'rgba(255,255,255,0.2)' } }, error.message)
163
- : null);
170
+ }, 'Retry'),
171
+ // Diagnostic is ALWAYS reachable (#481 ruling: errors must be
172
+ // visible) — collapsed by default, legible when opened.
173
+ createElement('details', {
174
+ style: {
175
+ marginTop: 8,
176
+ maxWidth: 320,
177
+ width: '100%',
178
+ textAlign: 'left',
179
+ opacity: 0.65,
180
+ },
181
+ }, createElement('summary', { style: { fontSize: 11, cursor: 'pointer' } }, 'Error details'), createElement('pre', {
182
+ style: {
183
+ fontSize: 11,
184
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
185
+ whiteSpace: 'pre-wrap',
186
+ overflowWrap: 'anywhere',
187
+ margin: '6px 0 0',
188
+ maxHeight: 140,
189
+ overflow: 'auto',
190
+ },
191
+ }, error.message)));
164
192
  }
165
193
  }
166
194
  /**
@@ -1,7 +1,7 @@
1
1
  import type { AckPayload } from '@ggui-ai/protocol';
2
2
  import type { ConnectionStatus } from '@ggui-ai/protocol/transport/websocket';
3
3
  import type { McpAppAiGguiRenderMeta } from '@ggui-ai/protocol/integrations/mcp-apps';
4
- import type { ChannelRegistry, RegistryPollingOptions, WsTransportHandle } from '@ggui-ai/live-channel';
4
+ import type { AnyTransportHandle, ChannelLogger, ChannelRegistry, RegistryPollingOptions, RegistrySseOptions, WsTransportHandle } from '@ggui-ai/live-channel';
5
5
  import { type ProtocolErrorEmitter } from './protocol-error.js';
6
6
  import type { ObservabilityEmitter } from './observability.js';
7
7
  /**
@@ -12,8 +12,19 @@ import type { ObservabilityEmitter } from './observability.js';
12
12
  * render slot.
13
13
  */
14
14
  export interface RegistrySubscribeHandle {
15
- readonly handle: WsTransportHandle;
16
- readonly ack: AckPayload;
15
+ /**
16
+ * The bound transport. `kind: 'ws'` on trio binds (the FailoverHandle
17
+ * facade keeps it across demotions); `kind: 'polling'` on trio-less
18
+ * bridge-only binds — narrow before using the ws-only `send` surface.
19
+ */
20
+ readonly handle: AnyTransportHandle;
21
+ /**
22
+ * First subscribe ack — present on trio binds only. A trio-less
23
+ * bridge-only bind has no subscribe handshake (the static seed
24
+ * already painted; the bridge delivers deltas), so the key is
25
+ * absent entirely (exact-optional).
26
+ */
27
+ readonly ack?: AckPayload;
17
28
  }
18
29
  /**
19
30
  * Caller-provided callbacks. The runtime's `bootSequence` owns the
@@ -90,6 +101,34 @@ export interface ConnectViaRegistryOptions {
90
101
  * URL- and shape-agnostic.
91
102
  */
92
103
  readonly polling?: RegistryPollingOptions;
104
+ /**
105
+ * SSE middle-rung descriptor (WS → SSE → polling ladder). Composed
106
+ * at the runtime layer from `meta.sseUrl` + the shared ladder
107
+ * cursor — the live-channel is URL- and auth-agnostic. Absent →
108
+ * two-rung WS→polling ladder (or WS-only when `polling` is also
109
+ * absent).
110
+ */
111
+ readonly sse?: RegistrySseOptions;
112
+ /**
113
+ * Bridge-pull terminal-rung descriptor (WS → SSE → polling → bridge
114
+ * ladder). Same {@link RegistryPollingOptions} shape as `polling`
115
+ * because bridge-pull IS the polling algorithm on the `fetchBody`
116
+ * carrier — the event ledger pulled via `ggui_runtime_pull`
117
+ * `tools/call`s over the host's postMessage bridge instead of an
118
+ * HTTP URL. Composed at the runtime layer (`buildBridgePolling`
119
+ * bound to `app.callServerTool` + the shared ladder cursor)
120
+ * whenever the connected App handle exists; threaded verbatim to
121
+ * `bind()` like `sse`/`polling`. Absent → the ladder ends at
122
+ * whichever of the rungs above is armed.
123
+ */
124
+ readonly bridge?: RegistryPollingOptions;
125
+ /**
126
+ * Diagnostics tap threaded into the live-channel bind — the
127
+ * transports' own `channel_*` events flow to it. The runtime passes
128
+ * its telemetry sink's `ChannelLogger` facade here so ladder
129
+ * behavior on console-less sandboxed hosts reaches the server.
130
+ */
131
+ readonly logger?: ChannelLogger;
93
132
  }
94
133
  /**
95
134
  * Open the WS via the registry, send subscribe (composed by the
@@ -146,14 +146,22 @@ export function connectViaRegistry(opts) {
146
146
  let seenOpen = false;
147
147
  const emitProtocolError = opts.onProtocolError ?? (() => { });
148
148
  const emitObserve = opts.onObserve ?? (() => { });
149
- // Defense-in-depth — live mode requires both wsUrl + wsToken. The
150
- // caller validates this before invoking; reject early with a
151
- // typed transport failure if either is missing.
149
+ // Defense-in-depth — the WS rung requires both wsUrl + wsToken.
150
+ // A trio-less meta is still connectable when a BRIDGE descriptor
151
+ // is supplied (#471 round-6 live finding): CSP-jailed MCP-Apps
152
+ // hosts mount statically with NO trio at all, and the bridge-pull
153
+ // terminal rung — whose only precondition is the host's tools/call
154
+ // postMessage bridge — is precisely their subscription. The
155
+ // registry handles a trio-less bootstrap by starting the ladder at
156
+ // its tail. Trio-less AND bridge-less remains the typed failure.
152
157
  const wsUrl = opts.meta.wsUrl;
153
158
  const wsToken = opts.meta.wsToken;
154
- if (typeof wsUrl !== 'string' || wsUrl.length === 0 ||
155
- typeof wsToken !== 'string' || wsToken.length === 0) {
156
- const err = new Error('connectViaRegistry: render-meta missing wsUrl/wsToken — live-mode required');
159
+ const hasTrio = typeof wsUrl === 'string' &&
160
+ wsUrl.length > 0 &&
161
+ typeof wsToken === 'string' &&
162
+ wsToken.length > 0;
163
+ if (!hasTrio && opts.bridge === undefined) {
164
+ const err = new Error('connectViaRegistry: render-meta missing wsUrl/wsToken and no bridge descriptor — nothing to subscribe');
157
165
  emitProtocolError(fromTransportFailure('DISCONNECTED', false, err.message));
158
166
  reject(err);
159
167
  return;
@@ -281,25 +289,40 @@ export function connectViaRegistry(opts) {
281
289
  reject(new Error('connectViaRegistry: transport went to failed state before ack'));
282
290
  }
283
291
  };
284
- const composedUrl = composeWsUrl(wsUrl, wsToken);
285
292
  // `bind()` selects the transport (WS for wsUrl+wsToken sessions;
286
293
  // the live-channel narrows on its `bootstrap` shape) and starts
287
294
  // it. We override `bootstrap.wsUrl` with the composed URL so the
288
295
  // registry's WS transport gets the wsToken-threaded variant. The
289
296
  // live-channel's `bootstrap` arg is its internal name; the field
290
- // on the wire is `wsToken`.
297
+ // on the wire is `wsToken`. Trio-less (bridge-capable) binds carry
298
+ // NEITHER key — the registry then starts the ladder at its tail.
291
299
  void opts.registry
292
300
  .bind({
293
301
  bootstrap: {
294
- wsUrl: composedUrl,
295
- wsToken,
302
+ ...(hasTrio
303
+ ? { wsUrl: composeWsUrl(wsUrl, wsToken), wsToken }
304
+ : {}),
296
305
  sessionId: opts.meta.sessionId,
297
306
  appId: opts.meta.appId,
298
307
  },
308
+ ...(opts.logger !== undefined ? { logger: opts.logger } : {}),
299
309
  onStatusChange: mappedStatusCallback,
310
+ ...(opts.sse !== undefined ? { sse: opts.sse } : {}),
300
311
  ...(opts.polling !== undefined ? { polling: opts.polling } : {}),
312
+ ...(opts.bridge !== undefined ? { bridge: opts.bridge } : {}),
301
313
  })
302
314
  .then((bound) => {
315
+ if (!hasTrio) {
316
+ // Bridge-only bind — no subscribe handshake, no ack. The
317
+ // polling facade reports 'open' immediately (mapped to
318
+ // 'connected' for the consumer); resolve with the handle
319
+ // alone and let the ladder deliver deltas.
320
+ if (!settled) {
321
+ settled = true;
322
+ resolve({ handle: bound });
323
+ }
324
+ return;
325
+ }
303
326
  // bound is `AnyTransportHandle`. Composed URL + wsToken means
304
327
  // the registry selected WSTransport — `kind === 'ws'` is the
305
328
  // structural invariant. Narrow defensively.
@@ -10,7 +10,7 @@
10
10
  * `StackRenderer` / `StackRenderContext` orchestrator (a stack-of-N
11
11
  * map keyed by item id) was retired along with `StackModel`.
12
12
  *
13
- * The dispatch tree mirrors `@ggui-ai/react::DynamicComponent` +
13
+ * The dispatch tree mirrors `@ggui-ai/mcp-apps-react::DynamicComponent` +
14
14
  * `GguiRender`'s MCP-Apps branch:
15
15
  *
16
16
  * - `type === 'mcpApps'` → `mountMcpAppIframe` (vanilla iframe
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Transport-telemetry sink — the iframe runtime's self-report channel
3
+ * (`ggui_runtime_telemetry`).
4
+ *
5
+ * Why: on sandboxed MCP-Apps hosts (claude.ai's `claudemcpcontent.com`
6
+ * frames) the console is unreadable from outside and the CSP blocks
7
+ * every network channel — when the delivery ladder (WS → SSE → polling
8
+ * → bridge-pull) misbehaves there, the failure is invisible by
9
+ * construction. This sink batches the ladder's own events and flushes
10
+ * them over the host's `tools/call` postMessage bridge, the one
11
+ * carrier a CSP jail cannot block.
12
+ *
13
+ * What flows through it:
14
+ * - `record()` calls from the boot sequence (path decisions, status
15
+ * transitions, doorbell rings);
16
+ * - the live-channel's OWN diagnostics via {@link channelLogger} —
17
+ * a `ChannelLogger` facade that forwards every
18
+ * `channel_failover_swap` / `channel_polling_*` / SSE event the
19
+ * transports already emit. Tapping the logger means ZERO new
20
+ * instrumentation inside `@ggui-ai/live-channel`.
21
+ *
22
+ * Posture: bounded, fire-and-forget, diagnostics-only.
23
+ * - Buffer caps at the protocol's per-batch limit; overflow drops
24
+ * the OLDEST entries (the tail of a failure story beats its
25
+ * preamble).
26
+ * - First flush is delayed (~4s) so one batch carries the whole
27
+ * boot+ladder story; later flushes throttle.
28
+ * - A hard per-session flush cap bounds host load; flush failures
29
+ * are swallowed (a diagnostics channel must never cause the
30
+ * symptoms it reports).
31
+ */
32
+ import type { ChannelLogger } from '@ggui-ai/live-channel';
33
+ /** Narrow slice of `App.callServerTool` the sink needs. */
34
+ export type TelemetryCallTool = (args: {
35
+ name: string;
36
+ arguments: Record<string, unknown>;
37
+ }) => Promise<unknown>;
38
+ export interface TelemetrySink {
39
+ /** Append one event; schedules a (throttled) flush. */
40
+ record(kind: string, detail?: string): void;
41
+ /**
42
+ * `ChannelLogger` facade for the live-channel bind — every event the
43
+ * transports emit lands in the buffer verbatim (event name = kind,
44
+ * fields JSON = detail).
45
+ */
46
+ readonly channelLogger: ChannelLogger;
47
+ /** Cancel timers; buffered-but-unflushed events are dropped. */
48
+ dispose(): void;
49
+ }
50
+ export declare function createTelemetrySink(opts: {
51
+ readonly sessionId: string;
52
+ readonly callTool: TelemetryCallTool;
53
+ }): TelemetrySink;
@@ -0,0 +1,74 @@
1
+ import { RUNTIME_TELEMETRY_MAX_EVENTS } from '@ggui-ai/protocol';
2
+ const FIRST_FLUSH_DELAY_MS = 4_000;
3
+ const FLUSH_THROTTLE_MS = 8_000;
4
+ const MAX_FLUSHES_PER_SESSION = 12;
5
+ const MAX_DETAIL_CHARS = 500;
6
+ export function createTelemetrySink(opts) {
7
+ const bootAt = Date.now();
8
+ const buffer = [];
9
+ let flushes = 0;
10
+ let timer = null;
11
+ let disposed = false;
12
+ let firstFlushDone = false;
13
+ const flush = () => {
14
+ timer = null;
15
+ if (disposed || buffer.length === 0)
16
+ return;
17
+ if (flushes >= MAX_FLUSHES_PER_SESSION)
18
+ return;
19
+ flushes += 1;
20
+ firstFlushDone = true;
21
+ const events = buffer.splice(0, buffer.length);
22
+ void opts
23
+ .callTool({
24
+ name: 'ggui_runtime_telemetry',
25
+ arguments: { sessionId: opts.sessionId, events },
26
+ })
27
+ .catch(() => {
28
+ // Swallowed by design — the diagnostics channel must never
29
+ // cause the failures it exists to report.
30
+ });
31
+ };
32
+ const scheduleFlush = () => {
33
+ if (disposed || timer !== null)
34
+ return;
35
+ if (flushes >= MAX_FLUSHES_PER_SESSION)
36
+ return;
37
+ timer = setTimeout(flush, firstFlushDone ? FLUSH_THROTTLE_MS : FIRST_FLUSH_DELAY_MS);
38
+ };
39
+ const record = (kind, detail) => {
40
+ if (disposed)
41
+ return;
42
+ if (buffer.length >= RUNTIME_TELEMETRY_MAX_EVENTS)
43
+ buffer.shift();
44
+ buffer.push({
45
+ at: Math.max(0, Date.now() - bootAt),
46
+ kind: kind.slice(0, 64),
47
+ ...(detail !== undefined ? { detail: detail.slice(0, MAX_DETAIL_CHARS) } : {}),
48
+ });
49
+ scheduleFlush();
50
+ };
51
+ const fieldsToDetail = (fields) => {
52
+ try {
53
+ return JSON.stringify(fields).slice(0, MAX_DETAIL_CHARS);
54
+ }
55
+ catch {
56
+ return '[unserializable]';
57
+ }
58
+ };
59
+ return {
60
+ record,
61
+ channelLogger: {
62
+ info: (event, fields) => record(event, fieldsToDetail(fields)),
63
+ warn: (event, fields) => record(event, fieldsToDetail(fields)),
64
+ debug: (event, fields) => record(event, fieldsToDetail(fields)),
65
+ },
66
+ dispose() {
67
+ disposed = true;
68
+ if (timer !== null) {
69
+ clearTimeout(timer);
70
+ timer = null;
71
+ }
72
+ },
73
+ };
74
+ }