@mpgd/cli 0.8.0 → 0.10.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.
@@ -3,7 +3,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
3
3
  import { createServer, context, getServerPort, reddit, redis } from '@devvit/web/server';
4
4
  import type { UiResponse } from '@devvit/web/shared';
5
5
  import {
6
- assertBridgeRequest,
7
6
  createBridgeError,
8
7
  type BridgeRequest,
9
8
  type BridgeResponse,
@@ -14,16 +13,9 @@ import {
14
13
  } from '@mpgd/bridge/orpc';
15
14
  import { createBridgeRpcNodeHandler } from '@mpgd/bridge/orpc/node';
16
15
 
17
- const redisKeyComponentPattern = /^[A-Za-z0-9:_-]{1,128}$/;
18
16
  const maxStorageKeyLength = 128;
19
17
  const maxEncodedStorageKeyLength = 384;
20
- const maxRequestBodySize = 1_000_000;
21
- const legacyBridgeEndpoint = '/api/mpgd/bridge';
22
- const leaderboardUpdateMaxAttempts = 3;
23
- const leaderboardBackoffBaseMs = 25;
24
- const leaderboardLockTtlMs = 2_000;
25
- const leaderboardLockTtlSeconds = Math.ceil(leaderboardLockTtlMs / 1_000);
26
- const leaderboardLockRetryBudgetMs = leaderboardLockTtlSeconds * 1_000;
18
+ const maxRequestBodySize = 1_048_576;
27
19
  const gameName = '__GAME_NAME__';
28
20
  const gameTitle = __GAME_TITLE_TS_LITERAL__;
29
21
  const bridgeRpcHandler = createBridgeRpcNodeHandler(
@@ -59,11 +51,6 @@ async function handleHttpRequest(
59
51
  return;
60
52
  }
61
53
 
62
- if (request.method === 'POST' && requestPathname(request) === legacyBridgeEndpoint) {
63
- await handleLegacyBridgeRequest(request, response);
64
- return;
65
- }
66
-
67
54
  if (await bridgeRpcHandler(request, response)) {
68
55
  return;
69
56
  }
@@ -73,73 +60,6 @@ async function handleHttpRequest(
73
60
  });
74
61
  }
75
62
 
76
- async function handleLegacyBridgeRequest(
77
- request: IncomingMessage,
78
- response: ServerResponse,
79
- ): Promise<void> {
80
- let body: unknown;
81
-
82
- try {
83
- body = await readJsonRequestBody(request, maxRequestBodySize);
84
- } catch (error) {
85
- const responseError = describeLegacyBridgeBodyError(error);
86
-
87
- if (error instanceof RequestBodyTooLargeError) {
88
- discardOversizedRequestBody(request, response);
89
- }
90
-
91
- if (responseError.retryable) {
92
- console.error(`devvit legacy bridge body read failed: ${errorMessage(error)}`, error);
93
- }
94
-
95
- sendJson(
96
- response,
97
- responseError.status,
98
- createBridgeError(
99
- 'unknown',
100
- responseError.code,
101
- responseError.message,
102
- responseError.retryable,
103
- ),
104
- );
105
- return;
106
- }
107
-
108
- let bridgeRequest: BridgeRequest;
109
-
110
- try {
111
- bridgeRequest = assertBridgeRequest(body);
112
- } catch (error) {
113
- console.warn(`devvit legacy bridge validation failed: ${errorMessage(error)}`);
114
- sendJson(
115
- response,
116
- 400,
117
- createBridgeError(
118
- requestIdFromBody(body),
119
- 'INVALID_BRIDGE_REQUEST',
120
- 'Invalid bridge request.',
121
- ),
122
- );
123
- return;
124
- }
125
-
126
- try {
127
- sendJson(response, 200, await handleBridgeRequest(bridgeRequest));
128
- } catch (error) {
129
- console.error(`devvit legacy bridge request failed: ${errorMessage(error)}`, error);
130
- sendJson(
131
- response,
132
- 500,
133
- createBridgeError(
134
- bridgeRequest.id,
135
- 'DEVVIT_BRIDGE_INTERNAL_ERROR',
136
- 'Devvit bridge request failed.',
137
- true,
138
- ),
139
- );
140
- }
141
- }
142
-
143
63
  async function handleCreatePostMenu(response: ServerResponse): Promise<void> {
144
64
  const subredditName = currentSubredditName();
145
65
 
@@ -217,7 +137,7 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
217
137
  nativeAds: false,
218
138
  rewardedAds: false,
219
139
  interstitialAds: false,
220
- nativeLeaderboard: true,
140
+ nativeLeaderboard: false,
221
141
  achievements: false,
222
142
  cloudSave: true,
223
143
  socialShare: false,
@@ -320,7 +240,9 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
320
240
  });
321
241
 
322
242
  case 'leaderboard.submitScore':
323
- return submitLeaderboardScore(input);
243
+ return ok(input, {
244
+ submitted: false,
245
+ });
324
246
 
325
247
  case 'storage.load':
326
248
  return loadStorage(input);
@@ -337,48 +259,6 @@ async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse
337
259
  }
338
260
  }
339
261
 
340
- async function submitLeaderboardScore(input: BridgeRequest): Promise<BridgeResponse> {
341
- const payload = optionalObjectPayload(input.payload) as {
342
- readonly leaderboardId?: unknown;
343
- readonly score?: unknown;
344
- };
345
- const leaderboardId = leaderboardIdFromPayload(input, payload.leaderboardId);
346
- const playerId = currentPlayerId();
347
-
348
- if (typeof leaderboardId !== 'string') {
349
- return leaderboardId;
350
- }
351
-
352
- if (typeof payload.score !== 'number' || !Number.isFinite(payload.score)) {
353
- return createBridgeError(
354
- input.id,
355
- 'INVALID_LEADERBOARD_SCORE',
356
- 'Finite leaderboard score is required.',
357
- );
358
- }
359
-
360
- if (playerId === undefined) {
361
- return ok(input, {
362
- submitted: false,
363
- });
364
- }
365
-
366
- const redisKey = leaderboardKey(leaderboardId);
367
- let scoreUpdate: 'updated' | 'unchanged' | 'failed';
368
-
369
- try {
370
- scoreUpdate = await submitMaxLeaderboardScore(redisKey, playerId, payload.score);
371
- } catch (error) {
372
- console.warn(`devvit leaderboard score submission failed: ${errorMessage(error)}`);
373
- scoreUpdate = 'failed';
374
- }
375
-
376
- return ok(input, {
377
- submitted: scoreUpdate !== 'failed',
378
- highScoreUpdated: scoreUpdate === 'updated',
379
- });
380
- }
381
-
382
262
  async function loadStorage(input: BridgeRequest): Promise<BridgeResponse> {
383
263
  const playerId = currentPlayerId();
384
264
 
@@ -467,179 +347,6 @@ function storageKey(input: BridgeRequest, playerId: string): string | BridgeResp
467
347
  return `${gameName}:save:${encodeURIComponent(playerId)}:${encodedKey}`;
468
348
  }
469
349
 
470
- function leaderboardIdFromPayload(
471
- input: BridgeRequest,
472
- value: unknown,
473
- ): string | BridgeResponse {
474
- if (value === undefined) {
475
- return 'default';
476
- }
477
-
478
- if (typeof value !== 'string' || !isValidRedisKeyComponent(value)) {
479
- return createBridgeError(
480
- input.id,
481
- 'INVALID_LEADERBOARD_ID',
482
- 'Leaderboard id format is invalid.',
483
- );
484
- }
485
-
486
- return value;
487
- }
488
-
489
- function leaderboardKey(leaderboardId: string): string {
490
- return `${gameName}:leaderboard:${encodeURIComponent(leaderboardId)}`;
491
- }
492
-
493
- async function submitMaxLeaderboardScore(
494
- redisKey: string,
495
- playerId: string,
496
- score: number,
497
- ): Promise<'updated' | 'unchanged' | 'failed'> {
498
- const lockKey = leaderboardLockKey(redisKey, playerId);
499
- const startedAt = Date.now();
500
- let attempt = 0;
501
-
502
- while (Date.now() - startedAt <= leaderboardLockRetryBudgetMs) {
503
- const lockToken = createLockToken();
504
- const acquiredLock = await acquireLeaderboardLock(lockKey, lockToken);
505
-
506
- if (!acquiredLock) {
507
- await delay(nextLeaderboardRetryDelay(attempt, startedAt));
508
- attempt += 1;
509
- continue;
510
- }
511
-
512
- try {
513
- const currentScore = await redis.zScore(redisKey, playerId);
514
-
515
- if (currentScore !== undefined && score <= currentScore) {
516
- return 'unchanged';
517
- }
518
-
519
- if (await writeLeaderboardScoreIfLockHeld(lockKey, lockToken, redisKey, playerId, score)) {
520
- return 'updated';
521
- }
522
-
523
- await delay(nextLeaderboardRetryDelay(attempt, startedAt));
524
- attempt += 1;
525
- } finally {
526
- try {
527
- await releaseLeaderboardLock(lockKey, lockToken);
528
- } catch (error) {
529
- console.warn(`devvit leaderboard lock release failed: ${errorMessage(error)}`);
530
- }
531
- }
532
- }
533
-
534
- console.warn(`devvit leaderboard lock contention exceeded retry budget for key: ${lockKey}`);
535
-
536
- return 'failed';
537
- }
538
-
539
- function leaderboardLockKey(redisKey: string, playerId: string): string {
540
- return `${redisKey}:lock:${encodeURIComponent(playerId)}`;
541
- }
542
-
543
- function createLockToken(): string {
544
- const cryptoImpl = globalThis.crypto;
545
-
546
- if (typeof cryptoImpl?.randomUUID === 'function') {
547
- return cryptoImpl.randomUUID();
548
- }
549
-
550
- if (typeof cryptoImpl?.getRandomValues === 'function') {
551
- const values = new Uint32Array(4);
552
- cryptoImpl.getRandomValues(values);
553
-
554
- return Array.from(values, (value) => value.toString(36).padStart(7, '0')).join('');
555
- }
556
-
557
- throw new Error('Web Crypto is required to create a Devvit leaderboard lock token.');
558
- }
559
-
560
- async function acquireLeaderboardLock(lockKey: string, lockToken: string): Promise<boolean> {
561
- const result = await redis.set(lockKey, lockToken, {
562
- nx: true,
563
- expiration: leaderboardLockExpirationDate(),
564
- });
565
-
566
- return result === 'OK';
567
- }
568
-
569
- function leaderboardLockExpirationDate(): Date {
570
- // Devvit Redis SetOptions expects a Date and converts it to Redis EX seconds internally.
571
- return new Date(Date.now() + leaderboardLockTtlMs);
572
- }
573
-
574
- async function writeLeaderboardScoreIfLockHeld(
575
- lockKey: string,
576
- lockToken: string,
577
- redisKey: string,
578
- playerId: string,
579
- score: number,
580
- ): Promise<boolean> {
581
- const transaction = await redis.watch(lockKey);
582
- const currentToken = await redis.get(lockKey);
583
-
584
- if (currentToken !== lockToken) {
585
- await transaction.unwatch();
586
- return false;
587
- }
588
-
589
- await transaction.multi();
590
- await transaction.zAdd(redisKey, {
591
- member: playerId,
592
- score,
593
- });
594
-
595
- const results = await transaction.exec();
596
-
597
- return Array.isArray(results) && results.length > 0;
598
- }
599
-
600
- async function releaseLeaderboardLock(lockKey: string, lockToken: string): Promise<void> {
601
- for (let attempt = 0; attempt < leaderboardUpdateMaxAttempts; attempt += 1) {
602
- const transaction = await redis.watch(lockKey);
603
- const currentToken = await redis.get(lockKey);
604
-
605
- if (currentToken !== lockToken) {
606
- await transaction.unwatch();
607
- return;
608
- }
609
-
610
- await transaction.multi();
611
- await transaction.del(lockKey);
612
-
613
- const results = await transaction.exec();
614
-
615
- if (Array.isArray(results) && results.length > 0) {
616
- return;
617
- }
618
-
619
- await delay(leaderboardBackoffBaseMs * (attempt + 1));
620
- }
621
-
622
- console.warn(`devvit leaderboard lock release exhausted retries for key: ${lockKey}`);
623
- }
624
-
625
- function delay(ms: number): Promise<void> {
626
- return new Promise((resolve) => {
627
- setTimeout(resolve, ms);
628
- });
629
- }
630
-
631
- function nextLeaderboardRetryDelay(attempt: number, startedAt: number): number {
632
- const elapsed = Date.now() - startedAt;
633
- const remaining = Math.max(0, leaderboardLockRetryBudgetMs - elapsed);
634
- const backoff = leaderboardBackoffBaseMs * (attempt + 1);
635
-
636
- return Math.min(backoff, remaining);
637
- }
638
-
639
- function isValidRedisKeyComponent(value: string): boolean {
640
- return redisKeyComponentPattern.test(value);
641
- }
642
-
643
350
  function errorMessage(error: unknown): string {
644
351
  return error instanceof Error ? error.message : String(error);
645
352
  }
@@ -690,38 +397,6 @@ function requestPathname(request: IncomingMessage): string {
690
397
 
691
398
  class RequestBodyTooLargeError extends Error {}
692
399
 
693
- function describeLegacyBridgeBodyError(error: unknown): {
694
- readonly status: number;
695
- readonly code: string;
696
- readonly message: string;
697
- readonly retryable: boolean;
698
- } {
699
- if (error instanceof RequestBodyTooLargeError) {
700
- return {
701
- status: 413,
702
- code: 'BRIDGE_REQUEST_TOO_LARGE',
703
- message: 'Bridge request body is too large.',
704
- retryable: false,
705
- };
706
- }
707
-
708
- if (error instanceof SyntaxError) {
709
- return {
710
- status: 400,
711
- code: 'INVALID_BRIDGE_REQUEST',
712
- message: 'Bridge request body is not valid JSON.',
713
- retryable: false,
714
- };
715
- }
716
-
717
- return {
718
- status: 500,
719
- code: 'DEVVIT_BRIDGE_INTERNAL_ERROR',
720
- message: 'Bridge request body could not be read.',
721
- retryable: true,
722
- };
723
- }
724
-
725
400
  async function* readRequestBodyChunks(
726
401
  request: IncomingMessage,
727
402
  maxBodySize: number,
@@ -751,19 +426,6 @@ async function drainRequestBody(
751
426
  }
752
427
  }
753
428
 
754
- async function readJsonRequestBody(
755
- request: IncomingMessage,
756
- maxBodySize: number,
757
- ): Promise<unknown> {
758
- const chunks: Buffer[] = [];
759
-
760
- for await (const chunk of readRequestBodyChunks(request, maxBodySize)) {
761
- chunks.push(chunk);
762
- }
763
-
764
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
765
- }
766
-
767
429
  function assertContentLengthWithinLimit(
768
430
  request: IncomingMessage,
769
431
  maxBodySize: number,
@@ -787,15 +449,6 @@ function discardOversizedRequestBody(
787
449
  request.resume();
788
450
  }
789
451
 
790
- function requestIdFromBody(input: unknown): string {
791
- if (typeof input !== 'object' || input === null || !('id' in input)) {
792
- return 'unknown';
793
- }
794
-
795
- const id = (input as { readonly id?: unknown }).id;
796
- return typeof id === 'string' && id.length > 0 ? id : 'unknown';
797
- }
798
-
799
452
  function setResponseSecurityHeaders(response: ServerResponse): void {
800
453
  response.setHeader('cache-control', 'no-store');
801
454
  response.setHeader('content-security-policy', "default-src 'none'; frame-ancestors 'none'");
@@ -1,9 +1,9 @@
1
1
  import { redis } from '@devvit/web/server';
2
2
  import {
3
3
  createDevvitRedisPostOperationStore,
4
- type DevvitDurableOperationStore,
4
+ type DevvitIndexedDurableOperationStore,
5
5
  } from '@mpgd/adapter-devvit/server';
6
6
 
7
- export function createPostOperationStore(): DevvitDurableOperationStore {
7
+ export function createPostOperationStore(): DevvitIndexedDurableOperationStore {
8
8
  return createDevvitRedisPostOperationStore(redis);
9
9
  }
@@ -4,5 +4,47 @@
4
4
  "source": "public/icon.svg",
5
5
  "backgroundColor": "#0f172a"
6
6
  }
7
+ },
8
+ "acceptance": {
9
+ "gameplay": {
10
+ "schemaVersion": 1,
11
+ "states": [
12
+ {
13
+ "id": "launch-ready",
14
+ "label": "Launch ready",
15
+ "expectation": "The lobby is rendered and ready for primary input.",
16
+ "actions": [
17
+ {
18
+ "type": "wait",
19
+ "durationMs": 500
20
+ }
21
+ ]
22
+ },
23
+ {
24
+ "id": "primary-input",
25
+ "label": "Primary input",
26
+ "expectation": "The game enters its playable state after primary input.",
27
+ "actions": [
28
+ {
29
+ "type": "tap",
30
+ "x": 0.5,
31
+ "y": 0.72
32
+ }
33
+ ]
34
+ },
35
+ {
36
+ "id": "resume-session",
37
+ "label": "Resume session",
38
+ "expectation": "The active game session remains healthy after pause and resume.",
39
+ "actions": [
40
+ {
41
+ "type": "pause-resume",
42
+ "backgroundMs": 1000,
43
+ "expectSameSession": true
44
+ }
45
+ ]
46
+ }
47
+ ]
48
+ }
7
49
  }
8
50
  }
@@ -43,7 +43,6 @@
43
43
  },
44
44
  "reddit": {
45
45
  "kind": "devvit-web",
46
- "buildStrategy": "devvit-vite",
47
46
  "gameApp": ".",
48
47
  "wrapperApp": "apps/target-devvit",
49
48
  "adapter": "devvit",
@@ -76,6 +76,8 @@ async function bootstrap(): Promise<void> {
76
76
 
77
77
  createStarterGame({
78
78
  mountId: 'game',
79
+ preserveBrowserTouchGestures:
80
+ document.body.dataset.mpgdPreserveBrowserTouchGestures === 'true',
79
81
  context: {
80
82
  platform,
81
83
  runtime,
@@ -1,57 +1,132 @@
1
1
  import {
2
2
  requestDevvitExpandedMode,
3
- startDevvitWebSurface,
3
+ startDevvitWebView,
4
+ type DevvitInlineModeContext,
4
5
  } from '@mpgd/adapter-devvit/web';
5
6
 
6
- await startDevvitWebSurface({
7
- async mountInlinePreview() {
8
- await import('./devvitInlinePreview.css');
9
- renderInlinePreview();
7
+ await startDevvitWebView({
8
+ async mountInlineMode(context) {
9
+ await import('./devvitInlineMode.css');
10
+ renderInlineLaunchScreen(context);
10
11
  },
11
- async loadExpandedGame() {
12
+ async loadGameplay() {
12
13
  await import('../main');
13
14
  },
14
15
  onModeUnavailable(error) {
15
16
  if (!(error instanceof ReferenceError)) {
16
- console.warn('[devvit] web view mode unavailable; loading the game surface.', error);
17
+ console.warn('[devvit] web view mode unavailable; loading gameplay.', error);
17
18
  }
18
19
  },
19
20
  });
20
21
 
21
- function renderInlinePreview(): void {
22
- const preview = document.createElement('main');
23
- preview.className = 'devvit-preview';
22
+ function renderInlineLaunchScreen(
23
+ context: DevvitInlineModeContext,
24
+ errorMessage?: string,
25
+ ): void {
26
+ const launchScreen = document.createElement('main');
27
+ launchScreen.className = 'devvit-launch-screen';
24
28
 
25
29
  const eyebrow = document.createElement('p');
26
- eyebrow.className = 'devvit-preview__eyebrow';
27
- eyebrow.textContent = 'Ready to play';
30
+ eyebrow.className = 'devvit-launch-screen__eyebrow';
31
+ eyebrow.textContent = 'Inline mode';
28
32
 
29
33
  const title = document.createElement('h1');
30
34
  title.textContent = '__GAME_TITLE__';
31
35
 
32
36
  const description = document.createElement('p');
33
- description.className = 'devvit-preview__description';
34
- description.textContent = 'Open the expanded view to start the game.';
35
-
36
- const button = document.createElement('button');
37
- button.className = 'devvit-preview__button';
38
- button.type = 'button';
39
- button.textContent = 'Play';
40
- button.addEventListener('click', async (event) => {
41
- try {
42
- await requestDevvitExpandedMode(event, 'game');
43
- } catch (error) {
44
- console.error('[devvit] expanded game surface request failed.', error);
45
- }
37
+ description.className = 'devvit-launch-screen__description';
38
+ description.textContent = 'Play directly in the post or open expanded mode.';
39
+
40
+ const actions = document.createElement('div');
41
+ actions.className = 'devvit-launch-screen__actions';
42
+
43
+ const playInlineButton = document.createElement('button');
44
+ playInlineButton.className = 'devvit-launch-screen__button';
45
+ playInlineButton.type = 'button';
46
+ playInlineButton.textContent = 'Play here';
47
+
48
+ const expandButton = document.createElement('button');
49
+ expandButton.className =
50
+ 'devvit-launch-screen__button devvit-launch-screen__button--secondary';
51
+ expandButton.type = 'button';
52
+ expandButton.textContent = 'Open expanded mode';
53
+
54
+ const status = document.createElement('p');
55
+ status.className = 'devvit-launch-screen__status';
56
+ status.setAttribute('aria-live', 'polite');
57
+ status.textContent = errorMessage ?? '';
58
+
59
+ playInlineButton.addEventListener('click', () => {
60
+ setBusy(true, 'Loading gameplay…');
61
+ const loading = mountGameplayDocument();
62
+
63
+ void context.startGameplay()
64
+ .then(() => {
65
+ loading.remove();
66
+ })
67
+ .catch((error: unknown) => {
68
+ console.error('[devvit] inline mode gameplay failed to load.', error);
69
+ renderInlineLaunchScreen(context, 'Gameplay could not start. Try again.');
70
+ });
46
71
  });
72
+ expandButton.addEventListener('click', (event) => {
73
+ setBusy(true, 'Opening expanded mode…');
74
+
75
+ void requestDevvitExpandedMode(event, 'game')
76
+ .then(() => {
77
+ setBusy(false, '');
78
+ })
79
+ .catch((error: unknown) => {
80
+ console.error('[devvit] expanded mode request failed.', error);
81
+ setBusy(false, 'Expanded mode is unavailable. Try again.');
82
+ });
83
+ });
84
+
85
+ actions.append(playInlineButton, expandButton);
86
+ launchScreen.append(eyebrow, title, description, actions, status);
87
+ const body = requireDocumentBody();
88
+
89
+ body.classList.remove('devvit-inline-mode-gameplay');
90
+ body.classList.add('devvit-inline-mode-host');
91
+ delete body.dataset.mpgdPreserveBrowserTouchGestures;
92
+ body.replaceChildren(launchScreen);
93
+
94
+ function setBusy(busy: boolean, message: string): void {
95
+ playInlineButton.disabled = busy;
96
+ expandButton.disabled = busy;
97
+ status.textContent = message;
98
+ }
99
+ }
100
+
101
+ function mountGameplayDocument(): HTMLElement {
102
+ const body = requireDocumentBody();
103
+ const app = document.createElement('main');
104
+ const game = document.createElement('div');
105
+ const loading = document.createElement('p');
106
+
107
+ app.id = 'app';
108
+ game.id = 'game';
109
+ loading.className = 'devvit-inline-gameplay-loading';
110
+ loading.setAttribute('aria-live', 'polite');
111
+ loading.setAttribute('role', 'status');
112
+ loading.textContent = 'Loading gameplay…';
113
+ game.append(loading);
114
+ app.append(game);
115
+
116
+ body.classList.remove('devvit-inline-mode-host');
117
+ body.classList.add('devvit-inline-mode-gameplay');
118
+ body.dataset.mpgdPreserveBrowserTouchGestures = 'true';
119
+ body.replaceChildren(app);
120
+
121
+ return loading;
122
+ }
47
123
 
48
- preview.append(eyebrow, title, description, button);
124
+ function requireDocumentBody(): HTMLElement {
49
125
  const body = document.body;
50
126
 
51
127
  if (body === null) {
52
- throw new Error('Devvit inline preview requires a document body.');
128
+ throw new Error('Devvit inline mode requires a document body.');
53
129
  }
54
130
 
55
- body.classList.add('devvit-preview-host');
56
- body.replaceChildren(preview);
131
+ return body;
57
132
  }