@shopify/ui-extensions-server-kit 0.0.0-nightly-20250605112924

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 (260) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +74 -0
  3. package/dist/ExtensionServerClient/ExtensionServerClient.cjs.js +1 -0
  4. package/dist/ExtensionServerClient/ExtensionServerClient.d.ts +28 -0
  5. package/dist/ExtensionServerClient/ExtensionServerClient.es.js +133 -0
  6. package/dist/ExtensionServerClient/ExtensionServerClient.test.d.ts +1 -0
  7. package/dist/ExtensionServerClient/index.d.ts +2 -0
  8. package/dist/ExtensionServerClient/types.cjs.js +1 -0
  9. package/dist/ExtensionServerClient/types.d.ts +124 -0
  10. package/dist/ExtensionServerClient/types.es.js +4 -0
  11. package/dist/context/ExtensionServerProvider.cjs.js +1 -0
  12. package/dist/context/ExtensionServerProvider.d.ts +2 -0
  13. package/dist/context/ExtensionServerProvider.es.js +30 -0
  14. package/dist/context/ExtensionServerProvider.test.d.ts +1 -0
  15. package/dist/context/constants.cjs.js +1 -0
  16. package/dist/context/constants.d.ts +3 -0
  17. package/dist/context/constants.es.js +14 -0
  18. package/dist/context/index.d.ts +3 -0
  19. package/dist/context/types.d.ts +11 -0
  20. package/dist/hooks/index.d.ts +5 -0
  21. package/dist/hooks/useExtensionClient.cjs.js +1 -0
  22. package/dist/hooks/useExtensionClient.d.ts +1 -0
  23. package/dist/hooks/useExtensionClient.es.js +8 -0
  24. package/dist/hooks/useExtensionServerContext.cjs.js +1 -0
  25. package/dist/hooks/useExtensionServerContext.d.ts +1 -0
  26. package/dist/hooks/useExtensionServerContext.es.js +6 -0
  27. package/dist/hooks/useExtensionServerEvent.cjs.js +1 -0
  28. package/dist/hooks/useExtensionServerEvent.d.ts +1 -0
  29. package/dist/hooks/useExtensionServerEvent.es.js +9 -0
  30. package/dist/hooks/useExtensionServerState.cjs.js +1 -0
  31. package/dist/hooks/useExtensionServerState.d.ts +1 -0
  32. package/dist/hooks/useExtensionServerState.es.js +9 -0
  33. package/dist/hooks/useIsomorphicLayoutEffect.cjs.js +1 -0
  34. package/dist/hooks/useIsomorphicLayoutEffect.d.ts +2 -0
  35. package/dist/hooks/useIsomorphicLayoutEffect.es.js +5 -0
  36. package/dist/i18n.cjs.js +1 -0
  37. package/dist/i18n.d.ts +93 -0
  38. package/dist/i18n.es.js +61 -0
  39. package/dist/i18n.test.d.ts +1 -0
  40. package/dist/index.cjs.js +1 -0
  41. package/dist/index.cjs2.js +1 -0
  42. package/dist/index.d.ts +7 -0
  43. package/dist/index.es.js +55 -0
  44. package/dist/index.es2.js +8 -0
  45. package/dist/state/actions/actions.cjs.js +1 -0
  46. package/dist/state/actions/actions.d.ts +7 -0
  47. package/dist/state/actions/actions.es.js +44 -0
  48. package/dist/state/actions/index.d.ts +2 -0
  49. package/dist/state/actions/types.d.ts +25 -0
  50. package/dist/state/index.d.ts +2 -0
  51. package/dist/state/reducers/constants.cjs.js +1 -0
  52. package/dist/state/reducers/constants.d.ts +2 -0
  53. package/dist/state/reducers/constants.es.js +7 -0
  54. package/dist/state/reducers/extensionServerReducer.cjs.js +1 -0
  55. package/dist/state/reducers/extensionServerReducer.d.ts +3 -0
  56. package/dist/state/reducers/extensionServerReducer.es.js +57 -0
  57. package/dist/state/reducers/extensionServerReducer.test.d.ts +1 -0
  58. package/dist/state/reducers/index.d.ts +3 -0
  59. package/dist/state/reducers/types.d.ts +6 -0
  60. package/dist/testing/MockExtensionServerProvider.cjs.js +1 -0
  61. package/dist/testing/MockExtensionServerProvider.d.ts +7 -0
  62. package/dist/testing/MockExtensionServerProvider.es.js +24 -0
  63. package/dist/testing/app.cjs.js +1 -0
  64. package/dist/testing/app.d.ts +2 -0
  65. package/dist/testing/app.es.js +16 -0
  66. package/dist/testing/extensions.cjs.js +1 -0
  67. package/dist/testing/extensions.d.ts +6 -0
  68. package/dist/testing/extensions.es.js +65 -0
  69. package/dist/testing/index.d.ts +3 -0
  70. package/dist/types.cjs.js +1 -0
  71. package/dist/types.d.ts +181 -0
  72. package/dist/types.es.js +4 -0
  73. package/dist/utilities/assetToString.cjs.js +1 -0
  74. package/dist/utilities/assetToString.d.ts +2 -0
  75. package/dist/utilities/assetToString.es.js +7 -0
  76. package/dist/utilities/assetToString.test.d.ts +1 -0
  77. package/dist/utilities/groupByKey.cjs.js +1 -0
  78. package/dist/utilities/groupByKey.d.ts +3 -0
  79. package/dist/utilities/groupByKey.es.js +6 -0
  80. package/dist/utilities/index.d.ts +7 -0
  81. package/dist/utilities/isUIExtension.cjs.js +1 -0
  82. package/dist/utilities/isUIExtension.d.ts +1 -0
  83. package/dist/utilities/isUIExtension.es.js +6 -0
  84. package/dist/utilities/isValidSurface.cjs.js +1 -0
  85. package/dist/utilities/isValidSurface.d.ts +2 -0
  86. package/dist/utilities/isValidSurface.es.js +7 -0
  87. package/dist/utilities/noop.cjs.js +1 -0
  88. package/dist/utilities/noop.d.ts +1 -0
  89. package/dist/utilities/noop.es.js +5 -0
  90. package/dist/utilities/replaceUpdated.cjs.js +1 -0
  91. package/dist/utilities/replaceUpdated.d.ts +1 -0
  92. package/dist/utilities/replaceUpdated.es.js +14 -0
  93. package/dist/utilities/replaceUpdated.test.d.ts +1 -0
  94. package/dist/utilities/set.cjs.js +1 -0
  95. package/dist/utilities/set.d.ts +4 -0
  96. package/dist/utilities/set.es.js +18 -0
  97. package/dist/utilities/set.test.d.ts +1 -0
  98. package/index.d.ts +1 -0
  99. package/index.js +1 -0
  100. package/index.mjs +1 -0
  101. package/node_modules/@shopify/react-testing/LICENSE.md +21 -0
  102. package/node_modules/@shopify/react-testing/README.md +711 -0
  103. package/node_modules/@shopify/react-testing/build/cjs/TestWrapper.js +52 -0
  104. package/node_modules/@shopify/react-testing/build/cjs/_virtual/_rollupPluginBabelHelpers.js +47 -0
  105. package/node_modules/@shopify/react-testing/build/cjs/compat.js +14 -0
  106. package/node_modules/@shopify/react-testing/build/cjs/destroy.js +13 -0
  107. package/node_modules/@shopify/react-testing/build/cjs/element.js +225 -0
  108. package/node_modules/@shopify/react-testing/build/cjs/index.js +21 -0
  109. package/node_modules/@shopify/react-testing/build/cjs/matchers/components.js +46 -0
  110. package/node_modules/@shopify/react-testing/build/cjs/matchers/context.js +25 -0
  111. package/node_modules/@shopify/react-testing/build/cjs/matchers/index.js +16 -0
  112. package/node_modules/@shopify/react-testing/build/cjs/matchers/props.js +38 -0
  113. package/node_modules/@shopify/react-testing/build/cjs/matchers/strings.js +42 -0
  114. package/node_modules/@shopify/react-testing/build/cjs/matchers/utilities.js +110 -0
  115. package/node_modules/@shopify/react-testing/build/cjs/mount.js +76 -0
  116. package/node_modules/@shopify/react-testing/build/cjs/root.js +284 -0
  117. package/node_modules/@shopify/react-testing/build/cjs/toReactString.js +86 -0
  118. package/node_modules/@shopify/react-testing/build/cjs/types.js +28 -0
  119. package/node_modules/@shopify/react-testing/build/esm/TestWrapper.mjs +44 -0
  120. package/node_modules/@shopify/react-testing/build/esm/_virtual/_rollupPluginBabelHelpers.mjs +42 -0
  121. package/node_modules/@shopify/react-testing/build/esm/compat.mjs +10 -0
  122. package/node_modules/@shopify/react-testing/build/esm/destroy.mjs +9 -0
  123. package/node_modules/@shopify/react-testing/build/esm/element.mjs +221 -0
  124. package/node_modules/@shopify/react-testing/build/esm/index.mjs +5 -0
  125. package/node_modules/@shopify/react-testing/build/esm/matchers/components.mjs +41 -0
  126. package/node_modules/@shopify/react-testing/build/esm/matchers/context.mjs +21 -0
  127. package/node_modules/@shopify/react-testing/build/esm/matchers/index.mjs +14 -0
  128. package/node_modules/@shopify/react-testing/build/esm/matchers/props.mjs +33 -0
  129. package/node_modules/@shopify/react-testing/build/esm/matchers/strings.mjs +37 -0
  130. package/node_modules/@shopify/react-testing/build/esm/matchers/utilities.mjs +101 -0
  131. package/node_modules/@shopify/react-testing/build/esm/mount.mjs +70 -0
  132. package/node_modules/@shopify/react-testing/build/esm/root.mjs +275 -0
  133. package/node_modules/@shopify/react-testing/build/esm/toReactString.mjs +80 -0
  134. package/node_modules/@shopify/react-testing/build/esm/types.mjs +26 -0
  135. package/node_modules/@shopify/react-testing/build/esnext/TestWrapper.esnext +44 -0
  136. package/node_modules/@shopify/react-testing/build/esnext/compat.esnext +10 -0
  137. package/node_modules/@shopify/react-testing/build/esnext/destroy.esnext +9 -0
  138. package/node_modules/@shopify/react-testing/build/esnext/element.esnext +221 -0
  139. package/node_modules/@shopify/react-testing/build/esnext/index.esnext +5 -0
  140. package/node_modules/@shopify/react-testing/build/esnext/matchers/components.esnext +41 -0
  141. package/node_modules/@shopify/react-testing/build/esnext/matchers/context.esnext +21 -0
  142. package/node_modules/@shopify/react-testing/build/esnext/matchers/index.esnext +14 -0
  143. package/node_modules/@shopify/react-testing/build/esnext/matchers/props.esnext +33 -0
  144. package/node_modules/@shopify/react-testing/build/esnext/matchers/strings.esnext +37 -0
  145. package/node_modules/@shopify/react-testing/build/esnext/matchers/utilities.esnext +99 -0
  146. package/node_modules/@shopify/react-testing/build/esnext/mount.esnext +71 -0
  147. package/node_modules/@shopify/react-testing/build/esnext/root.esnext +275 -0
  148. package/node_modules/@shopify/react-testing/build/esnext/toReactString.esnext +80 -0
  149. package/node_modules/@shopify/react-testing/build/esnext/types.esnext +26 -0
  150. package/node_modules/@shopify/react-testing/build/ts/TestWrapper.d.ts +17 -0
  151. package/node_modules/@shopify/react-testing/build/ts/TestWrapper.d.ts.map +1 -0
  152. package/node_modules/@shopify/react-testing/build/ts/compat.d.ts +3 -0
  153. package/node_modules/@shopify/react-testing/build/ts/compat.d.ts.map +1 -0
  154. package/node_modules/@shopify/react-testing/build/ts/destroy.d.ts +2 -0
  155. package/node_modules/@shopify/react-testing/build/ts/destroy.d.ts.map +1 -0
  156. package/node_modules/@shopify/react-testing/build/ts/element.d.ts +42 -0
  157. package/node_modules/@shopify/react-testing/build/ts/element.d.ts.map +1 -0
  158. package/node_modules/@shopify/react-testing/build/ts/index.d.ts +7 -0
  159. package/node_modules/@shopify/react-testing/build/ts/index.d.ts.map +1 -0
  160. package/node_modules/@shopify/react-testing/build/ts/matchers/components.d.ts +12 -0
  161. package/node_modules/@shopify/react-testing/build/ts/matchers/components.d.ts.map +1 -0
  162. package/node_modules/@shopify/react-testing/build/ts/matchers/context.d.ts +8 -0
  163. package/node_modules/@shopify/react-testing/build/ts/matchers/context.d.ts.map +1 -0
  164. package/node_modules/@shopify/react-testing/build/ts/matchers/index.d.ts +20 -0
  165. package/node_modules/@shopify/react-testing/build/ts/matchers/index.d.ts.map +1 -0
  166. package/node_modules/@shopify/react-testing/build/ts/matchers/props.d.ts +10 -0
  167. package/node_modules/@shopify/react-testing/build/ts/matchers/props.d.ts.map +1 -0
  168. package/node_modules/@shopify/react-testing/build/ts/matchers/strings.d.ts +11 -0
  169. package/node_modules/@shopify/react-testing/build/ts/matchers/strings.d.ts.map +1 -0
  170. package/node_modules/@shopify/react-testing/build/ts/matchers/utilities.d.ts +17 -0
  171. package/node_modules/@shopify/react-testing/build/ts/matchers/utilities.d.ts.map +1 -0
  172. package/node_modules/@shopify/react-testing/build/ts/mount.d.ts +39 -0
  173. package/node_modules/@shopify/react-testing/build/ts/mount.d.ts.map +1 -0
  174. package/node_modules/@shopify/react-testing/build/ts/root.d.ts +55 -0
  175. package/node_modules/@shopify/react-testing/build/ts/root.d.ts.map +1 -0
  176. package/node_modules/@shopify/react-testing/build/ts/toReactString.d.ts +5 -0
  177. package/node_modules/@shopify/react-testing/build/ts/toReactString.d.ts.map +1 -0
  178. package/node_modules/@shopify/react-testing/build/ts/types.d.ts +89 -0
  179. package/node_modules/@shopify/react-testing/build/ts/types.d.ts.map +1 -0
  180. package/node_modules/@shopify/react-testing/index.esnext +1 -0
  181. package/node_modules/@shopify/react-testing/index.js +1 -0
  182. package/node_modules/@shopify/react-testing/index.mjs +1 -0
  183. package/node_modules/@shopify/react-testing/matchers.esnext +1 -0
  184. package/node_modules/@shopify/react-testing/matchers.js +1 -0
  185. package/node_modules/@shopify/react-testing/matchers.mjs +1 -0
  186. package/node_modules/@shopify/react-testing/package.json +69 -0
  187. package/node_modules/@shopify/ui-extensions-test-utils/CHANGELOG.md +66 -0
  188. package/node_modules/@shopify/ui-extensions-test-utils/dist/index.d.ts +3 -0
  189. package/node_modules/@shopify/ui-extensions-test-utils/dist/index.js +3 -0
  190. package/node_modules/@shopify/ui-extensions-test-utils/dist/render.d.ts +2 -0
  191. package/node_modules/@shopify/ui-extensions-test-utils/dist/render.js +5 -0
  192. package/node_modules/@shopify/ui-extensions-test-utils/dist/renderHook.d.ts +17 -0
  193. package/node_modules/@shopify/ui-extensions-test-utils/dist/renderHook.js +20 -0
  194. package/node_modules/@shopify/ui-extensions-test-utils/dist/withProviders.d.ts +9 -0
  195. package/node_modules/@shopify/ui-extensions-test-utils/dist/withProviders.js +7 -0
  196. package/node_modules/@shopify/ui-extensions-test-utils/package.json +40 -0
  197. package/node_modules/@shopify/ui-extensions-test-utils/project.json +39 -0
  198. package/node_modules/@types/react/LICENSE +21 -0
  199. package/node_modules/@types/react/README.md +16 -0
  200. package/node_modules/@types/react/experimental.d.ts +192 -0
  201. package/node_modules/@types/react/global.d.ts +151 -0
  202. package/node_modules/@types/react/index.d.ts +3175 -0
  203. package/node_modules/@types/react/jsx-dev-runtime.d.ts +2 -0
  204. package/node_modules/@types/react/jsx-runtime.d.ts +2 -0
  205. package/node_modules/@types/react/package.json +149 -0
  206. package/node_modules/@vitejs/plugin-react-refresh/LICENSE +21 -0
  207. package/node_modules/@vitejs/plugin-react-refresh/README.md +73 -0
  208. package/node_modules/@vitejs/plugin-react-refresh/index.d.ts +14 -0
  209. package/node_modules/@vitejs/plugin-react-refresh/index.js +239 -0
  210. package/node_modules/@vitejs/plugin-react-refresh/package.json +35 -0
  211. package/package.json +67 -0
  212. package/project.json +72 -0
  213. package/scripts/create-entry-files.ts +44 -0
  214. package/src/ExtensionServerClient/ExtensionServerClient.test.ts +730 -0
  215. package/src/ExtensionServerClient/ExtensionServerClient.ts +311 -0
  216. package/src/ExtensionServerClient/index.ts +2 -0
  217. package/src/ExtensionServerClient/types.ts +161 -0
  218. package/src/context/ExtensionServerProvider.test.tsx +173 -0
  219. package/src/context/ExtensionServerProvider.tsx +48 -0
  220. package/src/context/constants.ts +15 -0
  221. package/src/context/index.ts +3 -0
  222. package/src/context/types.ts +13 -0
  223. package/src/hooks/index.ts +5 -0
  224. package/src/hooks/useExtensionClient.ts +6 -0
  225. package/src/hooks/useExtensionServerContext.ts +4 -0
  226. package/src/hooks/useExtensionServerEvent.ts +11 -0
  227. package/src/hooks/useExtensionServerState.ts +6 -0
  228. package/src/hooks/useIsomorphicLayoutEffect.ts +6 -0
  229. package/src/i18n.test.ts +417 -0
  230. package/src/i18n.ts +208 -0
  231. package/src/index.ts +7 -0
  232. package/src/state/actions/actions.ts +43 -0
  233. package/src/state/actions/index.ts +2 -0
  234. package/src/state/actions/types.ts +37 -0
  235. package/src/state/index.ts +2 -0
  236. package/src/state/reducers/constants.ts +6 -0
  237. package/src/state/reducers/extensionServerReducer.test.ts +174 -0
  238. package/src/state/reducers/extensionServerReducer.ts +87 -0
  239. package/src/state/reducers/index.ts +3 -0
  240. package/src/state/reducers/types.ts +7 -0
  241. package/src/testing/MockExtensionServerProvider.tsx +36 -0
  242. package/src/testing/app.ts +15 -0
  243. package/src/testing/extensions.ts +70 -0
  244. package/src/testing/index.ts +3 -0
  245. package/src/types.ts +180 -0
  246. package/src/utilities/assetToString.test.ts +16 -0
  247. package/src/utilities/assetToString.ts +8 -0
  248. package/src/utilities/groupByKey.ts +3 -0
  249. package/src/utilities/index.ts +7 -0
  250. package/src/utilities/isUIExtension.ts +7 -0
  251. package/src/utilities/isValidSurface.ts +7 -0
  252. package/src/utilities/noop.ts +1 -0
  253. package/src/utilities/replaceUpdated.test.ts +26 -0
  254. package/src/utilities/replaceUpdated.ts +17 -0
  255. package/src/utilities/set.test.ts +19 -0
  256. package/src/utilities/set.ts +30 -0
  257. package/testing.d.ts +1 -0
  258. package/testing.js +1 -0
  259. package/testing.mjs +1 -0
  260. package/tests/setup.ts +6 -0
@@ -0,0 +1,311 @@
1
+ /* eslint-disable @typescript-eslint/no-dynamic-delete */
2
+ /* eslint-disable no-console */
3
+ import {Surface} from './types.js'
4
+ import {
5
+ FlattenedLocalization,
6
+ Localization,
7
+ TRANSLATED_KEYS,
8
+ getFlattenedLocalization,
9
+ isFlattenedTranslations,
10
+ } from '../i18n'
11
+ import {isUIExtension, isValidSurface} from '../utilities'
12
+ import {DeepPartial, ExtensionPayload, ExtensionPoint} from '../types'
13
+
14
+ export class ExtensionServerClient implements ExtensionServer.Client {
15
+ public id: string
16
+
17
+ public connection!: WebSocket
18
+
19
+ public options: ExtensionServer.Options
20
+
21
+ protected EVENT_THAT_WILL_MUTATE_THE_SERVER = ['update']
22
+
23
+ protected listeners: {[key: string]: Set<any>} = {}
24
+ protected connectionListeners: {close: Set<any>; open: Set<any>} = {close: new Set(), open: new Set()}
25
+
26
+ protected connected = false
27
+
28
+ private uiExtensionsByUuid: {[key: string]: ExtensionServer.UIExtension} = {}
29
+
30
+ constructor(options: DeepPartial<ExtensionServer.Options> = {}) {
31
+ this.id = (Math.random() + 1).toString(36).substring(7)
32
+ this.options = getValidatedOptions({
33
+ ...options,
34
+ connection: {
35
+ automaticConnect: true,
36
+ protocols: [],
37
+ ...(options.connection ?? {}),
38
+ },
39
+ }) as ExtensionServer.Options
40
+
41
+ this.setupConnection(this.options.connection.automaticConnect)
42
+ }
43
+
44
+ public connect(options: ExtensionServer.Options = {connection: {}}) {
45
+ const newOptions = mergeOptions(this.options, options)
46
+ const optionsChanged = JSON.stringify(newOptions) !== JSON.stringify(this.options)
47
+
48
+ if (optionsChanged) {
49
+ this.options = newOptions
50
+ this.setupConnection(true)
51
+ }
52
+
53
+ return () => {
54
+ this.closeConnection()
55
+ }
56
+ }
57
+
58
+ public on<TEvent extends keyof ExtensionServer.InboundEvents>(
59
+ event: TEvent,
60
+ listener: (payload: ExtensionServer.InboundEvents[TEvent]) => void,
61
+ ): () => void {
62
+ if (!this.listeners[event]) {
63
+ this.listeners[event] = new Set()
64
+ }
65
+
66
+ this.listeners[event].add(listener)
67
+ return () => this.listeners[event].delete(listener)
68
+ }
69
+
70
+ public persist<TEvent extends keyof ExtensionServer.OutboundPersistEvents>(
71
+ event: TEvent,
72
+ data: ExtensionServer.OutboundPersistEvents[TEvent],
73
+ ): void {
74
+ if (this.EVENT_THAT_WILL_MUTATE_THE_SERVER.includes(event)) {
75
+ if (!this.options.locales) {
76
+ return this.connection?.send(JSON.stringify({event, data}))
77
+ }
78
+
79
+ /**
80
+ * Since each websocket connection will have its own translated values
81
+ * we need to strip out all translated properties to prevent
82
+ * mutating the Dev Server's data
83
+ * Before:
84
+ * ```
85
+ * {
86
+ * name: 'en name'
87
+ * localization: {...},
88
+ * extensionPoints: [{
89
+ * target: 'admin.product.item.action'
90
+ * name: 'en name'
91
+ * description: 'en description'
92
+ * localization: {...}
93
+ * }],
94
+ * }
95
+ * ```
96
+ * After:
97
+ * ```
98
+ * extensionPoints: [{
99
+ * target: 'admin.product.item.action'
100
+ * }],
101
+ * }
102
+ * ```
103
+ */
104
+ data.extensions?.forEach((extension) => {
105
+ TRANSLATED_KEYS.forEach((key) => {
106
+ if (isUIExtension(extension)) {
107
+ extension.extensionPoints?.forEach((extensionPoint) => {
108
+ delete extensionPoint[key as keyof ExtensionPoint]
109
+ })
110
+ }
111
+ delete extension[key as keyof ExtensionPayload]
112
+ })
113
+ })
114
+ return this.connection?.send(JSON.stringify({event, data}))
115
+ }
116
+
117
+ console.warn(`You tried to use "persist" with a dispatch event. Please use the "emit" method instead.`)
118
+ }
119
+
120
+ public emit<TEvent extends keyof ExtensionServer.DispatchEvents>(...args: ExtensionServer.EmitArgs<TEvent>): void {
121
+ const [event, data] = args
122
+
123
+ if (this.EVENT_THAT_WILL_MUTATE_THE_SERVER.includes(event)) {
124
+ return console.warn(
125
+ `You tried to use "emit" with a the "${event}" event. Please use the "persist" method instead to persist changes to the server.`,
126
+ )
127
+ }
128
+
129
+ this.connection?.send(JSON.stringify({event: 'dispatch', data: {type: event, payload: data}}))
130
+ }
131
+
132
+ public onConnection<TEvent extends keyof typeof this.connectionListeners>(
133
+ event: TEvent,
134
+ listener: (event: Event) => void,
135
+ ): () => void {
136
+ this.connectionListeners[event].add(listener)
137
+ return () => this.connectionListeners[event].delete(listener)
138
+ }
139
+
140
+ protected initializeConnection() {
141
+ if (!this.connection) {
142
+ return
143
+ }
144
+
145
+ this.connection.addEventListener('open', (event) => {
146
+ this.connected = true
147
+ this.connectionListeners.open.forEach((listener) => listener(event))
148
+ })
149
+
150
+ this.connection.addEventListener('close', (event) => {
151
+ this.connected = false
152
+ this.connectionListeners.close.forEach((listener) => listener(event))
153
+ })
154
+
155
+ this.connection?.addEventListener('message', (message) => {
156
+ try {
157
+ const {event, data} = JSON.parse(message.data) as ExtensionServer.ServerEvents
158
+
159
+ if (event === 'dispatch') {
160
+ const {type, payload} = data
161
+ this.listeners[type]?.forEach((listener) => listener(payload))
162
+ return
163
+ }
164
+
165
+ const filteredExtensions = data.extensions
166
+ ? filterExtensionsBySurface(data.extensions, this.options.surface)
167
+ : data.extensions
168
+
169
+ this.listeners[event]?.forEach((listener) => {
170
+ listener({...data, extensions: this._getLocalizedExtensions(filteredExtensions)})
171
+ })
172
+ // eslint-disable-next-line no-catch-all/no-catch-all
173
+ } catch (err) {
174
+ console.error(
175
+ `[ExtensionServer] Something went wrong while parsing a server message:`,
176
+ err instanceof Error ? err.message : err,
177
+ )
178
+ }
179
+ })
180
+ }
181
+
182
+ protected setupConnection(connectWebsocket = true) {
183
+ if (!this.options.connection.url) {
184
+ return
185
+ }
186
+
187
+ if (!connectWebsocket) {
188
+ return
189
+ }
190
+
191
+ this.closeConnection()
192
+
193
+ this.connection = new WebSocket(this.options.connection.url, this.options.connection.protocols)
194
+
195
+ this.initializeConnection()
196
+ }
197
+
198
+ protected closeConnection() {
199
+ if (this.connected) {
200
+ this.connection?.close()
201
+ }
202
+ }
203
+
204
+ private _getLocalizedExtensions(extensions?: ExtensionPayload[]) {
205
+ return extensions?.map((extension) => {
206
+ if (!this.options.locales || !isUIExtension(extension)) {
207
+ return extension
208
+ }
209
+
210
+ const shouldUpdateTranslations =
211
+ this.uiExtensionsByUuid[extension.uuid]?.localization?.lastUpdated !== extension.localization?.lastUpdated
212
+
213
+ const localization = shouldUpdateTranslations
214
+ ? getFlattenedLocalization(extension.localization, this.options.locales)
215
+ : this.uiExtensionsByUuid[extension.uuid]?.localization ?? extension.localization
216
+
217
+ const parsedTranslation: {[key: string]: string} =
218
+ localization && isFlattenedTranslations(localization) ? JSON.parse(localization.translations) : localization
219
+
220
+ const localizedExtension = {
221
+ ...extension,
222
+ localization,
223
+ name:
224
+ parsedTranslation && extension.name.startsWith('t:')
225
+ ? this._getLocalizedValue(parsedTranslation, extension.name)
226
+ : extension.name,
227
+ ...(extension.description && {
228
+ description:
229
+ parsedTranslation && extension.description?.startsWith('t:')
230
+ ? this._getLocalizedValue(parsedTranslation, extension.description)
231
+ : extension.description,
232
+ }),
233
+ }
234
+
235
+ this.uiExtensionsByUuid[extension.uuid] = {
236
+ ...localizedExtension,
237
+ extensionPoints: this._getLocalizedExtensionPoints(localization, localizedExtension),
238
+ }
239
+
240
+ return this.uiExtensionsByUuid[extension.uuid]
241
+ })
242
+ }
243
+
244
+ private _getLocalizedExtensionPoints(
245
+ localization: FlattenedLocalization | Localization | null | undefined,
246
+ {extensionPoints, name, description}: ExtensionServer.UIExtension,
247
+ ): ExtensionPoint[] {
248
+ if (!localization || !isFlattenedTranslations(localization)) {
249
+ return extensionPoints
250
+ }
251
+
252
+ return extensionPoints?.map((extensionPoint) => {
253
+ return {
254
+ ...extensionPoint,
255
+ localization,
256
+ name,
257
+ ...(description && {description}),
258
+ }
259
+ })
260
+ }
261
+
262
+ private _getLocalizedValue(translations: {[x: string]: string}, value: string): string {
263
+ const translationKey = value.replace('t:', '')
264
+ return translations[translationKey] || value
265
+ }
266
+ }
267
+
268
+ function mergeOptions(options: ExtensionServer.Options, newOptions: ExtensionServer.Options) {
269
+ return getValidatedOptions({
270
+ ...options,
271
+ ...newOptions,
272
+ connection: {
273
+ ...options.connection,
274
+ ...newOptions.connection,
275
+ },
276
+ })
277
+ }
278
+
279
+ function getValidatedOptions<TOptions extends DeepPartial<ExtensionServer.Options>>(options: TOptions): TOptions {
280
+ if (!isValidSurface(options.surface)) {
281
+ delete options.surface
282
+ }
283
+ return options
284
+ }
285
+
286
+ function filterExtensionsBySurface(extensions: ExtensionPayload[], surface: Surface | undefined): ExtensionPayload[] {
287
+ if (!surface) {
288
+ return extensions
289
+ }
290
+
291
+ return extensions.filter((extension) => {
292
+ if (extension.surface === surface) {
293
+ return true
294
+ }
295
+
296
+ if (Array.isArray(extension.extensionPoints)) {
297
+ const extensionPoints: (string | {surface: Surface; [key: string]: any})[] = extension.extensionPoints
298
+ const extensionPointMatchingSurface = extensionPoints.filter((extensionPoint) => {
299
+ if (typeof extensionPoint === 'string') {
300
+ return false
301
+ }
302
+
303
+ return extensionPoint.surface === surface
304
+ })
305
+
306
+ return extensionPointMatchingSurface.length > 0
307
+ }
308
+
309
+ return false
310
+ })
311
+ }
@@ -0,0 +1,2 @@
1
+ export * from './ExtensionServerClient'
2
+ export * from './types'
@@ -0,0 +1,161 @@
1
+ /* eslint-disable @typescript-eslint/no-invalid-void-type */
2
+ /* eslint-disable @typescript-eslint/no-empty-interface */
3
+ import type {LocalesOptions} from '../i18n'
4
+
5
+ declare global {
6
+ namespace ExtensionServer {
7
+ /**
8
+ * Events being received by the extension server where the keys are the event names
9
+ * and the values are the payload of the given action. In case no payload is
10
+ * required, a value of void should be used.
11
+ */
12
+ interface InboundEvents {
13
+ //
14
+ }
15
+
16
+ /**
17
+ * Events being sent to the extension server where the keys are the event names
18
+ * and the values are the payload of the given action. In case no payload is
19
+ * required, a value of void should be used.
20
+ *
21
+ * Persist events are those that will generate changes on the server, like
22
+ * update and connected events. Dispatch events are those that will
23
+ * simply be proxied to the clients connected to the server.
24
+ */
25
+ interface OutboundPersistEvents {
26
+ //
27
+ }
28
+
29
+ interface DispatchEvents {
30
+ //
31
+ }
32
+
33
+ /**
34
+ * Extension server client class options. These are used to configure
35
+ * the client class.
36
+ */
37
+ interface Options {
38
+ connection: {
39
+ /**
40
+ * The absolute URL of the WebSocket.
41
+ */
42
+ url?: string
43
+
44
+ /**
45
+ * This defines if we should automatically attempt to connect when the
46
+ * class is instantiated.
47
+ *
48
+ * @defaultValue true
49
+ */
50
+ automaticConnect?: boolean
51
+
52
+ /**
53
+ * The sub-protocol selected by the server.
54
+ *
55
+ * @defaultValue []
56
+ */
57
+ protocols?: string | string[]
58
+ }
59
+ /**
60
+ * If provided the extension server will only return extensions that matches the specified surface
61
+ */
62
+ surface?: Surface
63
+
64
+ /**
65
+ * If provided the extension server will return a requested translations object with flattened
66
+ * translations for each extension matching the requested locales
67
+ */
68
+ locales?: LocalesOptions
69
+ }
70
+
71
+ /**
72
+ * Extension server client class. This class will be used to connect and
73
+ * communicate with the extension server.
74
+ */
75
+ interface Client {
76
+ /**
77
+ * Connection options
78
+ */
79
+ options: Options
80
+
81
+ /**
82
+ * Reconnecting WebSocket Client
83
+ */
84
+ connection: WebSocket
85
+
86
+ /**
87
+ * Function to add an event listener to messages coming from
88
+ * the extension server connection.
89
+ */
90
+ on<TEvent extends keyof ExtensionServer.InboundEvents>(
91
+ event: TEvent,
92
+ cb: EventListener<TEvent>,
93
+ ): EventUnsubscriber
94
+
95
+ /**
96
+ * Function to emit an event that will persist changes to the extension server.
97
+ */
98
+ persist<TEvent extends keyof OutboundPersistEvents>(event: TEvent, payload: OutboundPersistEvents[TEvent]): void
99
+
100
+ /**
101
+ * Function to emit an event to the extension server.
102
+ */
103
+ emit<TEvent extends keyof DispatchEvents>(...args: EmitArgs<TEvent>): void
104
+
105
+ /**
106
+ * Function that opens a connection with the extensions server.
107
+ */
108
+ connect(options?: Options): () => void
109
+ }
110
+
111
+ /**
112
+ * This defines how the ExtensionServer client's static class is defined and the constructor
113
+ * arguments it requires.
114
+ *
115
+ * @example
116
+ * ```
117
+ * const client = new ExtensionServer({ url: 'wss://localhost:1234' });
118
+ * ```
119
+ */
120
+ type StaticClient = Static<ExtensionServer.Client, [option?: ExtensionServer.Options]>
121
+
122
+ // Utilities
123
+
124
+ /**
125
+ * This helper type allows us to account for nullish payloads on the emit function.
126
+ * In practice, this will allow TypeScript to type-check the event being emitted
127
+ * and, if the payload isn't required, the second argument won't be necessary.
128
+ */
129
+ type EmitArgs<TEvent extends keyof ExtensionServer.DispatchEvents> =
130
+ ExtensionServer.DispatchEvents[TEvent] extends void
131
+ ? [event: TEvent]
132
+ : [event: TEvent, payload: ExtensionServer.DispatchEvents[TEvent]]
133
+
134
+ /**
135
+ * This is a helper interface that allows us to define the static methods of a given
136
+ * class. This is useful to define static methods, static properties
137
+ * and constructor variables.
138
+ */
139
+ interface Static<T = unknown, TArgs extends unknown[] = unknown[]> {
140
+ prototype: T
141
+ new (...args: TArgs): T
142
+ }
143
+
144
+ /**
145
+ * This helper creates a partial interface with exception to the defined key values.
146
+ */
147
+ type PartialExcept<TOject, TKey extends keyof TOject> = Partial<Omit<TOject, TKey>> & Pick<TOject, TKey>
148
+
149
+ type EventListener<TEvent extends keyof ExtensionServer.InboundEvents> = (
150
+ payload: ExtensionServer.InboundEvents[TEvent],
151
+ ) => void
152
+
153
+ type EventUnsubscriber = () => void
154
+ }
155
+ }
156
+
157
+ export const AVAILABLE_SURFACES = ['admin', 'checkout', 'post_purchase', 'point_of_sale', 'customer-accounts'] as const
158
+
159
+ export type Surface = (typeof AVAILABLE_SURFACES)[number]
160
+
161
+ export {}
@@ -0,0 +1,173 @@
1
+ import {ExtensionServerProvider} from './ExtensionServerProvider'
2
+ import {mockApp, mockExtension} from '../testing'
3
+ import {useExtensionServerContext} from '../hooks'
4
+ import {createConnectedAction} from '../state'
5
+ import WS from 'jest-websocket-mock'
6
+ import {renderHook, withProviders} from '@shopify/ui-extensions-test-utils'
7
+
8
+ describe('ExtensionServerProvider tests', () => {
9
+ describe('client tests', () => {
10
+ test('creates a new ExtensionServerClient instance', async () => {
11
+ const options = {connection: {url: 'ws://example-host.com:8000/extensions/'}}
12
+
13
+ const wrapper = renderHook(useExtensionServerContext, withProviders(ExtensionServerProvider), {options})
14
+
15
+ expect(wrapper.result.client).toBeDefined()
16
+ })
17
+
18
+ test('does not start a new connection if an empty url is passed', async () => {
19
+ const options = {connection: {}}
20
+
21
+ const wrapper = renderHook(useExtensionServerContext, withProviders(ExtensionServerProvider), {options})
22
+
23
+ expect(wrapper.result.client.connection).toBeUndefined()
24
+ })
25
+ })
26
+
27
+ describe('connect tests', () => {
28
+ test('starts a new connection by calling connect', async () => {
29
+ const options = {connection: {url: 'ws://example-host.com:8000/extensions/'}}
30
+ const socket = new WS(options.connection.url, {jsonProtocol: true})
31
+ const wrapper = renderHook(useExtensionServerContext, withProviders(ExtensionServerProvider), {
32
+ options: {
33
+ connection: {url: ''},
34
+ },
35
+ })
36
+
37
+ expect(socket.server.clients()).toHaveLength(0)
38
+
39
+ wrapper.act(({connect}) => connect(options))
40
+
41
+ expect(wrapper.result.client.connection).toBeDefined()
42
+ expect(socket.server.clients()).toHaveLength(1)
43
+ socket.close()
44
+ })
45
+ })
46
+
47
+ describe('dispatch tests', () => {
48
+ test('updates the state', async () => {
49
+ const options = {connection: {url: 'ws://example-host.com:8000/extensions/'}}
50
+ const app = mockApp()
51
+ const extension = mockExtension()
52
+ const payload = {app, extensions: [extension], store: 'test-store.com'}
53
+ const wrapper = renderHook(useExtensionServerContext, withProviders(ExtensionServerProvider), {options})
54
+
55
+ wrapper.act(({dispatch}) => {
56
+ dispatch({type: 'connected', payload})
57
+ })
58
+
59
+ expect(wrapper.result.state).toStrictEqual({
60
+ app,
61
+ extensions: [extension],
62
+ store: 'test-store.com',
63
+ })
64
+ })
65
+ })
66
+
67
+ describe('state tests', () => {
68
+ let socket: WS
69
+ const options = {connection: {url: 'ws://example-host.com:8000/extensions/'}}
70
+
71
+ beforeEach(() => {
72
+ socket = new WS(options.connection.url, {jsonProtocol: true})
73
+ })
74
+
75
+ afterEach(() => {
76
+ socket.close()
77
+ })
78
+
79
+ test('persists connection data to the state', async () => {
80
+ const app = mockApp()
81
+ const extension = mockExtension()
82
+ const data = {app, store: 'test-store.com', extensions: [extension]}
83
+ const wrapper = renderHook(useExtensionServerContext, withProviders(ExtensionServerProvider), {options})
84
+
85
+ wrapper.act(() => socket.send({event: 'connected', data}))
86
+
87
+ expect(wrapper.result.state).toEqual({
88
+ app,
89
+ extensions: [extension],
90
+ store: 'test-store.com',
91
+ })
92
+ })
93
+
94
+ test('persists update data to the state', async () => {
95
+ const app = mockApp()
96
+ const extension = mockExtension()
97
+ const update = {...extension, version: 'v2'}
98
+ const data = {app, store: 'test-store.com', extensions: [extension]}
99
+ const wrapper = renderHook(useExtensionServerContext, withProviders(ExtensionServerProvider), {options})
100
+
101
+ wrapper.act(({dispatch}) => {
102
+ dispatch(createConnectedAction(data))
103
+
104
+ socket.send({event: 'update', data: {...data, extensions: [update]}})
105
+ })
106
+
107
+ expect(wrapper.result.state).toEqual({
108
+ app,
109
+ extensions: [update],
110
+ store: 'test-store.com',
111
+ })
112
+ })
113
+
114
+ // eslint-disable-next-line vitest/no-disabled-tests
115
+ test.skip('persists refresh data to the state', async () => {
116
+ const app = mockApp()
117
+ const extension = mockExtension()
118
+ const data = {app, store: 'test-store.com', extensions: [extension]}
119
+ const wrapper = renderHook(useExtensionServerContext, withProviders(ExtensionServerProvider), {options})
120
+
121
+ wrapper.act(({dispatch}) => {
122
+ dispatch(createConnectedAction(data))
123
+
124
+ socket.send({
125
+ event: 'dispatch',
126
+ data: {type: 'refresh', payload: [{uuid: extension.uuid}]},
127
+ })
128
+ })
129
+
130
+ const [updatedExtension] = wrapper.result.state.extensions
131
+ expect(updatedExtension.assets.main.url).not.toEqual(extension.assets.main.url)
132
+ })
133
+
134
+ test('persists focus data to the state', async () => {
135
+ const app = mockApp()
136
+ const extension = mockExtension()
137
+ const data = {app, store: 'test-store.com', extensions: [extension]}
138
+ const wrapper = renderHook(useExtensionServerContext, withProviders(ExtensionServerProvider), {options})
139
+
140
+ wrapper.act(({dispatch}) => {
141
+ dispatch(createConnectedAction(data))
142
+
143
+ socket.send({
144
+ event: 'dispatch',
145
+ data: {type: 'focus', payload: [{uuid: extension.uuid}]},
146
+ })
147
+ })
148
+
149
+ const [updatedExtension] = wrapper.result.state.extensions
150
+ expect(updatedExtension.development.focused).toBe(true)
151
+ })
152
+
153
+ test('persists unfocus data to the state', async () => {
154
+ const app = mockApp()
155
+ const extension = mockExtension()
156
+ extension.development.focused = true
157
+ const data = {app, store: 'test-store.com', extensions: [extension]}
158
+ const wrapper = renderHook(useExtensionServerContext, withProviders(ExtensionServerProvider), {options})
159
+
160
+ wrapper.act(({dispatch}) => {
161
+ dispatch(createConnectedAction(data))
162
+
163
+ socket.send({
164
+ event: 'dispatch',
165
+ data: {type: 'unfocus'},
166
+ })
167
+ })
168
+
169
+ const [updatedExtension] = wrapper.result.state.extensions
170
+ expect(updatedExtension.development.focused).toBe(false)
171
+ })
172
+ })
173
+ })
@@ -0,0 +1,48 @@
1
+ import {extensionServerContext} from './constants'
2
+ import {
3
+ createConnectedAction,
4
+ createUpdateAction,
5
+ createRefreshAction,
6
+ createFocusAction,
7
+ createUnfocusAction,
8
+ createLogAction,
9
+ } from '../state'
10
+
11
+ import {ExtensionServerClient} from '../ExtensionServerClient'
12
+ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'
13
+ import {useExtensionServerState} from '../hooks/useExtensionServerState'
14
+ import React, {useCallback, useMemo, useState} from 'react'
15
+
16
+ import type {ExtensionServerProviderProps} from './types'
17
+
18
+ export function ExtensionServerProvider({children, options: defaultOptions}: ExtensionServerProviderProps) {
19
+ const [state, dispatch] = useExtensionServerState()
20
+ const [options, setOptions] = useState(defaultOptions)
21
+ const [client] = useState<ExtensionServer.Client>(() => new ExtensionServerClient())
22
+
23
+ const connect = useCallback(
24
+ (newOptions: ExtensionServer.Options = options) => {
25
+ setOptions(newOptions)
26
+ },
27
+ [options],
28
+ )
29
+
30
+ useIsomorphicLayoutEffect(() => client.connect(options), [client, options])
31
+
32
+ useIsomorphicLayoutEffect(() => {
33
+ const listeners = [
34
+ client.on('update', (payload) => dispatch(createUpdateAction(payload))),
35
+ client.on('connected', (payload) => dispatch(createConnectedAction(payload))),
36
+ client.on('refresh', (payload) => dispatch(createRefreshAction(payload))),
37
+ client.on('focus', (payload) => dispatch(createFocusAction(payload))),
38
+ client.on('unfocus', (payload) => dispatch(createUnfocusAction(payload))),
39
+ client.on('log', (payload) => dispatch(createLogAction(payload))),
40
+ ]
41
+
42
+ return () => listeners.forEach((unsubscribe) => unsubscribe())
43
+ }, [dispatch])
44
+
45
+ const context = useMemo(() => ({dispatch, state, connect, client}), [dispatch, connect, state, client])
46
+
47
+ return <extensionServerContext.Provider value={context}>{children}</extensionServerContext.Provider>
48
+ }