@yolk-sdk/emulators 0.1.0-canary.96

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 (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Route evidence manifests shared by the emulators (internal; re-exported as
3
+ * types by each emulator subpath).
4
+ *
5
+ * Emulators never import SDK code. Each emulated route instead names the
6
+ * conformance case ids whose recorded wire shapes it follows, and how well
7
+ * that shape is backed: `verified` routes follow a live observation (dated by
8
+ * `observedAt`), `unverified` routes follow synthetic placeholders. The repo
9
+ * evidence check (`pnpm packages:evidence`) validates the case ids.
10
+ */
11
+ import { Data } from 'effect'
12
+
13
+ /** How well an emulated route's wire shape is backed by live observation. */
14
+ export type EmulatorEvidence = 'verified' | 'unverified'
15
+
16
+ /** One emulated route and the conformance evidence behind its wire shape. */
17
+ export type EmulatorRouteEvidence = {
18
+ readonly method: string
19
+ /** Path template, for example `/v1/chat/completions` or `/3/invoices/{DocumentNumber}`. */
20
+ readonly path: string
21
+ /** `provider` for model/provider APIs, `connector` for connector APIs. */
22
+ readonly kind: 'provider' | 'connector'
23
+ /** True when the route changes state in the real service. */
24
+ readonly write: boolean
25
+ /** Conformance case ids whose wire claims this route follows. */
26
+ readonly caseIds: ReadonlyArray<string>
27
+ readonly evidence: EmulatorEvidence
28
+ /** Calendar date (`YYYY-MM-DD`, UTC) of the live observation behind `verified` evidence. */
29
+ readonly observedAt?: string | undefined
30
+ }
31
+
32
+ /** Response header carried by every response from an unverified route. */
33
+ export const emulatorEvidenceHeader = 'x-emulator-evidence'
34
+
35
+ /** `METHOD /path` key for a manifest route. */
36
+ export const emulatorRouteKey = (method: string, path: string): string =>
37
+ `${method.toUpperCase()} ${path}`
38
+
39
+ /**
40
+ * An emulator's manifest and its handlers disagree: a manifest route has no
41
+ * handler, or a handler has no manifest route. A bug in the emulator, thrown
42
+ * when the emulator is constructed.
43
+ */
44
+ export class EmulatorRouteUnmapped extends Data.TaggedError('EmulatorRouteUnmapped')<{
45
+ readonly route: string
46
+ readonly problem: 'no-handler' | 'no-manifest-route'
47
+ }> {
48
+ override get message(): string {
49
+ return this.problem === 'no-handler'
50
+ ? `Emulator manifest route ${this.route} has no handler`
51
+ : `Emulator handler ${this.route} has no manifest route`
52
+ }
53
+ }
54
+
55
+ export type BoundEmulatorRoute<H> = {
56
+ readonly route: EmulatorRouteEvidence
57
+ readonly handler: H
58
+ }
59
+
60
+ /**
61
+ * Pair every manifest route with its own handler (keyed by
62
+ * `emulatorRouteKey`). Throws `EmulatorRouteUnmapped` when a manifest route
63
+ * has no handler or a handler has no manifest route, so a manifest entry can
64
+ * never be served by another route's handler.
65
+ */
66
+ export const bindRouteHandlers = <H>(
67
+ routes: ReadonlyArray<EmulatorRouteEvidence>,
68
+ handlers: ReadonlyMap<string, H>
69
+ ): ReadonlyArray<BoundEmulatorRoute<H>> => {
70
+ const bound = routes.map(route => {
71
+ const key = emulatorRouteKey(route.method, route.path)
72
+ const handler = handlers.get(key)
73
+
74
+ if (handler === undefined) {
75
+ throw new EmulatorRouteUnmapped({ route: key, problem: 'no-handler' })
76
+ }
77
+
78
+ return { route, handler }
79
+ })
80
+
81
+ const manifestKeys = new Set(routes.map(route => emulatorRouteKey(route.method, route.path)))
82
+
83
+ for (const key of handlers.keys()) {
84
+ if (!manifestKeys.has(key)) {
85
+ throw new EmulatorRouteUnmapped({ route: key, problem: 'no-manifest-route' })
86
+ }
87
+ }
88
+
89
+ return bound
90
+ }
package/src/router.ts ADDED
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Effect `HttpClient` routing to emulators.
3
+ *
4
+ * Hosts pick a transport by swapping `HttpClient` layers: `EmulatedHttpClient`
5
+ * rewrites real origins to loopback emulator processes over the host's own
6
+ * client, and `InProcessHttpClient` calls emulator fetch handlers directly.
7
+ * Both fail closed on unknown origins and refuse to build when `NODE_ENV` is
8
+ * `production`. The route check runs at the send step, so every request that
9
+ * is actually sent (including redirect follow-ups and requests a host changes
10
+ * with `HttpClient.mapRequest` on top) goes through the route table. Replay
11
+ * lives in `@yolk-sdk/conformance/replay`; live traffic is the host's own
12
+ * client.
13
+ *
14
+ * @experimental
15
+ */
16
+ import { Config, Data, Effect, Layer, Match, Option, Predicate } from 'effect'
17
+ import {
18
+ FetchHttpClient,
19
+ Headers as HttpHeaders,
20
+ HttpClient,
21
+ HttpClientError,
22
+ HttpClientRequest,
23
+ HttpClientResponse
24
+ } from 'effect/unstable/http'
25
+
26
+ /** A fetch handler, such as the one returned by an emulator factory. */
27
+ export type EmulatorFetch = (request: Request) => Promise<Response>
28
+
29
+ /** A loopback emulator process (http or https on 127.0.0.0/8, `::1`, or `localhost`). */
30
+ export type EmulatorUrlTarget = {
31
+ readonly kind: 'url'
32
+ readonly baseUrl: string
33
+ }
34
+
35
+ /** An emulator fetch handler called in-process. */
36
+ export type EmulatorHandlerTarget = {
37
+ readonly kind: 'handler'
38
+ readonly fetch: EmulatorFetch
39
+ }
40
+
41
+ export type EmulatorTarget = EmulatorUrlTarget | EmulatorHandlerTarget
42
+
43
+ /**
44
+ * Maps one real origin (for example `https://ai-gateway.vercel.sh`) to an
45
+ * emulator target. Path and query of each request are kept.
46
+ */
47
+ export type EmulatorRoute<T extends EmulatorTarget = EmulatorTarget> = {
48
+ readonly origin: string
49
+ readonly target: T
50
+ }
51
+
52
+ export const EmulatorRoute = {
53
+ /** Route an origin to a loopback emulator process. */
54
+ url: (origin: string, baseUrl: string): EmulatorRoute<EmulatorUrlTarget> => ({
55
+ origin,
56
+ target: { kind: 'url', baseUrl }
57
+ }),
58
+ /** Route an origin to an in-process emulator fetch handler. */
59
+ handler: (origin: string, fetch: EmulatorFetch): EmulatorRoute<EmulatorHandlerTarget> => ({
60
+ origin,
61
+ target: { kind: 'handler', fetch }
62
+ })
63
+ } as const
64
+
65
+ /** A route table that cannot be used: bad origin, duplicate origin, wrong target, or non-loopback base URL. */
66
+ export class EmulatorRouteInvalid extends Data.TaggedError('EmulatorRouteInvalid')<{
67
+ readonly reason: string
68
+ }> {
69
+ override get message(): string {
70
+ return `Invalid emulator route: ${this.reason}`
71
+ }
72
+ }
73
+
74
+ /** The router refused to build: `NODE_ENV` is `production`, or it could not be read. */
75
+ export class EmulatorEnvironmentRefused extends Data.TaggedError('EmulatorEnvironmentRefused')<{
76
+ readonly reason: 'production' | 'unreadable'
77
+ }> {
78
+ override get message(): string {
79
+ return this.reason === 'production'
80
+ ? 'Emulator routing refuses to run when NODE_ENV is production'
81
+ : 'Emulator routing could not read NODE_ENV'
82
+ }
83
+ }
84
+
85
+ export type EmulatorRouterError = EmulatorRouteInvalid | EmulatorEnvironmentRefused
86
+
87
+ /**
88
+ * Fails when `NODE_ENV` is `production`. A missing `NODE_ENV` is allowed; an
89
+ * unreadable one fails closed.
90
+ */
91
+ const refuseProductionEnvironment: Effect.Effect<void, EmulatorEnvironmentRefused> = Effect.gen(
92
+ function* () {
93
+ const nodeEnv = yield* Config.option(Config.String('NODE_ENV'))
94
+
95
+ return Option.getOrUndefined(nodeEnv)
96
+ }
97
+ ).pipe(
98
+ Effect.mapError(() => new EmulatorEnvironmentRefused({ reason: 'unreadable' })),
99
+ Effect.flatMap(nodeEnv =>
100
+ nodeEnv?.trim().toLowerCase() === 'production'
101
+ ? Effect.fail(new EmulatorEnvironmentRefused({ reason: 'production' }))
102
+ : Effect.void
103
+ )
104
+ )
105
+
106
+ const ipv4LoopbackPattern = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/
107
+
108
+ /**
109
+ * True for `http:`/`https:` URLs whose host is loopback: `localhost`, `::1`,
110
+ * or an IPv4 address in 127.0.0.0/8 (after WHATWG URL normalization).
111
+ * IPv4-mapped IPv6 forms such as `[::ffff:127.0.0.1]` are rejected: the
112
+ * allowlist stays minimal, and `127.0.0.1` says the same thing.
113
+ */
114
+ const isLoopbackUrl = (input: string): boolean => {
115
+ if (!URL.canParse(input)) {
116
+ return false
117
+ }
118
+
119
+ const url = new URL(input)
120
+
121
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
122
+ return false
123
+ }
124
+
125
+ const host = url.hostname.toLowerCase()
126
+
127
+ return host === 'localhost' || host === '[::1]' || ipv4LoopbackPattern.test(host)
128
+ }
129
+
130
+ const validOrigin = (origin: string): string | EmulatorRouteInvalid => {
131
+ if (!URL.canParse(origin)) {
132
+ return new EmulatorRouteInvalid({ reason: 'a route origin is not an absolute URL' })
133
+ }
134
+
135
+ const url = new URL(origin)
136
+
137
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
138
+ return new EmulatorRouteInvalid({ reason: `origin ${url.origin} must be http or https` })
139
+ }
140
+
141
+ if (
142
+ url.username !== '' ||
143
+ url.password !== '' ||
144
+ (url.pathname !== '/' && url.pathname !== '') ||
145
+ url.search !== '' ||
146
+ url.hash !== ''
147
+ ) {
148
+ return new EmulatorRouteInvalid({
149
+ reason: `origin ${url.origin} must be a bare origin without credentials, path, query, or hash`
150
+ })
151
+ }
152
+
153
+ return url.origin
154
+ }
155
+
156
+ const validBaseUrl = (origin: string, baseUrl: string): URL | EmulatorRouteInvalid => {
157
+ if (!isLoopbackUrl(baseUrl)) {
158
+ return new EmulatorRouteInvalid({
159
+ reason: `the base URL for ${origin} must be http(s) on loopback (127.0.0.0/8, ::1, or localhost)`
160
+ })
161
+ }
162
+
163
+ const url = new URL(baseUrl)
164
+
165
+ if (url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') {
166
+ return new EmulatorRouteInvalid({
167
+ reason: `the base URL for ${origin} must not carry credentials, a query, or a hash`
168
+ })
169
+ }
170
+
171
+ return url
172
+ }
173
+
174
+ type RouteTable<A> = ReadonlyMap<string, A>
175
+
176
+ const buildTable = <T extends EmulatorTarget, A>(
177
+ routes: ReadonlyArray<EmulatorRoute<T>>,
178
+ kind: T['kind'],
179
+ resolve: (origin: string, target: T) => A | EmulatorRouteInvalid
180
+ ): Effect.Effect<RouteTable<A>, EmulatorRouteInvalid> =>
181
+ Effect.gen(function* () {
182
+ const table = new Map<string, A>()
183
+
184
+ for (const route of routes) {
185
+ const origin = validOrigin(route.origin)
186
+
187
+ if (origin instanceof EmulatorRouteInvalid) {
188
+ return yield* Effect.fail(origin)
189
+ }
190
+
191
+ if (route.target.kind !== kind) {
192
+ return yield* Effect.fail(
193
+ new EmulatorRouteInvalid({ reason: `the route for ${origin} needs a ${kind} target` })
194
+ )
195
+ }
196
+
197
+ if (table.has(origin)) {
198
+ return yield* Effect.fail(
199
+ new EmulatorRouteInvalid({ reason: `duplicate route for ${origin}` })
200
+ )
201
+ }
202
+
203
+ const resolved = resolve(origin, route.target)
204
+
205
+ if (resolved instanceof EmulatorRouteInvalid) {
206
+ return yield* Effect.fail(resolved)
207
+ }
208
+
209
+ table.set(origin, resolved)
210
+ }
211
+
212
+ return table
213
+ })
214
+
215
+ const requestOrigin = (url: string): string | undefined =>
216
+ URL.canParse(url) ? new URL(url).origin : undefined
217
+
218
+ /**
219
+ * Fail-closed error for a request whose origin has no route. The attached
220
+ * request carries only the method and origin (no path, query, headers, or
221
+ * body), so the message never leaks more than the origin.
222
+ */
223
+ const unroutedOriginError = (
224
+ request: HttpClientRequest.HttpClientRequest,
225
+ origin: string | undefined
226
+ ) => {
227
+ const label = origin ?? 'unparseable-origin'
228
+
229
+ return new HttpClientError.HttpClientError({
230
+ reason: new HttpClientError.TransportError({
231
+ request: HttpClientRequest.make(request.method)(origin ?? ''),
232
+ description: `no emulator route for origin ${label}`
233
+ })
234
+ })
235
+ }
236
+
237
+ const joinPaths = (basePath: string, requestPath: string): string => {
238
+ const prefix = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath
239
+
240
+ return `${prefix}${requestPath.startsWith('/') ? requestPath : `/${requestPath}`}`
241
+ }
242
+
243
+ const rewriteToBase = (requestUrl: string, base: URL): string => {
244
+ const source = new URL(requestUrl)
245
+ const target = new URL(base.toString())
246
+
247
+ target.pathname = joinPaths(base.pathname, source.pathname)
248
+ target.search = source.search
249
+ target.hash = source.hash
250
+
251
+ return target.toString()
252
+ }
253
+
254
+ /**
255
+ * Merge `RequestInit` values (later wins; `headers` are merged, not replaced).
256
+ */
257
+ /** Name/value pairs of any `HeadersInit` shape (web `Headers`, pair list, or record). */
258
+ const requestInitHeaderPairs = (headers: HeadersInit): ReadonlyArray<readonly [string, string]> => {
259
+ if (headers instanceof Headers) return [...headers.entries()]
260
+
261
+ if (Array.isArray(headers)) {
262
+ return headers.flatMap(pair => {
263
+ const [name, value] = pair
264
+
265
+ return name === undefined || value === undefined ? [] : [[name, value] as const]
266
+ })
267
+ }
268
+
269
+ return Object.entries(headers)
270
+ }
271
+
272
+ const mergeRequestInit = (
273
+ ...inits: ReadonlyArray<globalThis.RequestInit | undefined>
274
+ ): globalThis.RequestInit => {
275
+ const merged: globalThis.RequestInit = {}
276
+ // Effect's `Headers.fromInput` never throws (unlike web `Headers`), so an invalid host default
277
+ // surfaces from fetch as a typed transport error instead of a defect here.
278
+ let headers = HttpHeaders.empty
279
+
280
+ for (const init of inits) {
281
+ if (init === undefined) continue
282
+
283
+ Object.assign(merged, init)
284
+
285
+ if (init.headers !== undefined) {
286
+ headers = HttpHeaders.merge(
287
+ headers,
288
+ HttpHeaders.fromInput(requestInitHeaderPairs(init.headers))
289
+ )
290
+ }
291
+ }
292
+
293
+ return Object.keys(headers).length === 0 ? merged : { ...merged, headers: { ...headers } }
294
+ }
295
+
296
+ /**
297
+ * Run `effect` with native fetch told not to follow redirects
298
+ * (`redirect: 'manual'`). The `RequestInit` supplied here replaces the one the
299
+ * underlying `FetchHttpClient` layer captured when it was built (Effect merges
300
+ * the request-time context over the captured one), so the defaults visible
301
+ * where the emulated layer is built and where the request runs are merged in
302
+ * and only `redirect` is overridden. Only the `FetchHttpClient` transport reads
303
+ * this service.
304
+ */
305
+ const withManualRedirects =
306
+ (buildInit: globalThis.RequestInit | undefined) =>
307
+ <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
308
+ Effect.flatMap(Effect.serviceOption(FetchHttpClient.RequestInit), init =>
309
+ Effect.provideService(
310
+ effect,
311
+ FetchHttpClient.RequestInit,
312
+ mergeRequestInit(buildInit, Option.getOrUndefined(init), { redirect: 'manual' })
313
+ )
314
+ )
315
+
316
+ export const EmulatedHttpClient = {
317
+ /**
318
+ * `HttpClient` that rewrites each routed origin to its loopback emulator
319
+ * base URL (keeping path and query) and sends it through the host's real
320
+ * `HttpClient` underneath. Requests to any other origin fail closed with an
321
+ * `HttpClientError` that names only the origin.
322
+ *
323
+ * The check and rewrite run at the send step (the client's postprocess), so
324
+ * redirect follow-ups (`HttpClient.followRedirects` on top) and requests
325
+ * changed by a host's `HttpClient.mapRequest` are routed or refused too.
326
+ * With `FetchHttpClient` underneath, requests are sent with
327
+ * `redirect: 'manual'`: a 3xx from an emulator comes back to the caller as
328
+ * a 3xx and native fetch never follows it. Other `RequestInit` defaults are
329
+ * kept when they are provided where this layer is built or where the request
330
+ * runs; defaults provided only to the underlying `FetchHttpClient.layer`
331
+ * itself are replaced, so provide them around the whole client stack. Put
332
+ * `HttpClient.followRedirects` on top of this layer, never underneath it: a
333
+ * redirect loop below the route check sends follow-ups without it. Any
334
+ * other underlying client must not follow redirects by itself, because a
335
+ * redirect it follows internally never passes through the route table.
336
+ *
337
+ * Building fails with `EmulatorRouteInvalid` when a route is malformed,
338
+ * duplicated, not a `url` target, or not on loopback, and with
339
+ * `EmulatorEnvironmentRefused` when `NODE_ENV` is `production` or cannot be read.
340
+ */
341
+ layer: (
342
+ routes: ReadonlyArray<EmulatorRoute<EmulatorUrlTarget>>
343
+ ): Layer.Layer<HttpClient.HttpClient, EmulatorRouterError, HttpClient.HttpClient> =>
344
+ Layer.effect(
345
+ HttpClient.HttpClient,
346
+ Effect.gen(function* () {
347
+ yield* refuseProductionEnvironment
348
+
349
+ const table = yield* buildTable(routes, 'url', (origin, target: EmulatorUrlTarget) =>
350
+ validBaseUrl(origin, target.baseUrl)
351
+ )
352
+
353
+ const underlying = yield* HttpClient.HttpClient
354
+
355
+ // `RequestInit` defaults visible where this layer is built (for example provided around the
356
+ // whole client stack) are kept for routed requests.
357
+ const manualRedirects = withManualRedirects(
358
+ Option.getOrUndefined(yield* Effect.serviceOption(FetchHttpClient.RequestInit))
359
+ )
360
+
361
+ const route = (request: HttpClientRequest.HttpClientRequest) => {
362
+ const origin = requestOrigin(request.url)
363
+ const base = origin === undefined ? undefined : table.get(origin)
364
+
365
+ return base === undefined
366
+ ? Effect.fail(unroutedOriginError(request, origin))
367
+ : Effect.succeed(HttpClientRequest.setUrl(request, rewriteToBase(request.url, base)))
368
+ }
369
+
370
+ // Route in postprocess, not preprocess: every request sent goes through postprocess, while
371
+ // `followRedirects` skips preprocess for follow-ups and `mapRequest` runs after it.
372
+ return HttpClient.makeWith(
373
+ request => manualRedirects(underlying.postprocess(Effect.flatMap(request, route))),
374
+ underlying.preprocess
375
+ )
376
+ })
377
+ )
378
+ } as const
379
+
380
+ /** Error for a request body the in-process client cannot hand to a fetch handler. */
381
+ const unsupportedBodyError = (request: HttpClientRequest.HttpClientRequest, kind: string) =>
382
+ new HttpClientError.HttpClientError({
383
+ reason: new HttpClientError.EncodeError({
384
+ request,
385
+ description: `in-process emulators accept only Empty, Uint8Array, and string Raw bodies (got ${kind})`
386
+ })
387
+ })
388
+
389
+ const webRequestBody = (
390
+ request: HttpClientRequest.HttpClientRequest
391
+ ): Effect.Effect<BodyInit | undefined, HttpClientError.HttpClientError> => {
392
+ const body = request.body
393
+
394
+ return Match.value(body).pipe(
395
+ Match.tagsExhaustive({
396
+ Empty: () => Effect.succeed(undefined),
397
+ // Copy into an ArrayBuffer-backed view, as `Request` requires.
398
+ Uint8Array: ({ body: bytes }) => Effect.succeed<BodyInit>(new Uint8Array(bytes)),
399
+ Raw: ({ body: raw }) =>
400
+ Predicate.isString(raw)
401
+ ? Effect.succeed<BodyInit>(raw)
402
+ : Effect.fail(unsupportedBodyError(request, 'non-string Raw')),
403
+ FormData: () => Effect.fail(unsupportedBodyError(request, 'FormData')),
404
+ Stream: () => Effect.fail(unsupportedBodyError(request, 'Stream'))
405
+ })
406
+ )
407
+ }
408
+
409
+ const headerEntries = (headers: Readonly<Record<string, string>>): Array<[string, string]> =>
410
+ Object.entries(headers)
411
+
412
+ export const InProcessHttpClient = {
413
+ /**
414
+ * `HttpClient` that turns each request to a routed origin into a web
415
+ * `Request` (method, URL, headers, and an Empty, Uint8Array, or string Raw
416
+ * body) and calls that route's fetch handler directly; the web `Response`
417
+ * comes back through `HttpClientResponse.fromWeb`. Other body kinds fail
418
+ * with an `HttpClientError` (`EncodeError`). Unknown origins fail closed
419
+ * with an `HttpClientError` that names only the origin.
420
+ *
421
+ * Building fails with `EmulatorRouteInvalid` for malformed, duplicated, or
422
+ * non-`handler` routes and with `EmulatorEnvironmentRefused` when
423
+ * `NODE_ENV` is `production` or cannot be read.
424
+ */
425
+ layer: (
426
+ routes: ReadonlyArray<EmulatorRoute<EmulatorHandlerTarget>>
427
+ ): Layer.Layer<HttpClient.HttpClient, EmulatorRouterError> =>
428
+ Layer.effect(
429
+ HttpClient.HttpClient,
430
+ Effect.gen(function* () {
431
+ yield* refuseProductionEnvironment
432
+
433
+ const table = yield* buildTable(
434
+ routes,
435
+ 'handler',
436
+ (_origin, target: EmulatorHandlerTarget) => target.fetch
437
+ )
438
+
439
+ return HttpClient.make((request, url, signal) =>
440
+ Effect.gen(function* () {
441
+ const handler = table.get(url.origin)
442
+
443
+ if (handler === undefined) {
444
+ return yield* Effect.fail(unroutedOriginError(request, url.origin))
445
+ }
446
+
447
+ const body = yield* webRequestBody(request)
448
+
449
+ const webRequest = yield* Effect.try({
450
+ try: () => {
451
+ const init: RequestInit = {
452
+ method: request.method,
453
+ headers: headerEntries(request.headers),
454
+ signal
455
+ }
456
+
457
+ if (body !== undefined) {
458
+ init.body = body
459
+ }
460
+
461
+ return new Request(url.toString(), init)
462
+ },
463
+ catch: cause =>
464
+ new HttpClientError.HttpClientError({
465
+ reason: new HttpClientError.EncodeError({
466
+ request,
467
+ cause,
468
+ description: 'could not build a web Request for the in-process emulator'
469
+ })
470
+ })
471
+ })
472
+
473
+ const response = yield* Effect.tryPromise({
474
+ try: () => handler(webRequest),
475
+ catch: cause =>
476
+ new HttpClientError.HttpClientError({
477
+ reason: new HttpClientError.TransportError({
478
+ request,
479
+ cause,
480
+ description: 'the in-process emulator handler failed'
481
+ })
482
+ })
483
+ })
484
+
485
+ return HttpClientResponse.fromWeb(request, response)
486
+ })
487
+ )
488
+ })
489
+ )
490
+ } as const
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The `@emulators/core` adapter of the stateful wrapper (internal; Node-only): the `createCore`
3
+ * argument of `makeStatefulEmulator` and its variants, shared by every subpath on
4
+ * `src/stateful-emulator.ts`. It loads the core lazily (the core imports Node builtins and reads
5
+ * files at import time), defines a custom emulator whose only route runs the wrapper's dispatch
6
+ * for every forwarded request, and adapts its runtime to `StatefulCore`. The wrapper itself never
7
+ * imports the core, so it stays runtime-portable.
8
+ *
9
+ * @experimental
10
+ */
11
+ import type { EmulatorSnapshot } from '@emulators/core'
12
+ import { Predicate } from 'effect'
13
+ import type { CoreDispatch, StatefulCore } from './stateful-emulator.ts'
14
+
15
+ export type StatefulCoreOptions<State> = {
16
+ /** The core emulator's name. */
17
+ readonly name: string
18
+ /** The state the runtime starts from (already built from the seed). */
19
+ readonly initial: State
20
+ /** Decode a state the core is handed (a string is why it is invalid). */
21
+ readonly decodeState: (value: unknown) => State | string
22
+ /** The emulator's input-invalid error for an invalid seed. */
23
+ readonly inputInvalid: (input: 'seed', reason: string) => Error
24
+ }
25
+
26
+ /**
27
+ * The `createCore` of a stateful emulator: a core custom runtime seeded with `initial`, whose
28
+ * state the wrapper reads, snapshots, and restores.
29
+ */
30
+ export const statefulCoreRuntime =
31
+ <State extends object>(options: StatefulCoreOptions<State>) =>
32
+ async (dispatch: CoreDispatch<State>): Promise<StatefulCore<State>> => {
33
+ const { decodeState, initial, inputInvalid } = options
34
+
35
+ // Loaded lazily: the core imports Node builtins and reads files at import time.
36
+ const core = await import('@emulators/core')
37
+
38
+ const definition = core.defineEmulator<State>({
39
+ name: options.name,
40
+ cors: false,
41
+ state: () => initial,
42
+ validateSeed: value => {
43
+ const decoded = decodeState(value)
44
+
45
+ if (Predicate.isString(decoded)) {
46
+ throw inputInvalid('seed', decoded)
47
+ }
48
+
49
+ return decoded
50
+ },
51
+ // The wrapper matched the route already and forwards every admitted request as a POST.
52
+ setup: ({ app, state }) => {
53
+ app.post('*', context => dispatch(state, context.req.raw))
54
+ }
55
+ })
56
+
57
+ const runtime = await core.createCustomRuntime(definition, { seed: initial })
58
+
59
+ return {
60
+ fetch: request => runtime.fetch(request),
61
+ baseUrl: runtime.baseUrl,
62
+ snapshot: () => runtime.snapshot().state,
63
+ restore: state => {
64
+ const current: EmulatorSnapshot<State> = runtime.snapshot()
65
+
66
+ return runtime.restore({ ...current, state })
67
+ },
68
+ close: () => runtime.close()
69
+ }
70
+ }