@essential-apps/shopify-test-runner 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/dist/contracts/normalize.d.ts +61 -0
  2. package/dist/contracts/normalize.d.ts.map +1 -0
  3. package/dist/contracts/normalize.js +99 -0
  4. package/dist/contracts/normalize.js.map +1 -0
  5. package/dist/contracts/normalizeHtml.d.ts +37 -0
  6. package/dist/contracts/normalizeHtml.d.ts.map +1 -0
  7. package/dist/contracts/normalizeHtml.js +89 -0
  8. package/dist/contracts/normalizeHtml.js.map +1 -0
  9. package/dist/edge/cert.d.ts +44 -0
  10. package/dist/edge/cert.d.ts.map +1 -0
  11. package/dist/edge/cert.js +117 -0
  12. package/dist/edge/cert.js.map +1 -0
  13. package/dist/edge/edgeProxy.d.ts +43 -0
  14. package/dist/edge/edgeProxy.d.ts.map +1 -0
  15. package/dist/edge/edgeProxy.js +297 -0
  16. package/dist/edge/edgeProxy.js.map +1 -0
  17. package/dist/edge/nodeShim.d.ts +2 -0
  18. package/dist/edge/nodeShim.d.ts.map +1 -0
  19. package/dist/edge/nodeShim.js +217 -0
  20. package/dist/edge/nodeShim.js.map +1 -0
  21. package/dist/index.d.ts +39 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +36 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/lib/buildSourceBundle.d.ts +56 -0
  26. package/dist/lib/buildSourceBundle.d.ts.map +1 -0
  27. package/dist/lib/buildSourceBundle.js +153 -0
  28. package/dist/lib/buildSourceBundle.js.map +1 -0
  29. package/dist/lib/freePort.d.ts +5 -0
  30. package/dist/lib/freePort.d.ts.map +1 -0
  31. package/dist/lib/freePort.js +33 -0
  32. package/dist/lib/freePort.js.map +1 -0
  33. package/dist/lib/functionBuild.d.ts +99 -0
  34. package/dist/lib/functionBuild.d.ts.map +1 -0
  35. package/dist/lib/functionBuild.js +413 -0
  36. package/dist/lib/functionBuild.js.map +1 -0
  37. package/dist/lib/neonWsProxy.d.ts +41 -0
  38. package/dist/lib/neonWsProxy.d.ts.map +1 -0
  39. package/dist/lib/neonWsProxy.js +101 -0
  40. package/dist/lib/neonWsProxy.js.map +1 -0
  41. package/dist/lib/sourceZipUpload.d.ts +45 -0
  42. package/dist/lib/sourceZipUpload.d.ts.map +1 -0
  43. package/dist/lib/sourceZipUpload.js +129 -0
  44. package/dist/lib/sourceZipUpload.js.map +1 -0
  45. package/dist/lib/stealthLaunch.d.ts +35 -0
  46. package/dist/lib/stealthLaunch.d.ts.map +1 -0
  47. package/dist/lib/stealthLaunch.js +46 -0
  48. package/dist/lib/stealthLaunch.js.map +1 -0
  49. package/dist/lib/storeAutomation.d.ts +22 -0
  50. package/dist/lib/storeAutomation.d.ts.map +1 -0
  51. package/dist/lib/storeAutomation.js +85 -0
  52. package/dist/lib/storeAutomation.js.map +1 -0
  53. package/dist/playwright/baseConfig.d.ts +62 -0
  54. package/dist/playwright/baseConfig.d.ts.map +1 -0
  55. package/dist/playwright/baseConfig.js +68 -0
  56. package/dist/playwright/baseConfig.js.map +1 -0
  57. package/dist/playwright/globalSetup.d.ts +2 -0
  58. package/dist/playwright/globalSetup.d.ts.map +1 -0
  59. package/dist/playwright/globalSetup.js +139 -0
  60. package/dist/playwright/globalSetup.js.map +1 -0
  61. package/dist/playwright/index.d.ts +9 -0
  62. package/dist/playwright/index.d.ts.map +1 -0
  63. package/dist/playwright/index.js +9 -0
  64. package/dist/playwright/index.js.map +1 -0
  65. package/dist/probes/fonts.d.ts +4 -0
  66. package/dist/probes/fonts.d.ts.map +1 -0
  67. package/dist/probes/fonts.js +255 -0
  68. package/dist/probes/fonts.js.map +1 -0
  69. package/dist/probes/mirror.d.ts +4 -0
  70. package/dist/probes/mirror.d.ts.map +1 -0
  71. package/dist/probes/mirror.js +260 -0
  72. package/dist/probes/mirror.js.map +1 -0
  73. package/dist/probes/runProbe.d.ts +3 -0
  74. package/dist/probes/runProbe.d.ts.map +1 -0
  75. package/dist/probes/runProbe.js +219 -0
  76. package/dist/probes/runProbe.js.map +1 -0
  77. package/dist/probes/types.d.ts +72 -0
  78. package/dist/probes/types.d.ts.map +1 -0
  79. package/dist/probes/types.js +2 -0
  80. package/dist/probes/types.js.map +1 -0
  81. package/dist/scripts/_probeSourceUrl.d.ts +3 -0
  82. package/dist/scripts/_probeSourceUrl.d.ts.map +1 -0
  83. package/dist/scripts/_probeSourceUrl.js +119 -0
  84. package/dist/scripts/_probeSourceUrl.js.map +1 -0
  85. package/dist/scripts/addStore.d.ts +3 -0
  86. package/dist/scripts/addStore.d.ts.map +1 -0
  87. package/dist/scripts/addStore.js +46 -0
  88. package/dist/scripts/addStore.js.map +1 -0
  89. package/dist/scripts/buildDockerImage.d.ts +3 -0
  90. package/dist/scripts/buildDockerImage.d.ts.map +1 -0
  91. package/dist/scripts/buildDockerImage.js +60 -0
  92. package/dist/scripts/buildDockerImage.js.map +1 -0
  93. package/dist/scripts/captureAuth.d.ts +3 -0
  94. package/dist/scripts/captureAuth.d.ts.map +1 -0
  95. package/dist/scripts/captureAuth.js +124 -0
  96. package/dist/scripts/captureAuth.js.map +1 -0
  97. package/dist/scripts/captureContracts.d.ts +3 -0
  98. package/dist/scripts/captureContracts.d.ts.map +1 -0
  99. package/dist/scripts/captureContracts.js +517 -0
  100. package/dist/scripts/captureContracts.js.map +1 -0
  101. package/dist/scripts/captureRestContracts.d.ts +3 -0
  102. package/dist/scripts/captureRestContracts.d.ts.map +1 -0
  103. package/dist/scripts/captureRestContracts.js +245 -0
  104. package/dist/scripts/captureRestContracts.js.map +1 -0
  105. package/dist/scripts/checkOperationCoverage.d.ts +3 -0
  106. package/dist/scripts/checkOperationCoverage.d.ts.map +1 -0
  107. package/dist/scripts/checkOperationCoverage.js +302 -0
  108. package/dist/scripts/checkOperationCoverage.js.map +1 -0
  109. package/dist/scripts/cleanupStores.d.ts +3 -0
  110. package/dist/scripts/cleanupStores.d.ts.map +1 -0
  111. package/dist/scripts/cleanupStores.js +77 -0
  112. package/dist/scripts/cleanupStores.js.map +1 -0
  113. package/dist/scripts/createStores.d.ts +3 -0
  114. package/dist/scripts/createStores.d.ts.map +1 -0
  115. package/dist/scripts/createStores.js +66 -0
  116. package/dist/scripts/createStores.js.map +1 -0
  117. package/dist/scripts/deployAppVersion.d.ts +3 -0
  118. package/dist/scripts/deployAppVersion.d.ts.map +1 -0
  119. package/dist/scripts/deployAppVersion.js +591 -0
  120. package/dist/scripts/deployAppVersion.js.map +1 -0
  121. package/dist/scripts/devE2eBackend.d.ts +3 -0
  122. package/dist/scripts/devE2eBackend.d.ts.map +1 -0
  123. package/dist/scripts/devE2eBackend.js +117 -0
  124. package/dist/scripts/devE2eBackend.js.map +1 -0
  125. package/dist/scripts/devOnlineBackend.d.ts +3 -0
  126. package/dist/scripts/devOnlineBackend.d.ts.map +1 -0
  127. package/dist/scripts/devOnlineBackend.js +117 -0
  128. package/dist/scripts/devOnlineBackend.js.map +1 -0
  129. package/dist/scripts/installApp.d.ts +3 -0
  130. package/dist/scripts/installApp.d.ts.map +1 -0
  131. package/dist/scripts/installApp.js +163 -0
  132. package/dist/scripts/installApp.js.map +1 -0
  133. package/dist/scripts/listStores.d.ts +3 -0
  134. package/dist/scripts/listStores.d.ts.map +1 -0
  135. package/dist/scripts/listStores.js +18 -0
  136. package/dist/scripts/listStores.js.map +1 -0
  137. package/dist/scripts/runDocker.d.ts +3 -0
  138. package/dist/scripts/runDocker.d.ts.map +1 -0
  139. package/dist/scripts/runDocker.js +88 -0
  140. package/dist/scripts/runDocker.js.map +1 -0
  141. package/dist/scripts/runDockerAuth.d.ts +3 -0
  142. package/dist/scripts/runDockerAuth.d.ts.map +1 -0
  143. package/dist/scripts/runDockerAuth.js +108 -0
  144. package/dist/scripts/runDockerAuth.js.map +1 -0
  145. package/dist/scripts/runDockerOffline.d.ts +3 -0
  146. package/dist/scripts/runDockerOffline.d.ts.map +1 -0
  147. package/dist/scripts/runDockerOffline.js +129 -0
  148. package/dist/scripts/runDockerOffline.js.map +1 -0
  149. package/dist/scripts/runDockerOfflineExplore.d.ts +3 -0
  150. package/dist/scripts/runDockerOfflineExplore.d.ts.map +1 -0
  151. package/dist/scripts/runDockerOfflineExplore.js +116 -0
  152. package/dist/scripts/runDockerOfflineExplore.js.map +1 -0
  153. package/dist/scripts/runIsolatedDockerOffline.d.ts +3 -0
  154. package/dist/scripts/runIsolatedDockerOffline.d.ts.map +1 -0
  155. package/dist/scripts/runIsolatedDockerOffline.js +351 -0
  156. package/dist/scripts/runIsolatedDockerOffline.js.map +1 -0
  157. package/dist/scripts/runOffline.d.ts +3 -0
  158. package/dist/scripts/runOffline.d.ts.map +1 -0
  159. package/dist/scripts/runOffline.js +521 -0
  160. package/dist/scripts/runOffline.js.map +1 -0
  161. package/dist/scripts/runOfflineE2e.d.ts +3 -0
  162. package/dist/scripts/runOfflineE2e.d.ts.map +1 -0
  163. package/dist/scripts/runOfflineE2e.js +408 -0
  164. package/dist/scripts/runOfflineE2e.js.map +1 -0
  165. package/dist/scripts/runOfflineFullTests.d.ts +3 -0
  166. package/dist/scripts/runOfflineFullTests.d.ts.map +1 -0
  167. package/dist/scripts/runOfflineFullTests.js +1456 -0
  168. package/dist/scripts/runOfflineFullTests.js.map +1 -0
  169. package/dist/scripts/runSupermachine.d.ts +3 -0
  170. package/dist/scripts/runSupermachine.d.ts.map +1 -0
  171. package/dist/scripts/runSupermachine.js +474 -0
  172. package/dist/scripts/runSupermachine.js.map +1 -0
  173. package/dist/scripts/runSupermachineAuth.d.ts +3 -0
  174. package/dist/scripts/runSupermachineAuth.d.ts.map +1 -0
  175. package/dist/scripts/runSupermachineAuth.js +454 -0
  176. package/dist/scripts/runSupermachineAuth.js.map +1 -0
  177. package/dist/scripts/runTests.d.ts +3 -0
  178. package/dist/scripts/runTests.d.ts.map +1 -0
  179. package/dist/scripts/runTests.js +278 -0
  180. package/dist/scripts/runTests.js.map +1 -0
  181. package/dist/scripts/runVm.d.ts +3 -0
  182. package/dist/scripts/runVm.d.ts.map +1 -0
  183. package/dist/scripts/runVm.js +524 -0
  184. package/dist/scripts/runVm.js.map +1 -0
  185. package/dist/scripts/runVmAuth.d.ts +3 -0
  186. package/dist/scripts/runVmAuth.d.ts.map +1 -0
  187. package/dist/scripts/runVmAuth.js +475 -0
  188. package/dist/scripts/runVmAuth.js.map +1 -0
  189. package/dist/scripts/runVmScript.d.ts +3 -0
  190. package/dist/scripts/runVmScript.d.ts.map +1 -0
  191. package/dist/scripts/runVmScript.js +242 -0
  192. package/dist/scripts/runVmScript.js.map +1 -0
  193. package/dist/scripts/setupTestDb.d.ts +3 -0
  194. package/dist/scripts/setupTestDb.d.ts.map +1 -0
  195. package/dist/scripts/setupTestDb.js +61 -0
  196. package/dist/scripts/setupTestDb.js.map +1 -0
  197. package/dist/scripts/verifyContracts.d.ts +3 -0
  198. package/dist/scripts/verifyContracts.d.ts.map +1 -0
  199. package/dist/scripts/verifyContracts.js +258 -0
  200. package/dist/scripts/verifyContracts.js.map +1 -0
  201. package/dist/scripts/verifyRestContracts.d.ts +3 -0
  202. package/dist/scripts/verifyRestContracts.d.ts.map +1 -0
  203. package/dist/scripts/verifyRestContracts.js +237 -0
  204. package/dist/scripts/verifyRestContracts.js.map +1 -0
  205. package/dist/vite/offlineConfig.d.ts +34 -0
  206. package/dist/vite/offlineConfig.d.ts.map +1 -0
  207. package/dist/vite/offlineConfig.js +61 -0
  208. package/dist/vite/offlineConfig.js.map +1 -0
  209. package/dist/vite/onlineConfig.d.ts +42 -0
  210. package/dist/vite/onlineConfig.d.ts.map +1 -0
  211. package/dist/vite/onlineConfig.js +56 -0
  212. package/dist/vite/onlineConfig.js.map +1 -0
  213. package/docker/Dockerfile +67 -0
  214. package/docker/Dockerfile.vm +137 -0
  215. package/docker/README.md +50 -0
  216. package/docker/entrypoint.sh +198 -0
  217. package/package.json +85 -0
  218. package/src/contracts/normalize.ts +96 -0
  219. package/src/contracts/normalizeHtml.ts +98 -0
  220. package/src/edge/ca.cnf +14 -0
  221. package/src/edge/ca.crt +22 -0
  222. package/src/edge/ca.key +28 -0
  223. package/src/edge/cert.ts +117 -0
  224. package/src/edge/edgeProxy.ts +390 -0
  225. package/src/edge/server.cnf +28 -0
  226. package/src/edge/server.crt +26 -0
  227. package/src/edge/server.key +28 -0
  228. package/src/index.ts +67 -0
  229. package/src/lib/buildSourceBundle.ts +197 -0
  230. package/src/lib/freePort.ts +33 -0
  231. package/src/lib/functionBuild.ts +490 -0
  232. package/src/lib/neonWsProxy.ts +124 -0
  233. package/src/lib/sourceZipUpload.ts +168 -0
  234. package/src/lib/stealthLaunch.ts +57 -0
  235. package/src/lib/storeAutomation.ts +110 -0
  236. package/src/playwright/baseConfig.ts +120 -0
  237. package/src/playwright/globalSetup.ts +179 -0
  238. package/src/playwright/index.ts +11 -0
  239. package/src/probes/fonts.ts +279 -0
  240. package/src/probes/mirror.ts +283 -0
  241. package/src/probes/runProbe.ts +257 -0
  242. package/src/probes/types.ts +73 -0
  243. package/src/scripts/addStore.ts +59 -0
  244. package/src/scripts/buildDockerImage.ts +66 -0
  245. package/src/scripts/captureAuth.ts +145 -0
  246. package/src/scripts/captureContracts.ts +675 -0
  247. package/src/scripts/captureRestContracts.ts +319 -0
  248. package/src/scripts/checkOperationCoverage.ts +365 -0
  249. package/src/scripts/cleanupStores.ts +91 -0
  250. package/src/scripts/createStores.ts +77 -0
  251. package/src/scripts/deployAppVersion.ts +692 -0
  252. package/src/scripts/devOnlineBackend.ts +141 -0
  253. package/src/scripts/installApp.ts +188 -0
  254. package/src/scripts/listStores.ts +19 -0
  255. package/src/scripts/runDockerAuth.ts +120 -0
  256. package/src/scripts/runOffline.ts +577 -0
  257. package/src/scripts/runOfflineFullTests.ts +1634 -0
  258. package/src/scripts/runTests.ts +306 -0
  259. package/src/scripts/runVm.ts +562 -0
  260. package/src/scripts/runVmAuth.ts +541 -0
  261. package/src/scripts/runVmScript.ts +282 -0
  262. package/src/scripts/setupTestDb.ts +71 -0
  263. package/src/scripts/verifyContracts.ts +310 -0
  264. package/src/scripts/verifyRestContracts.ts +275 -0
  265. package/src/vite/onlineConfig.ts +60 -0
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Compile a JS Shopify Function source tree into a deployable `.wasm`
3
+ * WITHOUT shelling out to the Shopify CLI.
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * `shopify app function build` is the canonical way to produce the
8
+ * `.wasm` artifact that Shopify Functions accepts. Tests in this repo
9
+ * are forbidden from depending on the Shopify CLI (binary mismatch
10
+ * across dev machines, interactive prompts, version drift) — so we
11
+ * replicate the pipeline directly.
12
+ *
13
+ * The pipeline is small and well-isolated. Traced from
14
+ * `Shopify/cli@packages/app/src/cli/services/function/build.ts`:
15
+ *
16
+ * 1. esbuild — bundle the user's JS source into a single ES module
17
+ * whose default export is a thin wrapper around
18
+ * `@shopify/shopify_function/run`. esbuild's Node API, no
19
+ * subprocess.
20
+ *
21
+ * 2. javy build — wraps the bundled JS in a Wizened QuickJS runtime
22
+ * AND links Shopify's host-binding plugin (`-C dynamic -C
23
+ * plugin=shopify_functions_javy_v3.wasm`). Output: a stand-alone
24
+ * .wasm module that the Shopify Functions runtime can invoke
25
+ * directly.
26
+ *
27
+ * No "trampoline" step is required for current
28
+ * `@shopify/shopify_function` runtimes — the plugin v3 + Javy 7 combo
29
+ * already targets the modern host ABI. (Trampoline exists in the CLI
30
+ * but is conditional on legacy `shopify_function_v{1,2}` imports.)
31
+ *
32
+ * Pinned versions
33
+ * ---------------
34
+ * Javy: bytecodealliance/javy v7.0.1 (stock upstream, NOT a Shopify
35
+ * fork — the Shopify-specific glue lives entirely in the
36
+ * plugin .wasm we pass to `-C plugin=`).
37
+ * Plugin: https://cdn.shopify.com/shopifycloud/shopify-functions-javy-plugin/
38
+ * shopify_functions_javy_v3.wasm
39
+ * (version-pinned in URL path; Shopify CDN, effectively
40
+ * immutable — when Shopify ships `_v4` we bump the const.)
41
+ *
42
+ * Binaries are cached at
43
+ * ~/.cache/essential-apps-shopify-test/bin/
44
+ * keyed by version so multiple test workspaces share the same download.
45
+ */
46
+ import { chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
47
+ import { execFile } from 'node:child_process';
48
+ import { homedir } from 'node:os';
49
+ import { dirname, join, resolve } from 'node:path';
50
+ import { pipeline } from 'node:stream/promises';
51
+ import { promisify } from 'node:util';
52
+ import { createGunzip } from 'node:zlib';
53
+ import { Readable } from 'node:stream';
54
+ import * as esbuild from 'esbuild';
55
+
56
+ const execFileP = promisify(execFile);
57
+
58
+ /**
59
+ * Pinned Javy version. Bumping = updating the URL pattern in
60
+ * `binaryUrlForJavy` AND verifying the `-C` flag interface hasn't
61
+ * changed across the bump (Javy did break flags between v0.x and v3+).
62
+ */
63
+ export const JAVY_VERSION = 'v7.0.1';
64
+
65
+ /**
66
+ * Pinned Shopify Functions Javy plugin wasm. The URL contains the
67
+ * major version (`_v3`) — if Shopify publishes a `_v4`, we explicitly
68
+ * decide whether to upgrade (the plugin's host ABI is what user JS
69
+ * sees via `@shopify/shopify_function`).
70
+ */
71
+ export const PLUGIN_URL =
72
+ 'https://cdn.shopify.com/shopifycloud/shopify-functions-javy-plugin/shopify_functions_javy_v3.wasm';
73
+
74
+ /** Filename under the cache dir for the plugin (derived from URL). */
75
+ const PLUGIN_FILENAME = 'shopify_functions_javy_v3.wasm';
76
+
77
+ /** `world` name we declare in the synthesized WIT — must match the
78
+ * `wit-world` flag passed to `javy build`. The literal string is
79
+ * uninteresting (Javy uses it only to disambiguate worlds in the WIT
80
+ * file); we follow the CLI's value to minimize surprise. */
81
+ const JAVY_WORLD_NAME = 'shopify-function';
82
+
83
+ function cacheDir(): string {
84
+ const dir = join(homedir(), '.cache', 'essential-apps-shopify-test', 'bin');
85
+ mkdirSync(dir, { recursive: true });
86
+ return dir;
87
+ }
88
+
89
+ /**
90
+ * Map Node's `process.{platform,arch}` to the asset-name suffix
91
+ * bytecodealliance/javy uses in its GitHub releases. Mismatches here
92
+ * surface as 404s from the GitHub redirect, so it's worth keeping the
93
+ * list explicit rather than guessing.
94
+ *
95
+ * Known asset names at v7.0.1:
96
+ * javy-arm-macos-v7.0.1.gz
97
+ * javy-x86_64-macos-v7.0.1.gz
98
+ * javy-arm-linux-v7.0.1.gz
99
+ * javy-x86_64-linux-v7.0.1.gz
100
+ * javy-x86_64-windows-v7.0.1.gz (no arm-windows)
101
+ */
102
+ function platformArch(): string {
103
+ const p = process.platform;
104
+ const a = process.arch;
105
+ const plat = p === 'darwin' ? 'macos' : p === 'linux' ? 'linux' : p === 'win32' ? 'windows' : null;
106
+ const arch = a === 'arm64' ? 'arm' : a === 'x64' ? 'x86_64' : null;
107
+ if (!plat || !arch) {
108
+ throw new Error(`Unsupported platform/arch for Javy: ${p}/${a}`);
109
+ }
110
+ if (plat === 'windows' && arch === 'arm') {
111
+ throw new Error(`Javy ${JAVY_VERSION} has no arm-windows build; install x86_64 Node or run under Rosetta.`);
112
+ }
113
+ return `${arch}-${plat}`;
114
+ }
115
+
116
+ function javyUrl(version: string): string {
117
+ return `https://github.com/bytecodealliance/javy/releases/download/${version}/javy-${platformArch()}-${version}.gz`;
118
+ }
119
+
120
+ /**
121
+ * Stream a remote .gz binary to a local file, decompressing on the
122
+ * way. Atomic via tmp + rename so concurrent test workers don't see
123
+ * partial files.
124
+ */
125
+ async function downloadGz(url: string, dest: string): Promise<void> {
126
+ const tmp = `${dest}.tmp.${process.pid}`;
127
+ const r = await fetch(url);
128
+ if (!r.ok || !r.body) {
129
+ throw new Error(`Failed to download ${url} — HTTP ${r.status}`);
130
+ }
131
+ // `r.body` is a Web ReadableStream; convert to a Node Readable so
132
+ // we can pipe it through Node's gunzip + file sink.
133
+ const src = Readable.fromWeb(r.body as never);
134
+ await pipeline(src, createGunzip(), createWriteStream(tmp));
135
+ // chmod before rename so the executable bit is set atomically with
136
+ // the visible filename.
137
+ chmodSync(tmp, 0o755);
138
+ await import('node:fs/promises').then(({ rename }) => rename(tmp, dest));
139
+ }
140
+
141
+ /**
142
+ * Download a remote file as-is (no decompression). Used for the plugin
143
+ * wasm which is served uncompressed by Shopify's CDN.
144
+ */
145
+ async function downloadFile(url: string, dest: string): Promise<void> {
146
+ const tmp = `${dest}.tmp.${process.pid}`;
147
+ const r = await fetch(url);
148
+ if (!r.ok || !r.body) {
149
+ throw new Error(`Failed to download ${url} — HTTP ${r.status}`);
150
+ }
151
+ const src = Readable.fromWeb(r.body as never);
152
+ await pipeline(src, createWriteStream(tmp));
153
+ await import('node:fs/promises').then(({ rename }) => rename(tmp, dest));
154
+ }
155
+
156
+ /**
157
+ * Return a path to the Javy binary, downloading it on first use.
158
+ * Idempotent — second call hits the on-disk cache. `JAVY_BIN`
159
+ * environment variable bypasses the download and points at a local
160
+ * build (useful when tracking Javy main).
161
+ */
162
+ export async function ensureJavy(): Promise<string> {
163
+ const override = process.env['JAVY_BIN'];
164
+ if (override) {
165
+ if (!existsSync(override)) {
166
+ throw new Error(`JAVY_BIN points at non-existent file: ${override}`);
167
+ }
168
+ return override;
169
+ }
170
+ const dest = join(cacheDir(), `javy-${JAVY_VERSION}`);
171
+ if (existsSync(dest)) return dest;
172
+ const url = javyUrl(JAVY_VERSION);
173
+ console.log(` ↓ Downloading Javy ${JAVY_VERSION} from ${url}`);
174
+ await downloadGz(url, dest);
175
+ return dest;
176
+ }
177
+
178
+ /**
179
+ * Return a path to the Shopify Functions Javy plugin wasm,
180
+ * downloading it on first use. `JAVY_PLUGIN_WASM` env overrides for
181
+ * local plugin development.
182
+ */
183
+ export async function ensureJavyPlugin(): Promise<string> {
184
+ const override = process.env['JAVY_PLUGIN_WASM'];
185
+ if (override) {
186
+ if (!existsSync(override)) {
187
+ throw new Error(`JAVY_PLUGIN_WASM points at non-existent file: ${override}`);
188
+ }
189
+ return override;
190
+ }
191
+ const dest = join(cacheDir(), PLUGIN_FILENAME);
192
+ if (existsSync(dest)) return dest;
193
+ console.log(` ↓ Downloading Shopify Functions Javy plugin from ${PLUGIN_URL}`);
194
+ await downloadFile(PLUGIN_URL, dest);
195
+ return dest;
196
+ }
197
+
198
+ /**
199
+ * One `[[extensions.targeting]]` block in a function's
200
+ * `shopify.extension.toml`. Maps to one exported function in the
201
+ * compiled wasm.
202
+ *
203
+ * target e.g. "cart.lines.discounts.generate.run" (Shopify-defined)
204
+ * export e.g. "cart-lines-discounts-generate-run" (what we name
205
+ * the wasm export — Shopify's docs use kebab-case here)
206
+ * input_query path (relative to the function dir) of the GraphQL
207
+ * query that produces the function's input — embedded
208
+ * into the deploy manifest, NOT compiled into the wasm.
209
+ */
210
+ export interface FunctionTarget {
211
+ target: string;
212
+ export: string;
213
+ inputQueryPath: string;
214
+ }
215
+
216
+ /**
217
+ * Subset of `shopify.extension.toml` that we care about for the build.
218
+ * We don't validate fields we don't use (e.g. `name`/`description`
219
+ * which come from locales) so this shape may grow as we touch more
220
+ * function deploys.
221
+ */
222
+ export interface ParsedFunctionToml {
223
+ handle: string;
224
+ /** Stable per-extension UUID baked into the toml. Shopify uses it
225
+ * to correlate the manifest entry with the right folder inside the
226
+ * source zip (the zip layout is `<uid>/dist/function.wasm`). */
227
+ uid: string;
228
+ type: 'function';
229
+ apiVersion: string;
230
+ /** Each target becomes one exported function in the compiled wasm. */
231
+ targets: FunctionTarget[];
232
+ /** `[extensions.build].path` — defaults to `dist/function.wasm`. */
233
+ outputRelativePath: string;
234
+ }
235
+
236
+ /**
237
+ * Bespoke parser for the function's `shopify.extension.toml`. The
238
+ * minimal-toml reader in `deployAppVersion.ts` doesn't grok arrays of
239
+ * inline tables (`[[extensions.targeting]]`), so we do a tiny ad-hoc
240
+ * parse here. It's intentionally narrow — only handles the shape
241
+ * `shopify app function init` produces (essentially: a single
242
+ * `[[extensions]]` table containing N `[[extensions.targeting]]`
243
+ * sub-tables and one `[extensions.build]` sub-table).
244
+ *
245
+ * If/when we deploy a function with multiple top-level `[[extensions]]`
246
+ * entries (rare — usually one file per extension dir), this needs
247
+ * generalizing.
248
+ */
249
+ export function parseFunctionToml(path: string): ParsedFunctionToml {
250
+ const txt = readFileSync(path, 'utf8');
251
+ const lines = txt.split('\n').map((l) => l.replace(/#.*$/, '').replace(/\s+$/, ''));
252
+
253
+ // We track the most-recently-opened table header so kv-lines are
254
+ // attached to the right place.
255
+ let section: 'extensions' | 'targeting' | 'build' | 'other' = 'other';
256
+
257
+ const targetingBlocks: Array<Partial<FunctionTarget>> = [];
258
+ let cur: Partial<FunctionTarget> | null = null;
259
+ const ext: { handle?: string; uid?: string; type?: string } = {};
260
+ let apiVersion = '';
261
+ const build: { path?: string } = {};
262
+
263
+ for (const raw of lines) {
264
+ const line = raw.trim();
265
+ if (!line) continue;
266
+ if (line === '[[extensions]]') {
267
+ section = 'extensions';
268
+ continue;
269
+ }
270
+ if (line === '[[extensions.targeting]]') {
271
+ section = 'targeting';
272
+ cur = {};
273
+ targetingBlocks.push(cur);
274
+ continue;
275
+ }
276
+ if (line === '[extensions.build]') {
277
+ section = 'build';
278
+ continue;
279
+ }
280
+ if (line.startsWith('[')) {
281
+ section = 'other';
282
+ continue;
283
+ }
284
+ const m = line.match(/^([a-z_][a-z0-9_]*)\s*=\s*"(.*)"$/i);
285
+ if (!m) continue;
286
+ const [, k, v] = m;
287
+ if (section === 'targeting' && cur) {
288
+ if (k === 'target') cur.target = v!;
289
+ else if (k === 'export') cur.export = v!;
290
+ else if (k === 'input_query') cur.inputQueryPath = v!;
291
+ } else if (section === 'extensions') {
292
+ if (k === 'handle') ext.handle = v!;
293
+ else if (k === 'uid') ext.uid = v!;
294
+ else if (k === 'type') ext.type = v!;
295
+ } else if (section === 'build') {
296
+ if (k === 'path') build.path = v!;
297
+ } else if (section === 'other') {
298
+ if (k === 'api_version') apiVersion = v!;
299
+ }
300
+ }
301
+ if (!ext.handle || !ext.uid || ext.type !== 'function') {
302
+ throw new Error(
303
+ `Invalid function toml at ${path} — expected [[extensions]] with handle/uid/type=function. Got: ${JSON.stringify(ext)}`,
304
+ );
305
+ }
306
+ const targets: FunctionTarget[] = [];
307
+ for (const t of targetingBlocks) {
308
+ if (!t.target || !t.export || !t.inputQueryPath) {
309
+ throw new Error(
310
+ `Invalid [[extensions.targeting]] in ${path} — needs target/export/input_query. Got: ${JSON.stringify(t)}`,
311
+ );
312
+ }
313
+ targets.push(t as FunctionTarget);
314
+ }
315
+ if (targets.length === 0) {
316
+ throw new Error(`No [[extensions.targeting]] blocks in ${path} — at least one is required.`);
317
+ }
318
+ return {
319
+ handle: ext.handle,
320
+ uid: ext.uid,
321
+ type: 'function',
322
+ apiVersion: apiVersion || 'unknown',
323
+ targets,
324
+ outputRelativePath: build.path ?? 'dist/function.wasm',
325
+ };
326
+ }
327
+
328
+ /**
329
+ * The CLI's `ExportJavyBuilder` synthesises an esbuild entry that
330
+ * re-exports each `[[extensions.targeting]].export` as a top-level
331
+ * function, delegating to the user's symbol of the same name. We
332
+ * mirror that exactly — variable names are arbitrary internal
333
+ * identifiers, only the EXPORTED names matter (they're what Javy
334
+ * sees and what Shopify's runtime invokes).
335
+ *
336
+ * Why kebab-case in the export string matches the JS identifier:
337
+ * Shopify's convention uses kebab-case in toml. The matching JS
338
+ * identifier is the camelCase form (per the Shopify CLI's
339
+ * convention). We accept both spellings — fall back to camelCase if
340
+ * the source happens not to define the literal kebab name (which is
341
+ * not a valid JS identifier anyway when used directly).
342
+ */
343
+ function jsIdent(exportName: string): string {
344
+ // kebab-case -> camelCase (Shopify CLI convention for the user-side
345
+ // entry symbol). `cart-lines-discounts-generate-run` →
346
+ // `cartLinesDiscountsGenerateRun`.
347
+ return exportName.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
348
+ }
349
+
350
+ function synthesizeEntry(targets: FunctionTarget[]): string {
351
+ // The exported NAME has to be the camelCase form of the WIT identifier.
352
+ // Javy translates WIT `%kebab-case-name` → JS export `camelCaseName` when
353
+ // resolving the world's exports. If the JS module exports the kebab-case
354
+ // string form, Javy fails with:
355
+ // "JS module does not export camelCaseName"
356
+ // (Confirmed empirically — see commit history.) The user's source code
357
+ // also exports camelCase by convention.
358
+ const importLines: string[] = [
359
+ `import __runFunction from "@shopify/shopify_function/run";`,
360
+ ];
361
+ const exportLines: string[] = [];
362
+ for (let i = 0; i < targets.length; i++) {
363
+ const t = targets[i]!;
364
+ const ident = jsIdent(t.export);
365
+ importLines.push(
366
+ `import { ${ident} as __target${i} } from "user-function";`,
367
+ );
368
+ exportLines.push(
369
+ `export function ${ident}() { return __runFunction(__target${i}); }`,
370
+ );
371
+ }
372
+ return `${importLines.join('\n')}\n${exportLines.join('\n')}\n`;
373
+ }
374
+
375
+ /**
376
+ * Synthesize the WIT world file that Javy needs to know which
377
+ * exports to lift from the JS module. The CLI generates this on the
378
+ * fly per build; we follow suit.
379
+ */
380
+ function synthesizeWit(targets: FunctionTarget[]): string {
381
+ const exports = targets
382
+ .map((t) => ` export %${t.export}: func();`)
383
+ .join('\n');
384
+ return `package shopify:function;\nworld ${JAVY_WORLD_NAME} {\n${exports}\n}\n`;
385
+ }
386
+
387
+ /** Identify the user-source entry path. Function dirs traditionally
388
+ * use `src/index.ts` (TypeScript) or `src/index.js`. We accept both
389
+ * — if neither exists, the error message points at the convention. */
390
+ function findEntrySource(functionDir: string): string {
391
+ for (const candidate of ['src/index.ts', 'src/index.js']) {
392
+ const p = resolve(functionDir, candidate);
393
+ if (existsSync(p)) return p;
394
+ }
395
+ throw new Error(
396
+ `No function entry found in ${functionDir}. Expected one of: src/index.ts, src/index.js`,
397
+ );
398
+ }
399
+
400
+ export interface BuildFunctionWasmOptions {
401
+ /** Directory containing `shopify.extension.toml` + `src/index.{ts,js}`. */
402
+ functionDir: string;
403
+ /**
404
+ * Optional override for the input source file. Defaults to
405
+ * src/index.ts / src/index.js. Useful for tests.
406
+ */
407
+ entrySource?: string;
408
+ }
409
+
410
+ export interface BuildFunctionWasmResult {
411
+ wasmPath: string;
412
+ parsedToml: ParsedFunctionToml;
413
+ }
414
+
415
+ /**
416
+ * Build a deployable `.wasm` for a JS Shopify Function.
417
+ *
418
+ * Idempotent: rebuilds on every call (we don't try to be clever about
419
+ * stale-cache detection — esbuild + javy together are ~hundreds of
420
+ * milliseconds, fast enough that incremental caching is unnecessary
421
+ * for our use case).
422
+ */
423
+ export async function buildFunctionWasm(
424
+ opts: BuildFunctionWasmOptions,
425
+ ): Promise<BuildFunctionWasmResult> {
426
+ const { functionDir } = opts;
427
+ const tomlPath = resolve(functionDir, 'shopify.extension.toml');
428
+ if (!existsSync(tomlPath)) {
429
+ throw new Error(`No shopify.extension.toml at ${tomlPath}`);
430
+ }
431
+ const toml = parseFunctionToml(tomlPath);
432
+ console.log(
433
+ ` • function ${toml.handle} (uid=${toml.uid}, ${toml.targets.length} target(s), api ${toml.apiVersion})`,
434
+ );
435
+
436
+ const entryPath = opts.entrySource ?? findEntrySource(functionDir);
437
+ const outputWasm = resolve(functionDir, toml.outputRelativePath);
438
+ const distDir = dirname(outputWasm);
439
+ mkdirSync(distDir, { recursive: true });
440
+
441
+ // Step 1: bundle JS with esbuild.
442
+ const bundledJs = resolve(distDir, 'function.js');
443
+ const entryContents = synthesizeEntry(toml.targets);
444
+ await esbuild.build({
445
+ stdin: {
446
+ contents: entryContents,
447
+ resolveDir: functionDir,
448
+ loader: 'ts',
449
+ sourcefile: 'shopify-function-entry.ts',
450
+ },
451
+ bundle: true,
452
+ format: 'esm',
453
+ target: 'es2022',
454
+ legalComments: 'none',
455
+ outfile: bundledJs,
456
+ alias: {
457
+ 'user-function': entryPath,
458
+ },
459
+ // Node-side bundler. Functions don't have access to Node APIs
460
+ // (the runtime is QuickJS via Javy), so we set platform=neutral
461
+ // and don't try to polyfill anything Node-specific.
462
+ platform: 'neutral',
463
+ // QuickJS exposes a recent-ish JS surface but ECMAScript modules
464
+ // are handled by Javy's runtime — esbuild's `format: esm` output
465
+ // is what Javy expects.
466
+ mainFields: ['module', 'main'],
467
+ conditions: ['shopify_function', 'import', 'default'],
468
+ });
469
+
470
+ // Step 2: WIT + javy build.
471
+ const witPath = resolve(distDir, 'javy-world.wit');
472
+ writeFileSync(witPath, synthesizeWit(toml.targets), 'utf8');
473
+
474
+ const [javy, plugin] = await Promise.all([ensureJavy(), ensureJavyPlugin()]);
475
+ const javyArgs = [
476
+ 'build',
477
+ '-C', 'dynamic',
478
+ '-C', `plugin=${plugin}`,
479
+ '-C', `wit=${witPath}`,
480
+ '-C', `wit-world=${JAVY_WORLD_NAME}`,
481
+ '-o', outputWasm,
482
+ bundledJs,
483
+ ];
484
+ // `cwd: functionDir` so Javy resolves any relative paths in its
485
+ // diagnostics (it doesn't, AFAICT, but parity with the CLI is
486
+ // cheap).
487
+ await execFileP(javy, javyArgs, { cwd: functionDir, maxBuffer: 50 * 1024 * 1024 });
488
+
489
+ return { wasmPath: outputWasm, parsedToml: toml };
490
+ }
@@ -0,0 +1,124 @@
1
+ import net from 'node:net';
2
+ import { WebSocketServer, type WebSocket } from 'ws';
3
+
4
+ export interface StartNeonWsProxyOptions {
5
+ /** Port to listen on (0 = OS-assigned). */
6
+ port?: number;
7
+ /** Listen host. Default 127.0.0.1. */
8
+ host?: string;
9
+ /** Postgres host to forward to. Default 127.0.0.1. */
10
+ targetHost?: string;
11
+ /** Postgres port to forward to. Default 5432. */
12
+ targetPort?: number;
13
+ }
14
+
15
+ export interface StartedNeonWsProxy {
16
+ /** Port the proxy listens on. */
17
+ port: number;
18
+ /** Stop the proxy + close all bridged sockets. */
19
+ close: () => Promise<void>;
20
+ }
21
+
22
+ /**
23
+ * Minimal WebSocket→TCP proxy implementing the transport the Neon
24
+ * serverless driver (`@neondatabase/serverless`) speaks to its
25
+ * `wsProxy`. It lets a consuming app's UNMODIFIED prisma client (which
26
+ * uses `new Pool()` from @neondatabase/serverless + `@prisma/adapter-neon`)
27
+ * talk to the offline-full suite's plain LOCAL Postgres — no app-source
28
+ * change, which is the whole point of zero-app-touch onboarding.
29
+ *
30
+ * Target resolution: when `wsProxy` is a custom function, the driver
31
+ * connects to `ws://<wsProxy()>` and does NOT append the DB address to
32
+ * the URL (verified empirically — `?address` is empty), so we forward to
33
+ * a CONFIGURED fixed Postgres (`targetHost`/`targetPort`). The Postgres
34
+ * wire-protocol startup packet (forwarded byte-for-byte) carries the
35
+ * user + database from the connection string, so a fixed server target
36
+ * is sufficient. We still honour `?address=` if a driver version sets it.
37
+ *
38
+ * `localhost` is normalised to `127.0.0.1`: some resolvers / Docker map
39
+ * localhost→::1 where Postgres may listen only on IPv4.
40
+ *
41
+ * Faithful passthrough by design: the REAL Neon driver runs unchanged;
42
+ * we only relocate its endpoint to local Postgres, avoiding the
43
+ * adapter-compat risk of swapping in a different pg driver.
44
+ */
45
+ export async function startNeonWsProxy(
46
+ opts: StartNeonWsProxyOptions = {},
47
+ ): Promise<StartedNeonWsProxy> {
48
+ const host = opts.host ?? '127.0.0.1';
49
+ const norm = (h: string): string => (h === 'localhost' ? '127.0.0.1' : h);
50
+ const defaultTargetHost = norm(opts.targetHost ?? '127.0.0.1');
51
+ const defaultTargetPort = opts.targetPort ?? 5432;
52
+
53
+ return new Promise<StartedNeonWsProxy>((resolve, reject) => {
54
+ const wss = new WebSocketServer({ port: opts.port ?? 0, host });
55
+
56
+ wss.once('error', reject);
57
+ wss.once('listening', () => {
58
+ wss.off('error', reject);
59
+ const addr = wss.address();
60
+ const port = typeof addr === 'object' && addr ? addr.port : Number(opts.port ?? 0);
61
+ resolve({
62
+ port,
63
+ close: () =>
64
+ new Promise<void>((res) => {
65
+ for (const client of wss.clients) {
66
+ try {
67
+ client.terminate();
68
+ } catch {
69
+ /* ignore */
70
+ }
71
+ }
72
+ wss.close(() => res());
73
+ }),
74
+ });
75
+ });
76
+
77
+ wss.on('connection', (ws: WebSocket, req) => {
78
+ let targetHost = defaultTargetHost;
79
+ let targetPort = defaultTargetPort;
80
+ // Honour `?address=host:port` if the driver ever provides it;
81
+ // otherwise fall back to the configured fixed Postgres.
82
+ const address = new URL(req.url ?? '/', 'http://localhost').searchParams.get('address');
83
+ if (address) {
84
+ const i = address.lastIndexOf(':');
85
+ if (i > 0) {
86
+ targetHost = norm(address.slice(0, i));
87
+ targetPort = Number(address.slice(i + 1)) || defaultTargetPort;
88
+ }
89
+ }
90
+
91
+ // net.connect queues writes until connected, so we can wire
92
+ // `ws → tcp` immediately without racing the connect.
93
+ const tcp = net.connect({ host: targetHost, port: targetPort });
94
+
95
+ let closed = false;
96
+ const cleanup = (): void => {
97
+ if (closed) return;
98
+ closed = true;
99
+ try {
100
+ ws.close();
101
+ } catch {
102
+ /* ignore */
103
+ }
104
+ try {
105
+ tcp.destroy();
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ };
110
+
111
+ // `ws` server delivers binary frames as Buffer (binaryType
112
+ // 'nodebuffer'); the Postgres wire protocol is binary.
113
+ ws.on('message', (data: Buffer) => tcp.write(data));
114
+ tcp.on('data', (data) => {
115
+ if (ws.readyState === ws.OPEN) ws.send(data);
116
+ });
117
+
118
+ ws.on('close', cleanup);
119
+ ws.on('error', cleanup);
120
+ tcp.on('close', cleanup);
121
+ tcp.on('error', cleanup);
122
+ });
123
+ });
124
+ }