@stripchatdev/create-stripchat-extension 0.1.49 → 0.2.2

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.
Files changed (31) hide show
  1. package/dist/bin/create.js +21 -5
  2. package/dist/package.json +1 -1
  3. package/dist/src/helpers/hasUI.d.ts +2 -0
  4. package/dist/src/helpers/hasUI.js +1 -0
  5. package/dist/src/helpers/hasUI.test.d.ts +1 -0
  6. package/dist/src/helpers/hasUI.test.js +15 -0
  7. package/dist/src/projectConfig/buildProjectConfig.d.ts +1 -0
  8. package/dist/src/projectConfig/buildProjectConfig.js +2 -1
  9. package/dist/src/projectConfig/updateProjectConfig.js +2 -1
  10. package/dist/src/projectConfig/validators/getIsValidProjectConfig.test.js +1 -0
  11. package/dist/src/templater/executor.test.js +39 -2
  12. package/dist/src/templater/mappers/mapProjectConfigToTemplateModel.test.js +35 -6
  13. package/dist/src/templater/planner.js +8 -5
  14. package/dist/src/templater/planner.test.js +51 -3
  15. package/dist/src/types.d.ts +2 -0
  16. package/dist/tsconfig.tsbuildinfo +1 -1
  17. package/package.json +1 -1
  18. package/templates/v1/AGENTS.md.ejs +62 -136
  19. package/templates/v1/README.md.ejs +6 -1
  20. package/templates/v1/package.json.ejs +1 -1
  21. package/templates/v1/public/manifest.json.ejs +4 -5
  22. package/templates/v1/src/slots/background/app.ts.ejs +4 -4
  23. package/templates/v1/src/slots/settings/app.ts.ejs +84 -0
  24. package/templates/v1/src/slots/settings/app.tsx.ejs +112 -0
  25. package/templates/v1/src/slots/settings/main.css.ejs +117 -0
  26. package/templates/v1/src/slots/settings/main.ts.ejs +3 -0
  27. package/templates/v1/src/slots/settings/main.tsx.ejs +9 -0
  28. package/templates/v1/src/slots/settings/settings.html.ejs +30 -0
  29. package/templates/v1/vite.config.ts.ejs +3 -0
  30. package/templates/v2/AGENTS.md.ejs +64 -21
  31. package/templates/v2/vite.config.ts.ejs +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stripchatdev/create-stripchat-extension",
3
- "version": "0.1.49",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -1,6 +1,6 @@
1
1
  # <%= name %>
2
2
  Use this file as the primary coding contract for this starter. Use the Stripchat docs to get more details.
3
- - Category: <%= config.category %>
3
+ - Category: <%= category %>
4
4
  - Slots: <%= slotsEntries.map(({ slot }) => slot).join(', ') %>
5
5
  - Framework: <%= framework %>
6
6
  - Language: <%= language %>
@@ -47,7 +47,7 @@ Use this file as the primary coding contract for this starter. Use the Stripchat
47
47
  - Use v1.ext.whisper.local for communication between this extension's frames for the current user.
48
48
  - Use v1.ext.whisper only when every loaded client in the room should receive the message.
49
49
  - When a user sends v1.ext.whisper during a public show, paymentHash is required. Obtain it from v1.payment.tokens.spend.succeeded. The model can whisper without paymentHash.
50
- - Broadcast whispers arrive as v1.ext.whispered
50
+ - Broadcast whispers arrive as v2.ext.whispered with the envelope `{ data, tokenSum }`. Treat `tokenSum` as authoritative actual payment; Validate `tokenSum` against actual activity price. It could be null for free actions, e.g. whispers called by a model.
51
51
  - Local whispers arrive as v1.ext.whispered.local
52
52
  - Always include a stable type field in whisper payloads.
53
53
  - Do not send sensitive data in whisper payloads, including usernames or user IDs.
@@ -65,7 +65,7 @@ Use this file as the primary coding contract for this starter. Use the Stripchat
65
65
  - Do not access the parent DOM.
66
66
  - Do not rely on host cookies.
67
67
  - Actions are strictly reviewed before publishing. Use it only when necessary (e.g. third-party API calls with secrets).
68
- - If you use EXTENSION_SLOT_MOVEABLE_OVERLAY, define moveableSlot.width and moveableSlot.height in the manifest. Max size is 450x740.
68
+ - If you use EXTENSION_SLOT_MOVEABLE_OVERLAY, no size configuration is needed in the manifest — the overlay is fixed at 450x685.
69
69
  - Do not invent new slot names or slot type constants. Use capitalized SNAKE_CASE in view.slots in manifest.
70
70
  - Do not invent SDK request names, event names, method names, manifest field names, or payload keys. Use only documented names from the Stripchat docs and this starter.
71
71
  - Prefer extending the generated wiring over replacing it.
@@ -73,10 +73,7 @@ Use this file as the primary coding contract for this starter. Use the Stripchat
73
73
  - Keep public/manifest.json valid JSON with no trailing commas.
74
74
  - In TypeScript, import types from @stripchatdev/ext-helper and use them
75
75
  - ALWAYS USE background slot to handle long-running orchestration logic — timers, polling, state machines, centralized state management, events, whispers
76
- - For state shared between a background slot and visual slots, use the slot-state helpers.
77
- - The background slot owns the state. Visual slots read it and send partial updates.
78
- - Use the same channel string for the host and every client. Create only one host for each channel.
79
- - For messages that are not synchronized state, use v1.ext.whisper.local for communication between this extension's frames for the current user. Background should subscribe to events and coordinate it.
76
+ - The flow should be: visual slot -> whisper.local to background -> background handles logic. Background should subscribe to events and coordinate it
80
77
  - Create background slot for each actor (model, viewer). Do not put model-only and viewer-only logic in the same background.
81
78
  - Chatbot extensions have no UI — only a background slot handles all logic (receiving messages and sending chatbot messages).
82
79
  - Do not create visual slots, HTML pages, or resolver entries for chatbot extensions.
@@ -86,14 +83,10 @@ Use this file as the primary coding contract for this starter. Use the Stripchat
86
83
  - Do not call v1.chatbot.message.send unconditionally inside v1.chat.message.received handlers for every message — chat rooms can have hundreds of messages per second. Use event-driven triggers (context changes, timers, tips) instead or add strict filters on message content or sender properties.
87
84
  - Broadcast Tools are rendered only for the model of the current room.
88
85
  - For Broadcast Tools, do not add explicit `context.user?.id === context.model?.id` guards in `public/resolveSlotPage.js` — the platform enforces model-only visibility automatically.
89
- - If you use `EXTENSION_SLOT_MOVEABLE_OVERLAY`, define `moveableSlot.width` and `moveableSlot.height` in the manifest (max 450x740). Only the user controls expand/collapse; update status/content via SDK, not host UI takeover.
86
+ - If you use `EXTENSION_SLOT_MOVEABLE_OVERLAY`, the overlay size is fixed at 450x685 — no manifest size fields are needed. Only the user controls expand/collapse; update status/content via SDK, not host UI takeover.
90
87
  - videoDecorativeOverlay is not interactive. Do not put buttons or interactive elements in it. Use it only for animations, visualizations etc. after calling `v1.ext.activity.request`.
91
88
  - When using `v1.ext.activity.request`, ALWAYS make sure that the actual animation is shorter then the activity duration in order to avoid immidiate slot closing after the animation ends.
92
89
 
93
- <% if (framework === 'preact') { -%>
94
- - Use useSlotState from @stripchatdev/ext-helper/helpers/react in React-compatible visual slots.
95
- <% } -%>
96
-
97
90
  ## Slot Guardrails
98
91
  - Generated slot sets for this starter:
99
92
  - Games & Fun: EXTENSION_SLOT_MAIN_GAME_FUN, EXTENSION_SLOT_BACKGROUND, either one of EXTENSION_SLOT_RIGHT_OVERLAY or EXTENSION_SLOT_VIDEO_DECORATIVE_OVERLAY
@@ -110,19 +103,12 @@ Use this file as the primary coding contract for this starter. Use the Stripchat
110
103
  ## Default Runtime Pattern
111
104
  - background owns long-lived state and coordination.
112
105
  - Visual slots render UI from current state and forward user actions.
113
- - Use slot-state helpers when visual slots need a synchronized state snapshot owned by background.
114
106
  - Load initial context once with v1.ext.context.get.
115
107
  - Keep context fresh with v1.ext.context.updated. Payload is { context: TV1ExtContext }.
116
108
  - Send state updates to every frame of the current user with v1.ext.whisper.local.
117
109
  - If all loaded clients need the event, use v1.ext.whisper.
118
110
  - Assume every visual slot can mount, unmount, and remount without notice.
119
111
 
120
- ## Shared Slot State helpers
121
- - Use the same channel value in the host and all clients.
122
- - The client starts with isLoading: true and state: null until it receives the initial state.
123
- - Use `setState` for shallow partial updates. Update nested objects explicitly.
124
- - Unsubscribe clients when visual slots are disposed and unsubscribe the host when background teardown runs.
125
-
126
112
  ## Resolver
127
113
  - Start from the generated resolver and extend it only when needed.
128
114
  - Keep resolver logic cheap, synchronous, and defensive.
@@ -213,7 +199,6 @@ TV1ExtLoggedInUser:
213
199
  - isGuest: false
214
200
  - id: number
215
201
  - username: string
216
- - fanClub: TV1UsersFanClub
217
202
  - status: 'public' | 'private' | 'exclusivePrivate' | 'groupShow' | 'ticketShow' | 'spy' | 'off'
218
203
  - hasTokens: boolean
219
204
  - hasPaidBefore: boolean
@@ -222,26 +207,11 @@ TV1ExtLoggedInUser:
222
207
  - `isModel` means the logged-in user has a model account. It does not mean they are the broadcaster of the current room.
223
208
  - userRanking?: { league: 'grey' | 'bronze' | 'silver' | 'gold' | 'diamond' | 'royal' | 'legend'; level: number }
224
209
 
225
- ### TV1FanClubTier
226
- Fan club subscription tier. Ordered from lowest to highest:
227
- - soldier: first tier with basic benefits
228
- - lord: second tier with more benefits than soldier
229
- - prince: third tier with the most benefits
230
-
231
- ### TV1UsersFanClub
232
- Discriminated union on `isSubscribed`: TV1UsersFanClubSubscribed | TV1UsersFanClubNotSubscribed.
233
-
234
- TV1UsersFanClubSubscribed:
235
- - isSubscribed: true
236
- - tier: TV1FanClubTier
237
-
238
- TV1UsersFanClubNotSubscribed:
239
- - isSubscribed: false
240
-
241
- ### TV1PaymentHash
242
- - string
243
- - Payment hash used for authentication. Do not store or reuse it. Use it only once for the request it authorizes.
244
-
210
+ ### TV1PaymentData
211
+ - amount: string
212
+ - paymentToken: string
213
+ - transactionId: string
214
+ - userId: string
245
215
 
246
216
  ### TV1TipData (payload of v1.tokens.spent)
247
217
  - amount: number
@@ -264,7 +234,7 @@ TV1UsersFanClubNotSubscribed:
264
234
  - v1.ext.context.updated -> react to context changes. Event payload is { context: TV1ExtContext }.
265
235
  - v1.ext.whisper.local -> send a message to every frame of the current user.
266
236
  - v1.ext.whisper -> broadcast to all loaded clients in the room. Users in public shows must include paymentHash from v1.payment.tokens.spend.succeeded.
267
- - v1.ext.whispered -> receives whispers from v1.ext.whisper.
237
+ - v2.ext.whispered -> receives broadcasts from v1.ext.whisper. The event payload is `{ data, tokenSum }`, where custom fields are in `data`;
268
238
  - v1.ext.whispered.local -> receives whisper.local messages.
269
239
  - v1.chat.message.send -> send a chat message on behalf of the extension. The message is sent locally to each user. Always pass isAnonymous from the payment/tip source and preserve the user's anonymity. It can include a {username} placeholder. You can highlight part of the text by using {#accent}Accent text{/accent} and {#fade}Faded text{/fade}. ALWAYS use it to notify a user about game results, e.g. after v1.ext.activity.request timeout.
270
240
  - v1.chatbot.message.send -> send a chat message as bot or model. Message is delivered locally to each user. Supports {username} placeholder in message text. Params: from ('bot' | 'model'), message (string). Use it only in chatbot extensions.
@@ -279,7 +249,6 @@ TV1UsersFanClubNotSubscribed:
279
249
  - v1.payment.tokens.topup -> open the token purchase modal directly for an explicit "Buy tokens" CTA. Params null, result null. It does not confirm a spend; use v1.payment.tokens.spend when you need a confirmed payment for a game or interaction.
280
250
  - v1.tokens.spent -> receive room-side tip events. Should be used in background to react on tips (e.g. to send a message in chat).
281
251
  - v1.private.request -> request a private show with the broadcaster.
282
- - v1.fanClub.request -> open the fan club subscription modal for the current model. Check `user.fanClub` in context before calling. Params: `{ tier: TV1FanClubTier } | null` — `tier` selects which tab to show in the modal; pass `null` to default to `soldier`. Result null.
283
252
  - v1.ext.signup.open -> open sign-up for anonymous users.
284
253
  - v1.ext.activity.request -> Reserves exclusive access to shared activity slots (`EXTENSION_SLOT_RIGHT_OVERLAY`, `EXTENSION_SLOT_VIDEO_DECORATIVE_OVERLAY`) for a short visual action (for example: reactions, wheel-of-fortune spin, or a brief action indicator). The call waits until the slot is available, then starts the reservation for `durationMs` (max `30000` ms) and returns `{ activityId: string }`. Reservation is ephemeral (not persisted across reboots and not restored for newly joined users); for long-lived, persistent flows use `v1.session.*`.
285
254
  - v1.ext.movableOverlay.update -> update moveableOverlay status pill (status + message). Sex Toys and Broadcast Tools only.
@@ -300,97 +269,45 @@ TV1UsersFanClubNotSubscribed:
300
269
  - v1.goal.get -> load the current active regular goal. Returns `{ goal: TV1GoalData | null }`. `goal` is `null` when no regular goal is active.
301
270
  - v1.goal.updated -> fires when any field of the active goal changes (description, isCompleted, tokensTarget, tokensCollected) or the active goal is removed. Payload: `{ goal: TV1GoalData | null }`. Use it in background to keep the cached goal in sync and broadcast updates to visual slots via v1.ext.whisper.local.
302
271
  - v1.session.join.model -> model-only create/start of a long-term session when none exists. If a session is already active, this call does not recreate/replace it.
303
- - v1.session.join.user -> user-only join after payment (`paymentHash` param is required and is taken from `paymentHash` in `v1.payment.tokens.spend.succeeded`). If no session is active, this call can create one.
272
+ - v1.session.join.user -> user-only join after payment (`paymentHash` is required and is taken from `paymentHash` in `v1.payment.tokens.spend.succeeded`). If no session is active, this call can create one.
304
273
  - v1.session.get -> returns the current long-term session. Before enabling Start/Join UI, check `v1.session.get`: if `session.owner === 'other'`, keep controls blocked because another extension owns the active session. TTL is applied from whoever created the active session first (model or user).
305
274
  - v1.session.finish.model -> clear the current long-term session from the model side.
306
275
  - v1.session.whisper.user -> send a custom action payload within the current long-term session from the user to the model.
307
276
 
308
- ## Canonical Examples and Practices
309
-
310
- ### Shared state between background and visual slots
311
- Use one host in the background slot and a client in each visual slot that needs the same state. Use the same channel in every slot.
312
-
313
- Background slot:
314
- ```
315
- import { createExtHelper } from '@stripchatdev/ext-helper';
316
- import { createSlotStateHost } from '@stripchatdev/ext-helper/helpers';
317
-
318
- const ext = createExtHelper();
319
- const scoreState = createSlotStateHost({
320
- channel: 'score',
321
- extHelper: ext,
322
- initialState: { score: 0 },
323
- });
324
-
325
- // The background slot owns the shared state.
326
- const cleanup = () => scoreState.unsubscribe();
327
- ```
328
-
329
- <% if (framework === 'preact') { -%>
330
- Visual slot:
331
- ```
332
- import { createExtHelper } from '@stripchatdev/ext-helper';
333
- import { useSlotState } from '@stripchatdev/ext-helper/helpers/react';
334
-
335
- type ScoreState = {
336
- score: number;
337
- };
338
-
339
- const ext = createExtHelper();
340
-
341
- export const App = () => {
342
- const { isLoading, state, setState } = useSlotState<ScoreState>({
343
- channel: 'score',
344
- extHelper: ext,
345
- });
346
-
347
- if (isLoading || !state) return <p>Loading score...</p>;
348
-
349
- return (
350
- <button onClick={() => void setState({ score: state.score + 1 })}>
351
- Score: {state.score}
352
- </button>
353
- );
277
+ ### Validate broadcast payment before acting
278
+ `tokenSum` on `v2.ext.whispered` is the authoritative number of tokens actually paid. In a runtime slot, load the expected action price with `v1.ext.settings.get` (or use a constant for defining a price) and narrow the returned settings value before acting;
279
+
280
+ ```js
281
+ const loadExpectedPrice = async () => {
282
+ try {
283
+ const { settings } = await ext.makeRequest('v1.ext.settings.get', null);
284
+ if (
285
+ typeof settings !== 'object' ||
286
+ settings === null ||
287
+ // priceTokens is an example field, you can name it as you wan't
288
+ !('priceTokens' in settings) ||
289
+ !isValidPrice(settings.priceTokens)
290
+ ) {
291
+ return null;
292
+ }
293
+ return settings.priceTokens;
294
+ } catch {
295
+ return null;
296
+ }
354
297
  };
355
- ```
356
- <% } else { -%>
357
- Visual slot:
358
- ```
359
- import { createExtHelper } from '@stripchatdev/ext-helper';
360
- import { createSlotStateClient } from '@stripchatdev/ext-helper/helpers';
361
298
 
362
- type ScoreState = {
363
- score: number;
364
- };
299
+ const expectedPrice = await loadExpectedPrice();
365
300
 
366
- const ext = createExtHelper();
367
- const scoreState = createSlotStateClient<ScoreState>({
368
- channel: 'score',
369
- extHelper: ext,
370
- });
371
- const button = document.createElement('button');
372
- document.body.append(button);
301
+ // later in the background slot
302
+ extHelper.susbcribe('v2.ext.whispered', ({ data, tokenSum }) => {
303
+ // tokenSum is null for a model or in private shows
304
+ if (tokenSum !== null && tokenSum !== expectedPrice) return;
305
+ // otherwise proceed with an action
306
+ })
373
307
 
374
- const render = () => {
375
- const { isLoading, state } = scoreState.getSnapshot();
376
- button.textContent = isLoading || !state ? 'Loading score...' : `Score: ${state.score}`;
377
- };
378
- const addPoint = () => {
379
- const state = scoreState.getSnapshot().state;
380
- if (state) void scoreState.setState({ score: state.score + 1 });
381
- };
382
-
383
- const unsubscribe = scoreState.subscribe(render);
384
- button.addEventListener('click', addPoint);
385
- const cleanup = () => {
386
- unsubscribe();
387
- button.removeEventListener('click', addPoint);
388
- button.remove();
389
- };
390
308
  ```
391
- <% } -%>
392
309
 
393
- The background host owns the state. Visual slots read it and send partial updates.
310
+ ## Canonical Examples and Practices
394
311
 
395
312
  ### Settings save flow in settings.html
396
313
  ```
@@ -511,7 +428,7 @@ const spendForRound = async () => {
511
428
  });
512
429
  };
513
430
  const onTokensSpendSucceeded = (data) => {
514
- console.log('Spend confirmed:', data.paymentHash, data.tokensSpendData);
431
+ console.log('Spend confirmed:', data.paymentData.amount, data.tokensSpendData);
515
432
  };
516
433
  ext.subscribe('v1.payment.tokens.spend.succeeded', onTokensSpendSucceeded);
517
434
  const cleanup = () => {
@@ -549,7 +466,7 @@ const cleanup = () => {
549
466
  };
550
467
  ```
551
468
 
552
- ### User-initiated whisper with payment hash (full flow)
469
+ ### User-initiated whisper with payment token (full flow)
553
470
  When a user needs to broadcast a whisper during a public show, paymentHash is required.
554
471
  Flow: visual slot calls v1.payment.tokens.spend -> user background listens to v1.payment.tokens.spend.succeeded -> user background sends v1.ext.whisper with paymentHash.
555
472
 
@@ -587,33 +504,43 @@ All clients receiving the broadcast:
587
504
  ```
588
505
  import { createExtHelper } from '@stripchatdev/ext-helper';
589
506
  const ext = createExtHelper();
590
- const onWhispered = (data) => {
591
- if (data.type !== 'SPIN_RESULT') {
507
+ const expectedPrice = 10;
508
+ const onWhispered = async ({ data, tokenSum }) => {
509
+ if (data.type !== 'SPIN_RESULT' || typeof data.value !== 'number') {
510
+ return;
511
+ }
512
+ // validate that tokenSum is the same as expectedPrice.
513
+ if (tokenSum !== null && tokenSum !== expectedPrice) {
592
514
  return;
593
515
  }
594
516
  showResult(data.value);
595
517
  };
596
- ext.subscribe('v1.ext.whispered', onWhispered);
518
+ ext.subscribe('v2.ext.whispered', onWhispered);
597
519
  const cleanup = () => {
598
- ext.unsubscribe('v1.ext.whispered', onWhispered);
520
+ ext.unsubscribe('v2.ext.whispered', onWhispered);
599
521
  };
600
522
  ```
601
523
 
602
524
  ### Activity request flow
603
- In rightOverlay or videoDecorativeOverlay (after user-initiated whisper with payment hash):
525
+ In rightOverlay or videoDecorativeOverlay (after user initiated whisper with payment token):
604
526
 
605
527
  ```
606
528
  import { createExtHelper } from '@stripchatdev/ext-helper';
607
529
  const ext = createExtHelper();
608
-
530
+ const expectedPrice = 10;
609
531
  const ANIMATION_DURATION = 8_000;
610
532
  const ACTIVITY_DURATION = 10_000;
611
533
 
612
- const handleWhispered = async (data) => {
613
- if ('type' in data && data.type !== 'SPIN_THE_WHEEL') {
534
+ const handleWhispered = async ({ data, tokenSum }) => {
535
+ if (data.type !== 'SPIN_RESULT' || typeof data.value !== 'number') {
614
536
  return;
615
537
  };
616
538
 
539
+ // validate that tokenSum is the same as expectedPrice.
540
+ if (tokenSum !== null && tokenSum !== expectedPrice)
541
+ return;
542
+ }
543
+
617
544
  await ext.makeRequest('v1.ext.activity.request', {
618
545
  durationMs: ACTIVITY_DURATION,
619
546
  });
@@ -631,7 +558,7 @@ const handleWhispered = async (data) => {
631
558
  });
632
559
  };
633
560
 
634
- ext.subscribe('v1.ext.whispered', handleWhispered);
561
+ ext.subscribe('v2.ext.whispered', handleWhispered);
635
562
  ```
636
563
  UI blocking should be handled in UI by listening to the activity changes. For example, in mainGameFun slot:
637
564
  ```
@@ -765,7 +692,7 @@ void init();
765
692
  ### Add moveable overlay
766
693
  1. Add EXTENSION_SLOT_MOVEABLE_OVERLAY to the slot configuration.
767
694
  2. Add a page key and root HTML file for it.
768
- 3. Add moveableSlot to the manifest with width and height. Max size is 450x740.
695
+ 3. The overlay is fixed at 450x685 — no size fields needed in the manifest.
769
696
  4. Keep public/icon.svg in the archive root.
770
697
  5. Add the HTML file to Vite input.
771
698
  6. Update the resolver to return the page key only when appropriate.
@@ -795,4 +722,3 @@ void init();
795
722
  - Events: https://extensions.stripchat.com/docs/api/events
796
723
  - Entities: https://extensions.stripchat.com/docs/api/entities
797
724
  - Session guide: https://extensions.stripchat.com/docs/guides/session-extension
798
- - Slots state: https://extensions.stripchat.com/docs/guides/slot-state
@@ -11,6 +11,11 @@ Read `AGENTS.md` first. It is the project contract for SDK usage, slot lifecycle
11
11
  - `public/manifest.json` declares slots, page keys, optional settings entry, and backend actions.
12
12
  - `public/resolveSlotPage.js` maps slot types to page keys from `views.pages`.
13
13
  - Root HTML files like `<%= slotsEntries[0].slotCamelCased %>.html` and `background.html` are emitted to the archive root.
14
+ <% if (hasSettings) { -%>
15
+ - `settings.html` is the model-facing settings iframe.
16
+ <% } else { -%>
17
+ - To add model-facing settings later, create `settings.html`, add `views.settings`, and add its Vite input.
18
+ <% } -%>
14
19
  - Visual slots should stay disposable; keep durable state and coordination in `background`.
15
20
  - `vite.config.<%= isTypescript ? 'ts' : 'js' %>` must include every root HTML file in `build.rollupOptions.input`.
16
21
 
@@ -32,7 +37,7 @@ Learn more about building Stripchat extensions in the [official documentation](h
32
37
  ## Common Changes
33
38
 
34
39
  - Add a page: create a root HTML file, add it to `public/manifest.json`, return its page key from `public/resolveSlotPage.js`, and include it in `vite.config.<%= isTypescript ? 'ts' : 'js' %>`.
35
- - Add settings: create `settings.html`, add `views.settings`, add its Vite input, load with `v1.model.ext.settings.get`, and save on `v1.model.ext.settings.set.requested`.
40
+ - Settings are loaded with `v1.model.ext.settings.get` and saved on `v1.model.ext.settings.set.requested` through `v1.model.ext.settings.set`.
36
41
  - Add an action: declare it in `public/manifest.json` and call it with `v1.ext.actions.call`.
37
42
 
38
43
  ## Usage
@@ -12,7 +12,7 @@
12
12
  "preact": "10.25.4"<% } %>
13
13
  },
14
14
  "devDependencies": {
15
- "vite": "6.4.2"<% if (isTypescript) { %>,
15
+ "vite": "6.4.3"<% if (isTypescript) { %>,
16
16
  "typescript": "5.8.2"<% } %><% if (framework === 'preact') { %>,
17
17
  "@preact/preset-vite": "2.10.5"<% } %>
18
18
  }
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "version": "v2.0",
3
3
  "views": {
4
+ <% if (hasSettings) { -%>
5
+ "settings": "settings.html",
6
+ <% } -%>
4
7
  "slots": [<%- slotsEntries.map(({ slot }) => `"${slot}"`).join(', ') %>],
5
8
  "pages": {
6
9
  <% slotsEntries.forEach(({ slotCamelCased }, i) => { -%>
@@ -8,9 +11,5 @@
8
11
  <% }); -%>
9
12
  },
10
13
  "resolveSlotPageScript": "resolveSlotPage.js"
11
- }<% if (slotsEntries.filter(({ slot }) => slot ==='EXTENSION_SLOT_MOVEABLE_OVERLAY').length > 0) { %>,
12
- "moveableSlot": {
13
- "width": 400,
14
- "height": 600
15
- }<% } %>
14
+ }
16
15
  }
@@ -6,10 +6,10 @@ import { createExtHelper } from '@stripchatdev/ext-helper';
6
6
  const extHelper = createExtHelper();
7
7
 
8
8
  export const init = () => {
9
- const handleWhispered = async (data<%- isTypescript ? ': TEvents["v1.ext.whispered"]' : '' %>) => {
9
+ const handleWhispered = async (event<%- isTypescript ? ': TEvents["v2.ext.whispered"]' : '' %>) => {
10
10
  await extHelper.makeRequest('v1.monitoring.report.log', {
11
11
  message: 'background whisper received',
12
- data,
12
+ data: event.data,
13
13
  });
14
14
  };
15
15
 
@@ -20,7 +20,7 @@ export const init = () => {
20
20
  });
21
21
  };
22
22
 
23
- extHelper.subscribe('v1.ext.whispered', handleWhispered);
23
+ extHelper.subscribe('v2.ext.whispered', handleWhispered);
24
24
  extHelper.subscribe('v1.ext.context.updated', handleContextUpdated);
25
25
 
26
26
  extHelper.makeRequest('v1.ext.context.get', null).then((ctx) => {
@@ -31,7 +31,7 @@ export const init = () => {
31
31
  });
32
32
 
33
33
  return () => {
34
- extHelper.unsubscribe('v1.ext.whispered', handleWhispered);
34
+ extHelper.unsubscribe('v2.ext.whispered', handleWhispered);
35
35
  extHelper.unsubscribe('v1.ext.context.updated', handleContextUpdated);
36
36
  };
37
37
  };
@@ -0,0 +1,84 @@
1
+ import { createExtHelper } from '@stripchatdev/ext-helper';
2
+
3
+ const extHelper = createExtHelper();
4
+
5
+ <% if (isTypescript) { -%>
6
+ type TSettings = {
7
+ viewerMessage: string;
8
+ };
9
+ <% } -%>
10
+
11
+ const DEFAULT_SETTINGS<%- isTypescript ? ': TSettings' : '' %> = {
12
+ viewerMessage: '',
13
+ };
14
+
15
+ const toSettings = (value<%- isTypescript ? ': unknown' : '' %>)<%- isTypescript ? ': TSettings' : '' %> => {
16
+ if (!value || typeof value !== 'object') {
17
+ return DEFAULT_SETTINGS;
18
+ }
19
+
20
+ const input<%- isTypescript ? ': Record<string, unknown>' : '' %> = <%- isTypescript ? 'value as Record<string, unknown>' : 'value' %>;
21
+
22
+ return {
23
+ viewerMessage:
24
+ typeof input.viewerMessage === 'string'
25
+ ? input.viewerMessage
26
+ : DEFAULT_SETTINGS.viewerMessage,
27
+ };
28
+ };
29
+
30
+ export const init = () => {
31
+ const viewerMessageInput = document.getElementById('viewer-message')<%- isTypescript ? ' as HTMLInputElement' : '' %>;
32
+ const errorMessage = document.getElementById('settings-error')<%- isTypescript ? ' as HTMLParagraphElement' : '' %>;
33
+ let settingsLoaded = false;
34
+
35
+ const renderValidation = (message<%- isTypescript ? ': string' : '' %>) => {
36
+ errorMessage.textContent = message;
37
+ viewerMessageInput.setAttribute('aria-invalid', String(message.length > 0));
38
+ };
39
+
40
+ const applySettings = (settings<%- isTypescript ? ': TSettings' : '' %>) => {
41
+ viewerMessageInput.value = settings.viewerMessage;
42
+ };
43
+
44
+ const readSettings = ()<%- isTypescript ? ': TSettings' : '' %> => ({
45
+ viewerMessage: viewerMessageInput.value,
46
+ });
47
+
48
+ const loadSettings = async () => {
49
+ try {
50
+ const response = await extHelper.makeRequest('v1.model.ext.settings.get', null);
51
+ applySettings(toSettings(response.settings));
52
+ } catch {
53
+ applySettings(DEFAULT_SETTINGS);
54
+ } finally {
55
+ settingsLoaded = true;
56
+ viewerMessageInput.removeAttribute('disabled');
57
+ }
58
+ };
59
+
60
+ const onSettingsSaveRequested = async () => {
61
+ const settings = readSettings();
62
+ const isError = !settingsLoaded || settings.viewerMessage.trim().length === 0;
63
+
64
+ renderValidation(
65
+ !settingsLoaded
66
+ ? 'Settings are still loading.'
67
+ : isError
68
+ ? 'Enter a viewer message.'
69
+ : '',
70
+ );
71
+
72
+ await extHelper.makeRequest('v1.model.ext.settings.set', {
73
+ settings,
74
+ isError,
75
+ });
76
+ };
77
+
78
+ extHelper.subscribe('v1.model.ext.settings.set.requested', onSettingsSaveRequested);
79
+ void loadSettings();
80
+
81
+ return () => {
82
+ extHelper.unsubscribe('v1.model.ext.settings.set.requested', onSettingsSaveRequested);
83
+ };
84
+ };
@@ -0,0 +1,112 @@
1
+ import { createExtHelper } from '@stripchatdev/ext-helper';
2
+ import { useEffect, useState } from 'preact/hooks';
3
+
4
+ const extHelper = createExtHelper();
5
+
6
+ <% if (isTypescript) { -%>
7
+ type TSettings = {
8
+ viewerMessage: string;
9
+ };
10
+ <% } -%>
11
+
12
+ const DEFAULT_SETTINGS<%- isTypescript ? ': TSettings' : '' %> = {
13
+ viewerMessage: '',
14
+ };
15
+
16
+ const toSettings = (value<%- isTypescript ? ': unknown' : '' %>)<%- isTypescript ? ': TSettings' : '' %> => {
17
+ if (!value || typeof value !== 'object') {
18
+ return DEFAULT_SETTINGS;
19
+ }
20
+
21
+ const input<%- isTypescript ? ': Record<string, unknown>' : '' %> = <%- isTypescript ? 'value as Record<string, unknown>' : 'value' %>;
22
+
23
+ return {
24
+ viewerMessage:
25
+ typeof input.viewerMessage === 'string'
26
+ ? input.viewerMessage
27
+ : DEFAULT_SETTINGS.viewerMessage,
28
+ };
29
+ };
30
+
31
+ export const App = () => {
32
+ const [settings, setSettings] = useState<%- isTypescript ? '<TSettings>' : '' %>(DEFAULT_SETTINGS);
33
+ const [settingsLoaded, setSettingsLoaded] = useState(false);
34
+ const [validationMessage, setValidationMessage] = useState('');
35
+
36
+ useEffect(() => {
37
+ let isMounted = true;
38
+
39
+ const loadSettings = async () => {
40
+ try {
41
+ const response = await extHelper.makeRequest('v1.model.ext.settings.get', null);
42
+
43
+ if (isMounted) {
44
+ setSettings(toSettings(response.settings));
45
+ }
46
+ } catch {
47
+ if (isMounted) {
48
+ setSettings(DEFAULT_SETTINGS);
49
+ }
50
+ } finally {
51
+ if (isMounted) {
52
+ setSettingsLoaded(true);
53
+ }
54
+ }
55
+ };
56
+
57
+ void loadSettings();
58
+
59
+ return () => {
60
+ isMounted = false;
61
+ };
62
+ }, []);
63
+
64
+ useEffect(() => {
65
+ const onSettingsSaveRequested = async () => {
66
+ const isError = !settingsLoaded || settings.viewerMessage.trim().length === 0;
67
+
68
+ setValidationMessage(
69
+ !settingsLoaded
70
+ ? 'Settings are still loading.'
71
+ : isError
72
+ ? 'Enter a viewer message.'
73
+ : '',
74
+ );
75
+
76
+ await extHelper.makeRequest('v1.model.ext.settings.set', {
77
+ settings,
78
+ isError,
79
+ });
80
+ };
81
+
82
+ extHelper.subscribe('v1.model.ext.settings.set.requested', onSettingsSaveRequested);
83
+
84
+ return () => {
85
+ extHelper.unsubscribe('v1.model.ext.settings.set.requested', onSettingsSaveRequested);
86
+ };
87
+ }, [settings, settingsLoaded]);
88
+
89
+ return (
90
+ <main>
91
+ <h1>Extension settings</h1>
92
+ <form>
93
+ <label className="message-field" for="viewer-message">
94
+ Viewer message
95
+ <input
96
+ className="input"
97
+ id="viewer-message"
98
+ type="text"
99
+ aria-invalid={validationMessage.length > 0}
100
+ value={settings.viewerMessage}
101
+ disabled={!settingsLoaded}
102
+ onInput={(event) => {
103
+ const target = event.currentTarget<%- isTypescript ? ' as HTMLInputElement' : '' %>;
104
+ setSettings({ ...settings, viewerMessage: target.value });
105
+ }}
106
+ />
107
+ </label>
108
+ <p className="error" role="alert" aria-live="polite">{validationMessage}</p>
109
+ </form>
110
+ </main>
111
+ );
112
+ };