@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
@@ -0,0 +1,117 @@
1
+ :root {
2
+ font-family: Inter, 'Helvetica Neue', Roboto, Arial, Helvetica, sans-serif;
3
+ }
4
+
5
+ html {
6
+ height: 100%;
7
+ font-size: 13px;
8
+ color: var(--text-color);
9
+ }
10
+
11
+ html[data-theme='light'] {
12
+ --text-color: #101010;
13
+ --input-error-color: #f14b57;
14
+ --input-error-active: #de5b64;
15
+ --input-error-hover: #d5444e;
16
+ --input-error-box-shadow-focused: inset 0 1px 1px rgb(0 0 0 / 7.5%), 0 0 6px rgb(213 68 78 / 75%);
17
+ --input-color: #141414;
18
+ --input-background-color: #fff;
19
+ --input-background-color-focused: #fff;
20
+ --input-border-color: rgb(20 20 20 / 20%);
21
+ --input-border-color-focused: rgb(20 20 20 / 40%);
22
+ --input-box-shadow-focused: inset 0 1px 1px rgb(0 0 0 / 7.5%), 0 0 5px rgb(20 20 20 / 15%);
23
+ --input-placeholder-color: rgb(20 20 20 / 40%);
24
+ }
25
+
26
+ html[data-theme='dark'] {
27
+ --text-color: #f8f8f8;
28
+ --input-error-color: #de5b64;
29
+ --input-error-active: #d5444e;
30
+ --input-error-hover: #d5444e;
31
+ --input-error-box-shadow-focused: inset 0 1px 1px rgb(0 0 0 / 7.5%), 0 0 6px rgb(213 68 78 / 75%);
32
+ --input-color: #f8f8f8;
33
+ --input-background-color: #000;
34
+ --input-background-color-focused: #000;
35
+ --input-border-color: #000;
36
+ --input-border-color-focused: rgb(255 255 255 / 30%);
37
+ --input-box-shadow-focused: inset 0 1px 1px rgb(0 0 0 / 7.5%), 0 0 5px rgb(255 255 255 / 25%);
38
+ --input-placeholder-color: rgb(248 248 248 / 33%);
39
+ }
40
+
41
+ * {
42
+ box-sizing: border-box;
43
+ padding: 0;
44
+ margin: 0;
45
+ }
46
+
47
+ body {
48
+ font-feature-settings:
49
+ 'liga' off,
50
+ 'clig' off;
51
+ }
52
+
53
+ main {
54
+ margin: 0 auto;
55
+ height: 100%;
56
+ }
57
+
58
+ h1 {
59
+ margin-bottom: 0.5rem;
60
+ }
61
+
62
+ form {
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: 1rem;
66
+ }
67
+
68
+ .message-field {
69
+ display: flex;
70
+ flex-direction: column;
71
+ gap: 0.4rem;
72
+ }
73
+
74
+ .input {
75
+ display: inline-block;
76
+ height: 40px;
77
+ padding: 10px 20px;
78
+ border: 1px solid var(--input-border-color);
79
+ border-radius: 20px;
80
+ background-color: var(--input-background-color);
81
+ color: var(--input-color);
82
+ font-size: 12px;
83
+ line-height: 20px;
84
+ transition: all 0.15s ease-in-out;
85
+ }
86
+
87
+ .input::placeholder {
88
+ color: var(--input-placeholder-color);
89
+ }
90
+
91
+ .input:focus {
92
+ border-color: var(--input-border-color-focused);
93
+ background-color: var(--input-background-color-focused);
94
+ box-shadow: var(--input-box-shadow-focused);
95
+ outline: none;
96
+ }
97
+
98
+ .input:disabled {
99
+ cursor: default;
100
+ opacity: 0.33;
101
+ }
102
+
103
+ input::-ms-clear {
104
+ display: none;
105
+ }
106
+
107
+ input[type='number']::-webkit-outer-spin-button,
108
+ input[type='number']::-webkit-inner-spin-button {
109
+ margin: 0;
110
+ appearance: none;
111
+ }
112
+
113
+ .error {
114
+ color: var(--input-error-color);
115
+ font-size: 0.75rem;
116
+ line-height: 1.2;
117
+ }
@@ -0,0 +1,3 @@
1
+ import { init } from './app';
2
+
3
+ init();
@@ -0,0 +1,9 @@
1
+ import { render } from 'preact';
2
+
3
+ import { App } from './app';
4
+
5
+ const root = document.getElementById('app');
6
+
7
+ if (root) {
8
+ render(<App />, root);
9
+ }
@@ -0,0 +1,30 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Extension settings</title>
8
+
9
+ <link rel="stylesheet" href="<%= stylePath %>" />
10
+ </head>
11
+
12
+ <body>
13
+ <% if (framework==='preact' ) { -%>
14
+ <div id="app"></div>
15
+ <% } else { -%>
16
+ <main>
17
+ <h1>Extension settings</h1>
18
+ <form>
19
+ <label class="message-field" for="viewer-message">
20
+ Viewer message
21
+ <input id="viewer-message" class="input" type="text" aria-invalid="false" disabled />
22
+ </label>
23
+ <p id="settings-error" class="error" role="alert" aria-live="polite"></p>
24
+ </form>
25
+ </main>
26
+ <% } -%>
27
+ <script type="module" src="<%= entryModulePath %>"></script>
28
+ </body>
29
+
30
+ </html>
@@ -15,6 +15,9 @@ export default defineConfig({
15
15
  outDir: 'dist',
16
16
  rollupOptions: {
17
17
  input: {
18
+ <% if (hasSettings) { -%>
19
+ 'settings': resolve(__dirname, 'settings.html'),
20
+ <% } -%>
18
21
  <% slotsEntries.forEach(({ slotCamelCased }, i) => { -%>
19
22
  '<%= slotCamelCased %>': resolve(__dirname, '<%= slotCamelCased %>.html')<%= i < slotsEntries.length - 1 ? ',' : '' %>
20
23
  <% }); -%>
@@ -76,8 +76,8 @@ npm run template:update
76
76
  - Slots can unmount, so you should always unsubscribe from sdk events and browser apis correctly.
77
77
  - Use v1.ext.whisper.local for communication between this extension's frames for the current user.
78
78
  - Use v1.ext.whisper only when every loaded client in the room should receive the message.
79
- - When a user sends v1.ext.whisper during a public show, paymentData is required. Obtain it from v1.payment.tokens.spend.succeeded. The model can whisper without paymentData.
80
- - Broadcast whispers arrive as v1.ext.whispered
79
+ - 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.
80
+ - 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.
81
81
  - Local whispers arrive as v1.ext.whispered.local
82
82
  - Always include a stable type field in whisper payloads.
83
83
  - Do not send sensitive data in whisper payloads, including usernames or user IDs.
@@ -95,7 +95,7 @@ npm run template:update
95
95
  - Do not access the parent DOM.
96
96
  - Do not rely on host cookies.
97
97
  - Actions are strictly reviewed before publishing. Use it only when necessary (e.g. third-party API calls with secrets).
98
- - If you use EXTENSION_SLOT_MOVEABLE_OVERLAY, define moveableSlot.width and moveableSlot.height in the manifest. Max size is 450x740.
98
+ - If you use EXTENSION_SLOT_MOVEABLE_OVERLAY, no size configuration is needed in the manifest — the overlay is fixed at 450x685.
99
99
  - Do not invent new slot names or slot type constants. Use capitalized SNAKE_CASE in view.slots in manifest.
100
100
  - 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.
101
101
  - Prefer extending the generated wiring over replacing it.
@@ -113,7 +113,7 @@ npm run template:update
113
113
  - 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.
114
114
  - Broadcast Tools are rendered only for the model of the current room.
115
115
  - 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.
116
- - 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.
116
+ - 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.
117
117
  - 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`.
118
118
  - 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.
119
119
 
@@ -263,8 +263,8 @@ TV1ExtLoggedInUser:
263
263
  - v1.ext.context.get -> load initial room context.
264
264
  - v1.ext.context.updated -> react to context changes. Event payload is { context: TV1ExtContext }.
265
265
  - v1.ext.whisper.local -> send a message to every frame of the current user.
266
- - v1.ext.whisper -> broadcast to all loaded clients in the room. Users in public shows must include paymentData from v1.payment.tokens.spend.succeeded.
267
- - v1.ext.whispered -> receives whispers from v1.ext.whisper.
266
+ - 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
+ - v2.ext.whispered -> receives broadcasts from v1.ext.whisper. The event payload is `{ data, tokenSum }`, where custom fields are in `data`;
268
268
  - v1.ext.whispered.local -> receives whisper.local messages.
269
269
  - 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
270
  - 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.
@@ -275,7 +275,7 @@ TV1ExtLoggedInUser:
275
275
  - v1.model.ext.settings.set -> persist settings from settings.html.
276
276
  - v1.ext.actions.call -> call backend actions declared in the manifest.
277
277
  - v1.payment.tokens.spend -> request token spend from the current user. Result is null; confirmation arrives via event.
278
- - v1.payment.tokens.spend.succeeded -> confirm successful token spend on the paying client. Payload includes isAnonymous and paymentData (TV1PaymentData) needed for v1.ext.whisper in public shows. Prefer it over v1.tokens.spent for any games or interactions that require payment confirmation.
278
+ - v1.payment.tokens.spend.succeeded -> confirm successful token spend on the paying client. Payload includes isAnonymous and paymentHash (TV1PaymentHash) needed for v1.ext.whisper in public shows. Prefer it over v1.tokens.spent for any games or interactions that require payment confirmation.
279
279
  - 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
280
  - 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
281
  - v1.private.request -> request a private show with the broadcaster.
@@ -299,11 +299,44 @@ TV1ExtLoggedInUser:
299
299
  - v1.goal.get -> load the current active regular goal. Returns `{ goal: TV1GoalData | null }`. `goal` is `null` when no regular goal is active.
300
300
  - 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.
301
301
  - 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.
302
- - v1.session.join.user -> user-only join after payment (`payment` param is required and is taken from `paymentData` in `v1.payment.tokens.spend.succeeded`). If no session is active, this call can create one.
302
+ - 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.
303
303
  - 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).
304
304
  - v1.session.finish.model -> clear the current long-term session from the model side.
305
305
  - v1.session.whisper.user -> send a custom action payload within the current long-term session from the user to the model.
306
306
 
307
+ ### Validate broadcast payment before acting
308
+ `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;
309
+
310
+ ```js
311
+ const loadExpectedPrice = async () => {
312
+ try {
313
+ const { settings } = await ext.makeRequest('v1.ext.settings.get', null);
314
+ if (
315
+ typeof settings !== 'object' ||
316
+ settings === null ||
317
+ // priceTokens is an example field, you can name it as you wan't
318
+ !('priceTokens' in settings) ||
319
+ !isValidPrice(settings.priceTokens)
320
+ ) {
321
+ return null;
322
+ }
323
+ return settings.priceTokens;
324
+ } catch {
325
+ return null;
326
+ }
327
+ };
328
+
329
+ const expectedPrice = await loadExpectedPrice();
330
+
331
+ // later in the background slot
332
+ extHelper.susbcribe('v2.ext.whispered', ({ data, tokenSum }) => {
333
+ // tokenSum is null for a model or in private shows
334
+ if (tokenSum !== null && tokenSum !== expectedPrice) return;
335
+ // otherwise proceed with an action
336
+ })
337
+
338
+ ```
339
+
307
340
  ## Canonical Examples and Practices
308
341
 
309
342
  ### Settings save flow in settings.html
@@ -464,8 +497,8 @@ const cleanup = () => {
464
497
  ```
465
498
 
466
499
  ### User-initiated whisper with payment token (full flow)
467
- When a user needs to broadcast a whisper during a public show, paymentData is required.
468
- Flow: visual slot calls v1.payment.tokens.spend -> user background listens to v1.payment.tokens.spend.succeeded -> user background sends v1.ext.whisper with paymentData.
500
+ When a user needs to broadcast a whisper during a public show, paymentHash is required.
501
+ 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.
469
502
 
470
503
  User visual slot (initiates spend on user action):
471
504
  ```
@@ -480,7 +513,7 @@ const spinWheel = async () => {
480
513
  };
481
514
  ```
482
515
 
483
- User background slot (listens for payment confirmation, whispers with paymentData):
516
+ User background slot (listens for payment confirmation, whispers with paymentHash):
484
517
  ```
485
518
  import { createExtHelper } from '@stripchatdev/ext-helper';
486
519
  const ext = createExtHelper();
@@ -488,7 +521,7 @@ const ext = createExtHelper();
488
521
  const onSpendSucceeded = async (data) => {
489
522
  await ext.makeRequest('v1.ext.whisper', {
490
523
  data: { type: 'SPIN_RESULT', value: 7, isAnonymous: data.isAnonymous },
491
- paymentData: data.paymentData,
524
+ paymentHash: data.paymentHash,
492
525
  });
493
526
  };
494
527
  ext.subscribe('v1.payment.tokens.spend.succeeded', onSpendSucceeded);
@@ -501,15 +534,20 @@ All clients receiving the broadcast:
501
534
  ```
502
535
  import { createExtHelper } from '@stripchatdev/ext-helper';
503
536
  const ext = createExtHelper();
504
- const onWhispered = (data) => {
505
- if (data.type !== 'SPIN_RESULT') {
537
+ const expectedPrice = 10;
538
+ const onWhispered = async ({ data, tokenSum }) => {
539
+ if (data.type !== 'SPIN_RESULT' || typeof data.value !== 'number') {
540
+ return;
541
+ }
542
+ // validate that tokenSum is the same as expectedPrice.
543
+ if (tokenSum !== null && tokenSum !== expectedPrice) {
506
544
  return;
507
545
  }
508
546
  showResult(data.value);
509
547
  };
510
- ext.subscribe('v1.ext.whispered', onWhispered);
548
+ ext.subscribe('v2.ext.whispered', onWhispered);
511
549
  const cleanup = () => {
512
- ext.unsubscribe('v1.ext.whispered', onWhispered);
550
+ ext.unsubscribe('v2.ext.whispered', onWhispered);
513
551
  };
514
552
  ```
515
553
 
@@ -519,15 +557,20 @@ In rightOverlay or videoDecorativeOverlay (after user initiated whisper with pay
519
557
  ```
520
558
  import { createExtHelper } from '@stripchatdev/ext-helper';
521
559
  const ext = createExtHelper();
522
-
560
+ const expectedPrice = 10;
523
561
  const ANIMATION_DURATION = 8_000;
524
562
  const ACTIVITY_DURATION = 10_000;
525
563
 
526
- const handleWhispered = async (data) => {
527
- if ('type' in data && data.type !== 'SPIN_THE_WHEEL') {
564
+ const handleWhispered = async ({ data, tokenSum }) => {
565
+ if (data.type !== 'SPIN_RESULT' || typeof data.value !== 'number') {
528
566
  return;
529
567
  };
530
568
 
569
+ // validate that tokenSum is the same as expectedPrice.
570
+ if (tokenSum !== null && tokenSum !== expectedPrice)
571
+ return;
572
+ }
573
+
531
574
  await ext.makeRequest('v1.ext.activity.request', {
532
575
  durationMs: ACTIVITY_DURATION,
533
576
  });
@@ -545,7 +588,7 @@ const handleWhispered = async (data) => {
545
588
  });
546
589
  };
547
590
 
548
- ext.subscribe('v1.ext.whispered', handleWhispered);
591
+ ext.subscribe('v2.ext.whispered', handleWhispered);
549
592
  ```
550
593
  UI blocking should be handled in UI by listening to the activity changes. For example, in mainGameFun slot:
551
594
  ```
@@ -679,7 +722,7 @@ void init();
679
722
  ### Add moveable overlay
680
723
  1. Add EXTENSION_SLOT_MOVEABLE_OVERLAY to the slot configuration.
681
724
  2. Add a page key and root HTML file for it.
682
- 3. Add moveableSlot to the manifest with width and height. Max size is 450x740.
725
+ 3. The overlay is fixed at 450x685 — no size fields needed in the manifest.
683
726
  4. Keep public/icon.svg in the archive root.
684
727
  5. Add the HTML file to Vite input.
685
728
  6. Update the resolver to return the page key only when appropriate.
@@ -17,6 +17,9 @@ export default defineConfig({
17
17
  assetsInlineLimit: 0,
18
18
  rollupOptions: {
19
19
  input: {
20
+ <% if (hasSettings) { -%>
21
+ 'settings': resolve(__dirname, 'settings.html'),
22
+ <% } -%>
20
23
  <% slotsEntries.forEach(({ slotCamelCased }, i) => { -%>
21
24
  '<%= slotCamelCased %>': resolve(__dirname, '<%= slotCamelCased %>.html')<%= i < slotsEntries.length - 1 ? ',' : '' %>
22
25
  <% }); -%>